From 435ce2c72e66763992add5ec6e6e0661561ac10a Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 17 Sep 2026 20:26:31 +0200 Subject: [PATCH 1/2] fix(sync): report a missing sync engine instead of a false success (closes #209) --- po/es.po | 9 ++++ src/core/account_runtime.rs | 4 ++ src/core/notifications.rs | 4 ++ src/core/scheduler.rs | 46 +++++++++++++++++ src/nextcloud/driver.rs | 20 +++++++- src/nextcloud/sync_engine.rs | 95 ++++++++++++++++++++++++++++++------ src/util/translations/es.rs | 2 + 7 files changed, 164 insertions(+), 16 deletions(-) diff --git a/po/es.po b/po/es.po index f04caba..5131f5f 100644 --- a/po/es.po +++ b/po/es.po @@ -2557,6 +2557,15 @@ msgstr "Sincronización bloqueada: el almacén de contraseñas está bloqueado" msgid "Synchronization blocked: the server is unreachable" msgstr "Sincronización bloqueada: el servidor no está disponible" +#: src/core/account_runtime.rs +#: src/core/scheduler.rs +msgid "Synchronization blocked: the sync engine is not installed" +msgstr "Sincronización bloqueada: el motor de sincronización no está instalado" + +#: src/core/notifications.rs +msgid "The sync engine is not installed." +msgstr "El motor de sincronización no está instalado." + #: src/core/scheduler.rs msgid "Waiting for local changes to settle" msgstr "Esperando a que los cambios locales se asienten" diff --git a/src/core/account_runtime.rs b/src/core/account_runtime.rs index 427248b..cace026 100644 --- a/src/core/account_runtime.rs +++ b/src/core/account_runtime.rs @@ -502,6 +502,9 @@ pub fn outcome_log_line(outcome: &crate::core::scheduler::SyncOutcome) -> &'stat crate::core::scheduler::SyncOutcome::NetworkError => { t("Synchronization blocked: the server is unreachable") } + crate::core::scheduler::SyncOutcome::EngineMissing => { + t("Synchronization blocked: the sync engine is not installed") + } } } @@ -1970,6 +1973,7 @@ mod tests { SyncOutcome::KeyringLocked, SyncOutcome::Failed, SyncOutcome::NetworkError, + SyncOutcome::EngineMissing, ] { let line = outcome_log_line(&outcome); assert!(!line.is_empty(), "English label for {outcome:?}"); diff --git a/src/core/notifications.rs b/src/core/notifications.rs index fcf2880..5a0946d 100644 --- a/src/core/notifications.rs +++ b/src/core/notifications.rs @@ -122,6 +122,9 @@ pub fn failure_notification(outcome: &crate::core::scheduler::SyncOutcome) -> Op "No credentials are saved for this account.", )), SyncOutcome::Failed => Some(crate::util::i18n::t("A synchronization failed.")), + SyncOutcome::EngineMissing => { + Some(crate::util::i18n::t("The sync engine is not installed.")) + } // A transport failure is a transient network condition (the server is // unreachable), not a problem the account needs a notification for: // it resolves on the next automatic trigger once the server answers. @@ -168,6 +171,7 @@ mod tests { SyncOutcome::Failed, SyncOutcome::AuthFailed, SyncOutcome::KeyringLocked, + SyncOutcome::EngineMissing, ] { let sent = Rc::new(CountingNotifier::default()); let notifier: Rc = sent.clone(); diff --git a/src/core/scheduler.rs b/src/core/scheduler.rs index 9930f47..03a4752 100644 --- a/src/core/scheduler.rs +++ b/src/core/scheduler.rs @@ -94,6 +94,12 @@ pub enum SyncOutcome { /// Offline for that account only: the machine has a network link but the /// specific server does not answer (issue #162). NetworkError, + /// The provider's sync binary (nextcloudcmd / opencloudcmd) is not + /// installed, so nothing can be synchronized. Distinct from Failed so the + /// UI can point at the real fix - installing the engine package - and the + /// ETag gate never reports a false success while the engine is missing + /// (issue #209). + EngineMissing, } /// Executes a reconciliation. Implemented by the sync engine in Task 2.3. @@ -985,6 +991,19 @@ impl SchedulerInner { ); (false, false) } + SyncOutcome::EngineMissing => { + // Issue #209: the provider's sync binary is not installed. + // Nothing ran, so this is not a "ran" outcome; every later + // trigger re-checks the binary, so installing the engine + // package (e.g. nextcloud-client) recovers without a restart. + self.keyring_locked = false; + self.consecutive_failing_syncs += 1; + self.state.set( + AppState::Error, + t("Synchronization blocked: the sync engine is not installed"), + ); + (false, false) + } }; // Fase 3: after a successful run the guard baseline is refreshed so // it reflects what nextcloudcmd just reconciled (Python: only for @@ -2158,6 +2177,33 @@ mod tests { assert_eq!(scheduler.state().snapshot().state, AppState::Error); } + /// Issue #209: a missing sync engine binary (e.g. the user removed the + /// desktop package that provides nextcloudcmd) marks the folder Error + /// with a clear message, never reads as synchronized, and keeps retrying + /// on the next trigger once the engine is installed again. + #[test] + fn engine_missing_sets_error_and_recovers_on_retry() { + let (scheduler, source, runner) = make_scheduler(None); + scheduler.request(Trigger::Manual); + run_idle(&source); + finish(&runner, SyncOutcome::EngineMissing); + let snapshot = scheduler.state().snapshot(); + assert_eq!(snapshot.state, AppState::Error); + assert!( + snapshot.message.contains("engine"), + "the row explains the missing engine: {}", + snapshot.message + ); + + // Installing the engine does not need an app restart: the next + // automatic trigger runs again and a real success clears the state. + scheduler.request(Trigger::RemoteInterval); + run_idle(&source); + assert_eq!(runner.0.borrow().start_calls, 2); + finish(&runner, SyncOutcome::Success); + assert_eq!(scheduler.state().snapshot().state, AppState::IdleOk); + } + /// Issue #162: an unreachable server (transport failure) marks the folder /// Offline instead of Connected, and does not arm the credential gate or /// leave the account spinning. The scheduler keeps retrying on the next diff --git a/src/nextcloud/driver.rs b/src/nextcloud/driver.rs index c1b73aa..bc9ee96 100644 --- a/src/nextcloud/driver.rs +++ b/src/nextcloud/driver.rs @@ -292,9 +292,14 @@ impl SyncDriver for OpenCloudDriver { } /// The provider binary: the explicit override, or the `$PATH` lookup. +/// +/// Issue #209: an explicit override pointing at a path that no longer exists +/// resolves as `MissingBinary` (the actionable "engine not installed" state), +/// not as an opaque spawn failure at run time. fn resolve_binary(name: &str, executable: &Option) -> Result { match executable { - Some(path) => Ok(path.to_string_lossy().into_owned()), + Some(path) if path.exists() => Ok(path.to_string_lossy().into_owned()), + Some(_) => Err(CommandError::MissingBinary), None => find_binary(name) .map(|path| path.to_string_lossy().into_owned()) .ok_or(CommandError::MissingBinary), @@ -593,6 +598,19 @@ mod tests { ); } + /// Issue #209: an explicit executable override pointing at a path that no + /// longer exists must resolve as MissingBinary (a configured stale path + /// must surface the actionable state, not a generic spawn failure). + #[test] + fn resolve_binary_rejects_a_missing_explicit_override() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("gone-nextcloudcmd"); + assert_eq!( + resolve_binary("nextcloudcmd", &Some(missing)), + Err(CommandError::MissingBinary) + ); + } + // ---- spaces discovery -------------------------------------------------- #[test] diff --git a/src/nextcloud/sync_engine.rs b/src/nextcloud/sync_engine.rs index 5c05dcc..16b04d6 100644 --- a/src/nextcloud/sync_engine.rs +++ b/src/nextcloud/sync_engine.rs @@ -474,6 +474,27 @@ fn engine_thread( // the password/token on its diagnostics, and the structural passes alone // would leave it verbatim in the run tail (issue #126). let redactor = Arc::new(Redactor::from_secrets([password.clone()])); + // Issue #209: resolve the engine command BEFORE the remote ensurer and + // the ETag gate. A missing provider binary (nextcloudcmd / opencloudcmd) + // must surface as EngineMissing even when the periodic interval would + // take the cheap unchanged-ETag shortcut: otherwise the row reports a + // clean success for days while nothing is actually synchronized. + let driver = driver_for(inputs.account.provider); + let ctx = DriverContext::from_folder( + &inputs.account, + &inputs.folder, + &inputs.network, + password.clone(), + inputs.exclude_file.clone(), + inputs.executable.clone(), + ); + let spec = match driver.build_command(&ctx) { + Ok(spec) => spec, + Err(crate::nextcloud::command::CommandError::MissingBinary) => { + return EngineRun::Direct(SyncOutcome::EngineMissing) + } + Err(_) => return EngineRun::Direct(SyncOutcome::Failed), + }; // `nextcloudcmd` exits 1 with no output when the remote folder does not // exist; create it (and its parents) first. Auth rejection surfaces as // such; anything else falls through and lets nextcloudcmd report. @@ -534,19 +555,6 @@ fn engine_thread( // might be missed). } } - let driver = driver_for(inputs.account.provider); - let ctx = DriverContext::from_folder( - &inputs.account, - &inputs.folder, - &inputs.network, - password.clone(), - inputs.exclude_file.clone(), - inputs.executable.clone(), - ); - let spec = match driver.build_command(&ctx) { - Ok(spec) => spec, - Err(_) => return EngineRun::Direct(SyncOutcome::Failed), - }; let mut command = if inputs.network.reduce_transfer_impact { spec.to_command_low_impact() } else { @@ -907,8 +915,11 @@ mod tests { assert!(events.is_empty()); } + /// Issue #209: a missing engine binary is the distinct EngineMissing + /// outcome (not the generic Failed), so the row can point at the real + /// fix - installing the engine package. #[test] - fn missing_binary_maps_to_failed() { + fn missing_binary_maps_to_engine_missing() { let (progress_tx, progress_rx) = async_channel::unbounded(); let engine = SyncEngine::new( account(), @@ -922,7 +933,7 @@ mod tests { "secret".to_string(), )))); let (outcome, _events) = run_engine(engine, &progress_rx); - assert_eq!(outcome, SyncOutcome::Failed); + assert_eq!(outcome, SyncOutcome::EngineMissing); } #[test] @@ -1338,6 +1349,60 @@ mod tests { assert!(!marker.exists(), "nextcloudcmd must not be spawned"); } + /// Issue #209: with the sync engine binary missing, a periodic interval + /// run whose root ETag is unchanged must NOT report a clean success: the + /// ETag gate would skip the reconciliation without ever spawning the + /// engine, hiding the breakage for days. The missing engine is detected + /// before the gate and reported as EngineMissing. + #[test] + fn missing_engine_blocks_the_etag_gate_success() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("no-such-nextcloudcmd"); + let (etag_tx, _etag_rx) = async_channel::unbounded(); + let engine = SyncEngine::new( + account(), + folder(), + NetworkConfig::default(), + None, + Some(missing), + etag_tx, + ) + .with_credentials(Arc::new(FakeCredentials(CredentialLookup::Found( + "secret".to_string(), + )))) + .with_etag_probe(Arc::new(|_account, _folder, _password| { + Ok(Some("\"abc\"".to_string())) + })); + // Seed the slot with the same ETag the probe returns: without the + // engine check this run would take the gate shortcut and succeed. + *engine.etag_slot.lock().unwrap() = Some("\"abc\"".to_string()); + let outcome = run_engine_interval(engine, &async_channel::unbounded().1); + assert_eq!(outcome, SyncOutcome::EngineMissing); + } + + /// Issue #209: a manual run with a missing engine binary reports + /// EngineMissing (a distinct, actionable state) instead of the generic + /// failure. + #[test] + fn missing_engine_reports_engine_missing_on_manual_run() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("no-such-nextcloudcmd"); + let (progress_tx, progress_rx) = async_channel::unbounded(); + let engine = SyncEngine::new( + account(), + folder(), + NetworkConfig::default(), + None, + Some(missing), + progress_tx, + ) + .with_credentials(Arc::new(FakeCredentials(CredentialLookup::Found( + "secret".to_string(), + )))); + let (outcome, _) = run_engine(engine, &progress_rx); + assert_eq!(outcome, SyncOutcome::EngineMissing); + } + /// Issue #189: a changed root ETag (or no recorded ETag yet, e.g. first /// run) must reconcile. #[test] diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 29be120..d98de2f 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -394,6 +394,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Synchronization blocked: no saved credentials", "Sincronización bloqueada: no hay credenciales guardadas"), ("Synchronization blocked: password keyring is locked", "Sincronización bloqueada: el almacén de contraseñas está bloqueado"), ("Synchronization blocked: the server is unreachable", "Sincronización bloqueada: el servidor no está disponible"), + ("Synchronization blocked: the sync engine is not installed", "Sincronización bloqueada: el motor de sincronización no está instalado"), ("Synchronization completed", "Sincronización completada"), ("Synchronization completed with conflicts", "Sincronización completada con conflictos"), ("Synchronization failed", "Error de sincronización"), @@ -424,6 +425,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("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 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."), From f83549a51dafe65649f251296b4f078c2e54a445 Mon Sep 17 00:00:00 2001 From: gnacho Date: Thu, 17 Sep 2026 20:27:13 +0200 Subject: [PATCH 2/2] chore(release): bump version to 0.2.22 --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- PKGBUILD | 2 +- README.es.md | 4 ++-- README.md | 4 ++-- data/io.github.gnacho.nextsync.metainfo.xml | 1 + landing/index.html | 2 +- version.json | 11 ++++++----- 9 files changed, 22 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c5659a..35f8afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Todas las versiones notables de NextSync se documentan aquí. El formato sigue [Keep a Changelog](https://keepachangelog.com/es/1.1.0/) y el versionado es **+0.0.2 por release, reiniciado en 0.1.4** (decisión del usuario, 22-Ago-2026; sustituye al +0.02 anterior). +## [0.2.22] - 2026-09-17 + +### Fixed + +- Detect a missing sync engine (nextcloudcmd / opencloudcmd) and report a distinct "sync engine is not installed" state instead of a false "Synchronization completed" from the unchanged-ETag interval shortcut while nothing was actually synchronized (#209). +- A configured engine path that no longer exists resolves as missing engine instead of a generic spawn failure. + ## [0.2.20] - 2026-09-08 ### Mejorado diff --git a/Cargo.lock b/Cargo.lock index d9ba8f9..825a8df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1283,7 +1283,7 @@ dependencies = [ [[package]] name = "nextsync" -version = "0.2.20" +version = "0.2.22" dependencies = [ "async-channel", "data-encoding", diff --git a/Cargo.toml b/Cargo.toml index 2066df2..785a7cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nextsync" -version = "0.2.20" +version = "0.2.22" edition = "2021" rust-version = "1.83" license = "GPL-3.0-or-later" diff --git a/PKGBUILD b/PKGBUILD index e2ef713..42efb6e 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: gnacho pkgname=nextsync -pkgver=0.2.20 +pkgver=0.2.22 pkgrel=1 pkgdesc='Nextcloud desktop synchronization client for GNOME (Rust rewrite)' arch=('x86_64' 'aarch64') diff --git a/README.es.md b/README.es.md index 9a5eadf..19f02a6 100644 --- a/README.es.md +++ b/README.es.md @@ -17,7 +17,7 @@

Estado de CI - Versión 0.2.20 + Versión 0.2.22 GNU GPL v3 o posterior

@@ -84,7 +84,7 @@ Ambos motores se esconden detrás del mismo trait pequeño, así que un proveedo Descarga el `.pkg.tar.zst` de la [última release](https://github.com/gnacho/nextsync/releases/latest) e instálalo: ```bash -sudo pacman -U nextsync-0.2.20-1-x86_64.pkg.tar.zst +sudo pacman -U nextsync-0.2.22-1-x86_64.pkg.tar.zst ``` El paquete depende de `gtk4` y `libadwaita`. Para cuentas Nextcloud instala `nextcloud-client` (aporta `nextcloudcmd`); para cuentas OpenCloud, el `opencloudcmd` oficial. diff --git a/README.md b/README.md index 1c7b209..1d3a984 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

CI status - Version 0.2.20 + Version 0.2.22 GNU GPL v3 or later

@@ -84,7 +84,7 @@ Both engines sit behind the same small trait, so a new provider is a command bui Download the `.pkg.tar.zst` from the [latest release](https://github.com/gnacho/nextsync/releases/latest) and install it: ```bash -sudo pacman -U nextsync-0.2.20-1-x86_64.pkg.tar.zst +sudo pacman -U nextsync-0.2.22-1-x86_64.pkg.tar.zst ``` The package depends on `gtk4` and `libadwaita`. For Nextcloud accounts install `nextcloud-client` (it provides `nextcloudcmd`); for OpenCloud accounts, the official `opencloudcmd`. diff --git a/data/io.github.gnacho.nextsync.metainfo.xml b/data/io.github.gnacho.nextsync.metainfo.xml index 613bbf2..99728ef 100644 --- a/data/io.github.gnacho.nextsync.metainfo.xml +++ b/data/io.github.gnacho.nextsync.metainfo.xml @@ -32,6 +32,7 @@ io.github.gnacho.nextsync + diff --git a/landing/index.html b/landing/index.html index e9bf8e9..bc09ba4 100644 --- a/landing/index.html +++ b/landing/index.html @@ -210,7 +210,7 @@

Lo que NextSync no hace (todavía)

Instalación fácil y rápida

En Arch, CachyOS y derivadas hay paquete listo en cada release.

-
sudo pacman -U nextsync-0.2.20-1-x86_64.pkg.tar.zst
+
sudo pacman -U nextsync-0.2.22-1-x86_64.pkg.tar.zst

Descarga el paquete .pkg.tar.zst más reciente desde GitHub Releases y ajusta el nombre del fichero.

diff --git a/version.json b/version.json index 1d45203..f512b8e 100644 --- a/version.json +++ b/version.json @@ -1,11 +1,12 @@ { "schema_version": 1, - "version": "0.2.20", + "version": "0.2.22", "mandatory": false, - "summary": "Improved the mass-deletion review summary for a large number of flat files.", + "summary": "Detect a missing sync engine and stop reporting success when nothing can be synchronized.", "changelog": [ - "When the deletion guard pauses for many files sitting at the top level of a folder, the review now shows a preview of the first files and a 'N more' note instead of just a bare counter, so you can see what was removed without scrolling a hundreds-row list.", - "Files inside subfolders keep the folder grouping; individual rows are only listed when the number of missing files is small." + "When the sync binary (nextcloudcmd / opencloudcmd) is not installed, folders now show a clear 'sync engine is not installed' state instead of a generic failure - or worse, a misleading 'Synchronization completed' from the unchanged-ETag interval shortcut while nothing was actually synced.", + "A configured engine path that no longer exists resolves as missing too, so a stale override surfaces the same actionable state.", + "The missing-engine reason is written to the daily log and raises a desktop notification like other problem outcomes." ], - "released_at": "2026-09-08T00:00:00Z" + "released_at": "2026-09-17T00:00:00Z" }