diff --git a/po/es.po b/po/es.po index d15db36..3e1e5a9 100644 --- a/po/es.po +++ b/po/es.po @@ -2749,3 +2749,63 @@ msgstr "{action} · {count}" msgid "{used} used" msgstr "{used} usados" + +#: src/ui/engine_install.rs +msgid "This command will run with administrator rights:" +msgstr "Este comando se ejecutará con derechos de administrador:" + +#: src/ui/engine_install.rs +msgid "Run this command in a terminal:" +msgstr "Ejecuta este comando en una terminal:" + +#: src/ui/engine_install.rs +msgid "No automatic installation is available for this distribution yet." +msgstr "Todavía no hay instalación automática disponible para esta distribución." + +#: src/ui/engine_install.rs +msgid "Install" +msgstr "Instalar" + +#: src/ui/engine_install.rs +msgid "Copy Command" +msgstr "Copiar comando" + +#: src/ui/engine_install.rs +msgid "Installing…" +msgstr "Instalando…" + +#: src/ui/engine_install.rs +msgid "Command copied to the clipboard." +msgstr "Comando copiado al portapapeles." + +#: src/ui/engine_install.rs +msgid "The installation failed." +msgstr "La instalación ha fallado." + +#: src/ui/engine_install.rs +msgid "Exit code: {code}" +msgstr "Código de salida: {code}" + +#: src/ui/engine_install.rs +msgid "Installation Failed" +msgstr "Instalación fallida" + +#: src/ui/engine_install.rs +msgid "Sync Engine Installed" +msgstr "Motor de sincronización instalado" + +#: src/ui/engine_install.rs +msgid "The sync engine was installed. You can finish the setup now." +msgstr "Se ha instalado el motor de sincronización. Ya puedes terminar la configuración." + +#: src/ui/engine_install.rs +msgid "Install sync engine…" +msgstr "Instalar el motor de sincronización…" + +#: src/ui/engine_install.rs +msgid "On distributions without this package, install it from the AUR (for example: yay -S opencloud-desktop)." +msgstr "En distribuciones sin este paquete, instálalo desde el AUR (por ejemplo: yay -S opencloud-desktop)." + +#: src/ui/engine_install.rs +msgid "The installer crashed." +msgstr "El instalador se cerró inesperadamente." diff --git a/src/core/engine_install.rs b/src/core/engine_install.rs new file mode 100644 index 0000000..4ff6fe4 --- /dev/null +++ b/src/core/engine_install.rs @@ -0,0 +1,413 @@ +//! Install plans for the provider's sync engine package (issue #218). +//! +//! NextSync has no sync engine of its own: it delegates to `nextcloudcmd` +//! (Nextcloud) or `opencloudcmd` (OpenCloud). When the binary is missing the +//! app can now offer one-click installation instead of sending the user to a +//! terminal. This module is the pure, testable core of that feature: given the +//! provider and the package manager detected on the host, it returns the +//! exact `argv` to run under `pkexec` (fixed arguments, never a shell string, +//! never user input) plus a display/copyable form of the same command. The UI +//! layer owns the dialog, the spawn and the retry. +//! +//! Package names were verified against the distro indexes (2026-09): +//! `nextcloud-client` on Arch/Fedora/openSUSE provides `nextcloudcmd`; +//! Debian/Ubuntu split it into `nextcloud-desktop-cmd`; OpenCloud ships as +//! `opencloud-desktop` (Arch repos on Arch derivatives, AUR on plain Arch) and +//! is not packaged by apt/dnf/zypper yet, so those combinations fall back to +//! manual guidance. + +use crate::nextcloud::command::find_binary; +use crate::nextcloud::driver::Provider; + +/// A system package manager able to install the engine package. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageManager { + /// Arch Linux and derivatives (`pacman -S`). + Pacman, + /// Debian/Ubuntu and derivatives (`apt-get install`). + Apt, + /// Fedora and derivatives (`dnf install`). + Dnf, + /// openSUSE (`zypper install`). + Zypper, +} + +impl PackageManager { + /// Stable lowercase name (logs, tests). + pub const fn as_str(self) -> &'static str { + match self { + Self::Pacman => "pacman", + Self::Apt => "apt", + Self::Dnf => "dnf", + Self::Zypper => "zypper", + } + } +} + +/// An AUR helper usable as a manual fallback for AUR-only packages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AurHelper { + Paru, + Yay, +} + +impl AurHelper { + pub const fn as_str(self) -> &'static str { + match self { + Self::Paru => "paru", + Self::Yay => "yay", + } + } +} + +/// Presence of the package-management tools on the host. +/// +/// Pure data: production builds it from `$PATH` lookups via [`HostFacts::detect`], +/// tests hand-roll it, so the policy below never touches the filesystem. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HostFacts { + pub pacman: bool, + pub apt: bool, + pub dnf: bool, + pub zypper: bool, + pub paru: bool, + pub yay: bool, +} + +impl HostFacts { + /// Probe the host for package managers and AUR helpers. + pub fn detect() -> Self { + Self { + pacman: find_binary("pacman").is_some(), + apt: find_binary("apt-get").is_some(), + dnf: find_binary("dnf").is_some(), + zypper: find_binary("zypper").is_some(), + paru: find_binary("paru").is_some(), + yay: find_binary("yay").is_some(), + } + } + + /// The package manager to drive, by direct evidence (the install binary + /// exists on `$PATH`). Priority mirrors the distro families above; hosts + /// with several tools (containers) pick the first match. + pub fn package_manager(&self) -> Option { + if self.pacman { + Some(PackageManager::Pacman) + } else if self.apt { + Some(PackageManager::Apt) + } else if self.dnf { + Some(PackageManager::Dnf) + } else if self.zypper { + Some(PackageManager::Zypper) + } else { + None + } + } + + /// An AUR helper, when present (`paru` preferred over `yay`). + pub fn aur_helper(&self) -> Option { + if self.paru { + Some(AurHelper::Paru) + } else if self.yay { + Some(AurHelper::Yay) + } else { + None + } + } +} + +/// How the app can get the provider's engine installed on this host. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InstallPlan { + /// Run `argv` (already prefixed with `pkexec`) with administrator rights. + /// `command` is the same invocation as display/copy text. + Automatic { argv: Vec, command: String }, + /// No automatic route for this combination. `command` is what to run in a + /// terminal, when one is known; `None` means generic manual guidance. + Manual { command: Option }, +} + +impl InstallPlan { + /// The display/copyable command, when there is one. + pub fn command(&self) -> Option<&str> { + match self { + Self::Automatic { command, .. } => Some(command), + Self::Manual { command } => command.as_deref(), + } + } +} + +/// Build the install plan for one provider on the given host. +/// +/// The argv is fixed per (provider, package manager): no user input ever +/// reaches it, so it can be spawned directly without a shell. +pub fn install_plan(provider: Provider, facts: &HostFacts) -> InstallPlan { + let Some(pm) = facts.package_manager() else { + return manual_without_package_manager(provider, facts); + }; + let Some(package) = package_for(provider, pm) else { + // The package manager is supported but does not package this engine + // (OpenCloud on apt/dnf/zypper today): point at a manual route. + return manual_without_package_manager(provider, facts); + }; + match pm { + PackageManager::Pacman => automatic(&["pacman", "-S", "--needed", "--noconfirm", package]), + PackageManager::Apt => automatic(&["apt-get", "install", "-y", package]), + PackageManager::Dnf => automatic(&["dnf", "install", "-y", package]), + PackageManager::Zypper => automatic(&["zypper", "--non-interactive", "install", package]), + } +} + +/// The fallback when no automatic route exists. OpenCloud is AUR-only on +/// Arch hosts, so an AUR helper (when present) is the best manual pointer. +fn manual_without_package_manager(provider: Provider, facts: &HostFacts) -> InstallPlan { + let command = match (provider, facts.aur_helper()) { + (Provider::OpenCloud, Some(helper)) => Some(format!( + "{} -S --noconfirm opencloud-desktop", + helper.as_str() + )), + _ => None, + }; + InstallPlan::Manual { command } +} + +/// The distro package that provides the provider's engine binary, when the +/// distro packages it at all. +fn package_for(provider: Provider, pm: PackageManager) -> Option<&'static str> { + match (provider, pm) { + (Provider::Nextcloud, PackageManager::Pacman) => Some("nextcloud-client"), + (Provider::Nextcloud, PackageManager::Apt) => Some("nextcloud-desktop-cmd"), + (Provider::Nextcloud, PackageManager::Dnf) => Some("nextcloud-client"), + (Provider::Nextcloud, PackageManager::Zypper) => Some("nextcloud-client"), + // `opencloud-desktop` is in the Arch repos (and AUR on plain Arch); + // apt/dnf/zypper do not package it yet. + (Provider::OpenCloud, PackageManager::Pacman) => Some("opencloud-desktop"), + (Provider::OpenCloud, _) => None, + } +} + +/// Wrap a package-manager invocation in `pkexec` and precompute the display +/// form of the same command. +fn automatic(argv: &[&'static str]) -> InstallPlan { + let argv: Vec = std::iter::once("pkexec".to_string()) + .chain(argv.iter().map(|arg| (*arg).to_string())) + .collect(); + let command = argv.join(" "); + InstallPlan::Automatic { argv, command } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facts(pacman: bool, apt: bool, dnf: bool, zypper: bool) -> HostFacts { + HostFacts { + pacman, + apt, + dnf, + zypper, + paru: false, + yay: false, + } + } + + // ---- detection --------------------------------------------------------- + + #[test] + fn package_manager_is_detected_by_binary_presence_in_priority_order() { + assert_eq!( + facts(true, true, false, false).package_manager(), + Some(PackageManager::Pacman), + "pacman wins on Arch hosts that also see other tools" + ); + assert_eq!( + facts(false, true, false, false).package_manager(), + Some(PackageManager::Apt) + ); + assert_eq!( + facts(false, false, true, false).package_manager(), + Some(PackageManager::Dnf) + ); + assert_eq!( + facts(false, false, false, true).package_manager(), + Some(PackageManager::Zypper) + ); + assert_eq!(facts(false, false, false, false).package_manager(), None); + } + + #[test] + fn aur_helper_prefers_paru_over_yay() { + let mut host = facts(false, false, false, false); + assert_eq!(host.aur_helper(), None); + host.yay = true; + assert_eq!(host.aur_helper(), Some(AurHelper::Yay)); + host.paru = true; + assert_eq!(host.aur_helper(), Some(AurHelper::Paru)); + } + + // ---- install plans ----------------------------------------------------- + + #[test] + fn nextcloud_on_pacman_installs_nextcloud_client_via_pkexec() { + let plan = install_plan(Provider::Nextcloud, &facts(true, false, false, false)); + let InstallPlan::Automatic { argv, command } = plan else { + panic!("expected an automatic plan, got {plan:?}"); + }; + assert_eq!( + argv, + vec![ + "pkexec", + "pacman", + "-S", + "--needed", + "--noconfirm", + "nextcloud-client", + ] + ); + assert_eq!( + command, + "pkexec pacman -S --needed --noconfirm nextcloud-client" + ); + } + + #[test] + fn nextcloud_on_apt_installs_the_cmd_split_package() { + let plan = install_plan(Provider::Nextcloud, &facts(false, true, false, false)); + let InstallPlan::Automatic { argv, command } = plan else { + panic!("expected an automatic plan, got {plan:?}"); + }; + assert_eq!( + argv, + vec![ + "pkexec", + "apt-get", + "install", + "-y", + "nextcloud-desktop-cmd" + ] + ); + assert_eq!(command, "pkexec apt-get install -y nextcloud-desktop-cmd"); + } + + #[test] + fn nextcloud_on_dnf_and_zypper_install_nextcloud_client() { + let dnf = install_plan(Provider::Nextcloud, &facts(false, false, true, false)); + let InstallPlan::Automatic { argv, .. } = dnf else { + panic!("expected an automatic plan, got {dnf:?}"); + }; + assert_eq!( + argv, + vec!["pkexec", "dnf", "install", "-y", "nextcloud-client"] + ); + + let zypper = install_plan(Provider::Nextcloud, &facts(false, false, false, true)); + let InstallPlan::Automatic { argv, .. } = zypper else { + panic!("expected an automatic plan, got {zypper:?}"); + }; + assert_eq!( + argv, + vec![ + "pkexec", + "zypper", + "--non-interactive", + "install", + "nextcloud-client" + ] + ); + } + + #[test] + fn opencloud_on_pacman_installs_opencloud_desktop() { + let plan = install_plan(Provider::OpenCloud, &facts(true, false, false, false)); + let InstallPlan::Automatic { argv, command } = plan else { + panic!("expected an automatic plan, got {plan:?}"); + }; + assert_eq!( + argv, + vec![ + "pkexec", + "pacman", + "-S", + "--needed", + "--noconfirm", + "opencloud-desktop" + ] + ); + assert_eq!( + command, + "pkexec pacman -S --needed --noconfirm opencloud-desktop" + ); + } + + #[test] + fn opencloud_has_no_automatic_route_on_apt_dnf_or_zypper() { + for host in [ + facts(false, true, false, false), + facts(false, false, true, false), + facts(false, false, false, true), + ] { + let plan = install_plan(Provider::OpenCloud, &host); + assert_eq!(plan, InstallPlan::Manual { command: None }); + } + } + + #[test] + fn nextcloud_without_a_package_manager_falls_back_to_manual_guidance() { + let plan = install_plan(Provider::Nextcloud, &facts(false, false, false, false)); + assert_eq!(plan, InstallPlan::Manual { command: None }); + } + + #[test] + fn opencloud_without_a_package_manager_points_at_an_aur_helper() { + let mut host = facts(false, false, false, false); + host.yay = true; + let plan = install_plan(Provider::OpenCloud, &host); + assert_eq!( + plan, + InstallPlan::Manual { + command: Some("yay -S --noconfirm opencloud-desktop".to_string()) + } + ); + + host.paru = true; + let plan = install_plan(Provider::OpenCloud, &host); + assert_eq!( + plan, + InstallPlan::Manual { + command: Some("paru -S --noconfirm opencloud-desktop".to_string()) + } + ); + } + + #[test] + fn plans_never_interpolate_user_input_and_name_packages_not_binaries() { + // The provider is the only variable; every argv element must come + // from the fixed tables above (no paths, no free text). + for provider in [Provider::Nextcloud, Provider::OpenCloud] { + for host in [ + facts(true, false, false, false), + facts(false, true, false, false), + facts(false, false, true, false), + facts(false, false, false, true), + ] { + let plan = install_plan(provider, &host); + if let InstallPlan::Automatic { argv, .. } = &plan { + for arg in argv { + assert!( + !arg.contains(' '), + "argv elements are single tokens: {argv:?}" + ); + assert!(!arg.contains('/'), "no paths in argv: {argv:?}"); + assert_ne!(arg, "pkexec pacman"); + } + assert_eq!(argv[0], "pkexec", "pkexec is the elevation prefix"); + assert!(!argv.contains(&"nextcloudcmd".to_string())); + assert!(!argv.contains(&"opencloudcmd".to_string())); + } + if let Some(command) = plan.command() { + assert!(!command.is_empty()); + } + } + } + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 73bebab..3c3950e 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -7,6 +7,7 @@ pub mod conflict_files; pub mod debounce; pub mod delete_guard; pub mod desktop_integration; +pub mod engine_install; pub mod etag_store; pub mod exclusions; pub mod files_journal; diff --git a/src/ui/engine_install.rs b/src/ui/engine_install.rs new file mode 100644 index 0000000..99322a7 --- /dev/null +++ b/src/ui/engine_install.rs @@ -0,0 +1,268 @@ +//! "Install the sync engine" dialog (issue #218). +//! +//! Wherever the user meets the missing-engine state (the setup wizard's +//! finish gate, a folder row in `EngineMissing` error), this dialog offers +//! the one-click route: the exact `pkexec …` plan from +//! [`crate::core::engine_install`], or the command/guidance to do it by hand +//! when no automatic route exists. Nothing runs until the user clicks +//! Install, and after a successful install the caller's `on_installed` +//! callback re-checks the binary and retries the folder. + +use std::cell::Cell; +use std::rc::Rc; + +use libadwaita::prelude::*; + +use crate::core::engine_install::{install_plan, HostFacts, InstallPlan}; +use crate::nextcloud::driver::Provider; +use crate::ui::setup::{engine_install_hint, engine_present_for}; +use crate::util::i18n::t; + +/// The translated dialog body for one provider/plan pair (pure, testable). +pub(crate) fn install_dialog_text(provider: Provider, plan: &InstallPlan) -> String { + let hint = engine_install_hint(provider, false).unwrap_or_default(); + match plan { + InstallPlan::Automatic { command, .. } => { + format!( + "{hint}\n\n{}\n{command}", + t("This command will run with administrator rights:") + ) + } + InstallPlan::Manual { + command: Some(command), + } => format!( + "{hint}\n\n{}\n{command}", + t("Run this command in a terminal:") + ), + InstallPlan::Manual { command: None } => format!( + "{hint}\n\n{}", + t("No automatic installation is available for this distribution yet.") + ), + } +} + +/// The translated failure body: what went wrong plus the manual fallback. +pub(crate) fn install_failure_text( + provider: Provider, + plan: &InstallPlan, + code: Option, + stderr: &str, +) -> String { + let mut parts = vec![t("The installation failed.").to_string()]; + if let Some(code) = code { + parts.push(t("Exit code: {code}").replace("{code}", &code.to_string())); + } + let stderr = stderr.trim(); + if !stderr.is_empty() { + // Keep the tail only: package-manager output is long and the end + // carries the actual error. + let tail: String = stderr + .chars() + .rev() + .take(400) + .collect::() + .chars() + .rev() + .collect(); + parts.push(tail.to_string()); + } + if let Some(command) = plan.command() { + parts.push(t("Run this command in a terminal:").to_string()); + parts.push(command.to_string()); + } + if provider == Provider::OpenCloud { + parts.push( + t("On distributions without this package, install it from the AUR (for example: yay -S opencloud-desktop).") + .to_string(), + ); + } + parts.join("\n\n") +} + +/// Present the install dialog transient for `parent`. +/// +/// `on_installed` runs on the UI thread after the plan succeeded and the +/// engine binary is visible on `$PATH` (the caller retries the folder or, +/// in the wizard, tells the user they can finish the setup). +pub fn present_engine_install_dialog( + parent: &impl IsA, + provider: Provider, + on_installed: Rc, +) { + let facts = HostFacts::detect(); + let plan = install_plan(provider, &facts); + let dialog = libadwaita::AlertDialog::new( + Some(t("Sync Engine Not Installed")), + Some(&install_dialog_text(provider, &plan)), + ); + dialog.add_response("cancel", t("Cancel")); + let can_install = matches!(plan, InstallPlan::Automatic { .. }); + if can_install { + dialog.add_response("install", t("Install")); + dialog.set_response_appearance("install", libadwaita::ResponseAppearance::Suggested); + dialog.set_default_response(Some("install")); + } + if plan.command().is_some() { + dialog.add_response("copy", t("Copy Command")); + } + + let in_flight = Rc::new(Cell::new(false)); + let on_installed = Rc::new(on_installed); + dialog.connect_response(None, move |dialog, response| { + match response { + "copy" => { + if let Some(display) = gtk4::gdk::Display::default() { + display + .clipboard() + .set_text(plan.command().unwrap_or_default()); + } + dialog.set_body(t("Command copied to the clipboard.")); + } + "install" => { + // Guard against a second click while the installer runs. + if in_flight.replace(true) { + return; + } + let InstallPlan::Automatic { argv, .. } = plan.clone() else { + return; + }; + dialog.set_body(t("Installing…")); + let handle = gio::spawn_blocking(move || { + let mut command = std::process::Command::new(&argv[0]); + command.args(&argv[1..]).output() + }); + let dialog_w = dialog.clone(); + let plan_w = plan.clone(); + let on_installed = on_installed.clone(); + glib::spawn_future_local(async move { + let outcome = handle.await; + // The binary may sit in a directory the running session + // did not have on `$PATH`; re-check before declaring + // success and keep the manual fallback otherwise. + let ran_ok = matches!(&outcome, Ok(Ok(output)) if output.status.success()); + if ran_ok && engine_present_for(provider) { + dialog_w.force_close(); + on_installed(); + return; + } + let (code, stderr) = match outcome { + Ok(Ok(output)) => ( + output.status.code(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ), + Ok(Err(error)) => (None, error.to_string()), + // The blocking task panicked (not a spawn failure of + // the package manager itself). + Err(_) => (None, t("The installer crashed.").to_string()), + }; + present_install_failure_dialog(&dialog_w, provider, &plan_w, code, &stderr); + }); + } + _ => {} + } + }); + + dialog.present(Some(parent)); +} + +/// The failure path: what happened, and the manual command as fallback. +fn present_install_failure_dialog( + parent: &impl IsA, + provider: Provider, + plan: &InstallPlan, + code: Option, + stderr: &str, +) { + let dialog = libadwaita::AlertDialog::new( + Some(t("Installation Failed")), + Some(&install_failure_text(provider, plan, code, stderr)), + ); + if let Some(command) = plan.command() { + dialog.add_response("copy", t("Copy Command")); + let command = command.to_string(); + dialog.connect_response(None, move |dialog, response| { + if response == "copy" { + if let Some(display) = gtk4::gdk::Display::default() { + display.clipboard().set_text(&command); + } + dialog.set_body(t("Command copied to the clipboard.")); + } + }); + } + dialog.add_response("ok", t("OK")); + dialog.present(Some(parent)); +} + +/// The wizard's follow-up once the engine landed: finish is now unblocked. +pub fn present_engine_installed_dialog(parent: &impl IsA) { + let dialog = libadwaita::AlertDialog::new( + Some(t("Sync Engine Installed")), + Some(t( + "The sync engine was installed. You can finish the setup now.", + )), + ); + dialog.add_response("ok", t("OK")); + dialog.present(Some(parent)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::engine_install::HostFacts; + use crate::util::i18n::{reset_locale, set_locale, Locale}; + + fn automatic(provider: Provider) -> InstallPlan { + install_plan( + provider, + &HostFacts { + pacman: true, + ..HostFacts::default() + }, + ) + } + + #[test] + fn dialog_text_names_the_command_for_automatic_plans() { + set_locale(Locale::English); + let text = install_dialog_text(Provider::Nextcloud, &automatic(Provider::Nextcloud)); + assert!(text.contains("nextcloud-client")); + assert!(text.contains("This command will run with administrator rights:")); + assert!(text.contains("pkexec pacman -S --needed --noconfirm nextcloud-client")); + reset_locale(); + } + + #[test] + fn dialog_text_offers_the_terminal_command_for_manual_plans() { + set_locale(Locale::English); + let plan = InstallPlan::Manual { + command: Some("yay -S --noconfirm opencloud-desktop".to_string()), + }; + let text = install_dialog_text(Provider::OpenCloud, &plan); + assert!(text.contains("Run this command in a terminal:")); + assert!(text.contains("yay -S --noconfirm opencloud-desktop")); + // No automatic route at all: generic guidance, no command. + let text = install_dialog_text(Provider::Nextcloud, &InstallPlan::Manual { command: None }); + assert!(text.contains("No automatic installation is available")); + reset_locale(); + } + + #[test] + fn failure_text_carries_the_code_the_error_tail_and_the_fallback() { + set_locale(Locale::English); + let plan = automatic(Provider::Nextcloud); + let text = install_failure_text( + Provider::Nextcloud, + &plan, + Some(1), + "error: target not found", + ); + assert!(text.contains("The installation failed.")); + assert!(text.contains("Exit code: 1")); + assert!(text.contains("error: target not found")); + assert!(text.contains("pkexec pacman -S --needed --noconfirm nextcloud-client")); + // OpenCloud failures add the AUR hint. + let text = install_failure_text(Provider::OpenCloud, &plan, None, ""); + assert!(text.contains("install it from the AUR")); + reset_locale(); + } +} diff --git a/src/ui/folder_status.rs b/src/ui/folder_status.rs index 8e86809..ac12f76 100644 --- a/src/ui/folder_status.rs +++ b/src/ui/folder_status.rs @@ -162,6 +162,10 @@ pub fn pair_folder_runtimes( .collect() } +/// Parented dialog callback: receives the row widget as the dialog's +/// transient parent. +pub type ParentedCallback = Rc; + /// Per-folder menu callbacks. All optional; the corresponding menu item is /// omitted when `None`. #[derive(Default)] @@ -174,6 +178,10 @@ pub struct FolderRowCallbacks { pub on_pending_changes: Option>, pub on_review_deletions: Option>, pub on_resolve_conflicts: Option>, + /// Issue #218: present the "install the sync engine" dialog. The wiring + /// only sets it while the provider's engine binary is missing, so the + /// menu entry disappears once an engine is available. + pub on_install_engine: Option, } /// A GTK action row rendering one synchronized folder with live status. @@ -305,8 +313,29 @@ impl FolderStatusRow { menu_actions.add_action(&action); actions.insert(name.to_string(), action); } + // Issue #218: the install entry needs the row as its dialog parent, + // so it cannot go through the parent-less loop above. + if let Some(callback) = callbacks.on_install_engine.clone() { + let action = gio::SimpleAction::new("install-engine", None); + let row_for_dialog = row.clone(); + action.connect_activate(move |_action, _param| { + callback(row_for_dialog.upcast_ref()); + }); + menu_actions.add_action(&action); + actions.insert("install-engine".to_string(), action); + } let menu = gio::Menu::new(); + // The install entry leads while the engine is missing: it is the + // action that unblocks everything else in this menu. + if actions.contains_key("install-engine") { + let item = gio::MenuItem::new( + Some(t("Install sync engine…")), + Some("folder.install-engine"), + ); + item.set_icon(&gio::ThemedIcon::new("system-software-install-symbolic")); + menu.append_item(&item); + } if actions.contains_key("open") { let item = gio::MenuItem::new(Some(t("Open local folder")), Some("folder.open")); item.set_icon(&gio::ThemedIcon::new("folder-open-symbolic")); @@ -704,6 +733,52 @@ mod tests { reset_locale(); } + #[test] + fn install_engine_menu_item_follows_the_callback_presence() { + crate::ui::test_helpers::gtk_smoke(|| { + set_locale(Locale::English); + let folder = FolderConfig { + id: "f1".to_string(), + local_root: "/tmp/a".to_string(), + remote_path: "/docs".to_string(), + space_id: None, + size_confirmed: false, + }; + let label_at = |row: &FolderStatusRow, index: i32| { + row.menu_model + .item_attribute_value(index, "label", None) + .and_then(|value| value.str().map(str::to_string)) + }; + let has_install_entry = |row: &FolderStatusRow| { + (0..row.menu_model.n_items()) + .any(|index| label_at(row, index).as_deref() == Some("Install sync engine…")) + }; + // Without the callback the entry is absent (the default). + let without = FolderStatusRow::new( + folder.clone(), + None, + FolderRowCallbacks::default(), + None, + None, + ); + assert!(!has_install_entry(&without)); + // With the callback the entry leads the menu. + let row = FolderStatusRow::new( + folder, + None, + FolderRowCallbacks { + on_install_engine: Some(Rc::new(|_parent: >k4::Widget| {})), + ..FolderRowCallbacks::default() + }, + None, + None, + ); + assert!(row._actions.contains_key("install-engine")); + assert_eq!(label_at(&row, 0).as_deref(), Some("Install sync engine…")); + reset_locale(); + }); + } + #[test] fn pairing_matches_folders_to_runtimes_by_id() { let folders = vec![ diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index c1d6486..9c45d88 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -354,6 +354,30 @@ impl AccountView { } })) }, + // Issue #218: while the provider's engine is missing, the row + // menu offers one-click installation; a successful install + // retries the folder right away (the engine-missing state + // recovers on the next manual run). + on_install_engine: { + let provider = account.provider; + let folder_runtime = folder_runtime.clone(); + if crate::ui::setup::engine_present_for(provider) { + None + } else { + Some(Rc::new(move |parent: >k4::Widget| { + let folder_runtime = folder_runtime.clone(); + crate::ui::engine_install::present_engine_install_dialog( + parent, + provider, + Rc::new(move || { + if let Some(fr) = &folder_runtime { + fr.sync_now(); + } + }), + ); + })) + } + }, }; // No last-sync caption is rendered (the v0.4.0 folder-focused // redesign dropped it), so no scheduler query here: the row's state diff --git a/src/ui/mod.rs b/src/ui/mod.rs index be92397..4a13acb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,7 @@ pub mod about; pub mod account_settings; pub mod activity; pub mod conflict_resolver; +pub mod engine_install; pub mod folder_emblems; pub mod folder_status; pub mod main_window; diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 92838e5..445af38 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -1414,10 +1414,18 @@ fn finish_setup(ctx: &SetupContext) { // provider in the state is the freshly selected one, so switching // providers re-evaluates the check for the new engine. let provider = ctx.state.borrow().provider; - if let Some(hint) = engine_install_hint(provider, engine_present_for(provider)) { - let dialog = libadwaita::AlertDialog::new(Some(t("Sync Engine Not Installed")), Some(hint)); - dialog.add_response("ok", t("OK")); - dialog.present(Some(&ctx.window)); + if engine_install_hint(provider, engine_present_for(provider)).is_some() { + // Issue #218: the blocking dialog offers one-click installation of + // the provider's engine package; once installed, the finish gate + // passes on the next attempt. + let window = ctx.window.clone(); + crate::ui::engine_install::present_engine_install_dialog( + &ctx.window, + provider, + Rc::new(move || { + crate::ui::engine_install::present_engine_installed_dialog(&window); + }), + ); return; } let (provider, server, username, authentication_type, folders, trust_invalid, size_confirmed) = { @@ -1659,8 +1667,8 @@ fn update_provider_warning(banner: &libadwaita::Banner, provider: Provider) { } /// Whether the selected provider's sync binary is available on `$PATH` -/// (issue #210). -fn engine_present_for(provider: Provider) -> bool { +/// (issue #210; reused by the install dialog, issue #218). +pub(crate) fn engine_present_for(provider: Provider) -> bool { match provider { Provider::Nextcloud => find_binary("nextcloudcmd").is_some(), Provider::OpenCloud => find_binary("opencloudcmd").is_some(), @@ -1671,8 +1679,11 @@ fn engine_present_for(provider: Provider) -> bool { /// provider's sync engine is not installed. Returns the actionable install /// hint to block the finish with, or `None` when the engine is present. Kept /// pure (binary presence passed in) so the policy is testable without a real -/// `$PATH`. -fn engine_install_hint(provider: Provider, engine_present: bool) -> Option<&'static str> { +/// `$PATH`; also the body of the install dialog (issue #218). +pub(crate) fn engine_install_hint( + provider: Provider, + engine_present: bool, +) -> Option<&'static str> { if engine_present { return None; } diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 17db55d..3f30ff0 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -78,6 +78,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Close", "Cerrar"), ("Close Application", "Cerrar la aplicación"), ("Comma-separated network names. Leave empty to sync on any network.", "Nombres de red separados por comas. Déjalo vacío para sincronizar en cualquier red."), + ("Command copied to the clipboard.", "Comando copiado al portapapeles."), ("Complete changelog", "Historial completo de cambios"), ("Conflicts", "Conflictos"), ("Connect to Nextcloud", "Conectar con Nextcloud"), @@ -94,6 +95,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Continue", "Continuar"), ("Continue to the next step", "Continuar al siguiente paso"), ("Copy", "Copiar"), + ("Copy Command", "Copiar comando"), ("Copy Link", "Copiar enlace"), ("Copy the login link to the clipboard", "Copiar el enlace de inicio de sesión al portapapeles"), ("Could Not Add the Account", "No se pudo añadir la cuenta"), @@ -141,6 +143,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Exclude disposable files", "Excluir archivos desechables"), ("Excluded Files", "Archivos excluidos"), ("Existing configurations migrate transparently to schema version 4 with the remote path defaulting to the account root.", "Las configuraciones existentes migran de forma transparente a la versión 4 del esquema con la ruta remota por defecto apuntando a la raíz de la cuenta."), + ("Exit code: {code}", "Código de salida: {code}"), ("Export configuration", "Exportar configuración"), ("Export configuration…", "Exportar configuración…"), ("File", "Archivo"), @@ -168,7 +171,11 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Identifies the synchronized folder and its shortcuts in Files.", "Identifica la carpeta sincronizada y sus accesos en Archivos."), ("Import configuration", "Importar configuración"), ("Import configuration…", "Importar configuración…"), + ("Install", "Instalar"), + ("Install sync engine…", "Instalar el motor de sincronización…"), + ("Installation Failed", "Instalación fallida"), ("Installed version", "Versión instalada"), + ("Installing…", "Instalando…"), ("Interactive Debian upgrades now gracefully stop and restart a running application.", "Las actualizaciones interactivas mediante Debian ahora detienen de forma segura y reinician la aplicación si estaba en ejecución."), ("Invalid HTTP proxy URL", "URL de proxy HTTP no válida"), ("Invalid Login Flow response.", "Respuesta de Login Flow no válida."), @@ -231,6 +238,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("No Nextcloud conflicted copies were found in this folder.", "No se encontraron copias en conflicto de Nextcloud en esta carpeta."), ("No OpenCloud space was discovered for this account. Sign in again to retry the discovery.", "No se ha descubierto ningún espacio de OpenCloud para esta cuenta. Vuelve a iniciar sesión para reintentar el descubrimiento."), ("No Synchronization Folders", "Sin carpetas de sincronización"), + ("No automatic installation is available for this distribution yet.", "Todavía no hay instalación automática disponible para esta distribución."), ("No conflicted copies found in {folder}.", "No se encontraron copias en conflicto en {folder}."), ("No credentials are saved for this account.", "No hay credenciales guardadas para esta cuenta."), ("No deleted files to resolve", "No hay archivos borrados que resolver"), @@ -251,6 +259,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Notifications", "Notificaciones"), ("OK", "Aceptar"), ("Offline", "Sin conexión"), + ("On distributions without this package, install it from the AUR (for example: yay -S opencloud-desktop).", "En distribuciones sin este paquete, instálalo desde el AUR (por ejemplo: yay -S opencloud-desktop)."), ("Only file names, extensions, and wildcard patterns are allowed. Folders and paths cannot be excluded.", "Solo se permiten nombres de archivo, extensiones y patrones comodín. No se pueden excluir carpetas ni rutas."), ("Only sync on these Wi-Fi networks", "Sincronizar solo en estas redes Wi-Fi"), ("Open Browser Again", "Abrir el navegador de nuevo"), @@ -345,6 +354,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Run a local interval", "Ejecutar un intervalo local"), ("Run a remote interval", "Ejecutar un intervalo remoto"), ("Run one synchronization without changing the power preference?", "¿Ejecutar una sincronización sin cambiar la preferencia de energía?"), + ("Run this command in a terminal:", "Ejecuta este comando en una terminal:"), ("Runs the sync engine with idle IO priority and low CPU priority so transfers do not saturate the machine. It is a priority hint, not a speed limit.", "Ejecuta el motor de sincronización con prioridad de E/S inactiva y baja prioridad de CPU para que las transferencias no saturën la máquina. Es una indicación de prioridad, no un límite de velocidad."), ("Save every account, folder and preference to a JSON file", "Guarda todas las cuentas, carpetas y preferencias en un fichero JSON"), ("Save log files", "Guardar archivos de registro"), @@ -381,6 +391,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Startup", "Inicio"), ("Suspend automatic synchronization inside a daily time window", "Suspende la sincronización automática dentro de una franja horaria diaria"), ("Sync Activity and Conflicts", "Actividad y conflictos de sincronización"), + ("Sync Engine Installed", "Motor de sincronización instalado"), ("Sync Engine Not Installed", "Motor de sincronización no instalado"), ("Sync Now", "Sincronizar ahora"), ("Sync Once", "Sincronizar una vez"), @@ -421,17 +432,21 @@ pub static CATALOG: &[(&str, &str)] = &[ ("The deletion guard has not flagged any files in this folder.", "El guard de borrado no ha marcado ningún archivo en esta carpeta."), ("The folder could not be moved to the trash.", "No se pudo mover la carpeta a la papelera."), ("The independent Settings window is released after closing to preserve the low-memory interface lifecycle.", "La ventana independiente de Configuración se libera al cerrarla para conservar el ciclo de vida de la interfaz con bajo consumo de memoria."), + ("The installation failed.", "La instalación ha fallado."), + ("The installer crashed.", "El instalador se cerró inesperadamente."), ("The local folder and all files inside it will remain untouched.", "La carpeta local y todos los archivos que contiene permanecerán intactos."), ("The package now confirms that the old instance has exited before replacing application files.", "Ahora el paquete confirma que la instancia anterior finalizó antes de reemplazar los archivos de la aplicación."), ("The password keyring is locked.", "El almacén de contraseñas está bloqueado."), ("The remote folder holds about {size}. Its files will be downloaded into {target}.", "La carpeta remota ocupa unos {size}. Sus archivos se descargarán en {target}."), ("The server rejected the account credentials.", "El servidor rechazó las credenciales de la cuenta."), ("The sync engine is not installed.", "El motor de sincronización no está instalado."), + ("The sync engine was installed. You can finish the setup now.", "Se ha instalado el motor de sincronización. Ya puedes terminar la configuración."), ("The update notice now remains above the main window when the application is opened from its launcher.", "El aviso de actualización ahora permanece sobre la ventana principal cuando la aplicación se abre desde su lanzador."), ("The update window now shows a short summary and an expandable full changelog.", "La ventana de actualización ahora muestra un resumen breve y un historial completo de cambios desplegable."), ("The version information could not be obtained. Check your connection and try again later.", "No se pudo obtener la información de versión. Compruebe su conexión e inténtelo de nuevo más tarde."), ("These deletions will be propagated to the server when it synchronizes.", "Estos borrados se propagarán al servidor al sincronizar."), ("Third-party projects and licenses", "Proyectos de terceros y licencias"), + ("This command will run with administrator rights:", "Este comando se ejecutará con derechos de administrador:"), ("This folder has no synchronization journal yet, so every local file counts as new. Remote changes are not included.", "Esta carpeta aún no tiene registro de sincronización, así que cada archivo local cuenta como nuevo. Los cambios remotos no se incluyen."), ("This folder was synchronized before.", "Esta carpeta se sincronizó anteriormente."), ("This local folder is already added.", "Esta carpeta local ya está añadida."),