Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion PKGBUILD
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Maintainer: gnacho <https://github.com/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')
Expand Down
4 changes: 2 additions & 2 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</p>
<p>
<a href="https://github.com/gnacho/nextsync/actions/workflows/ci.yml"><img src="https://github.com/gnacho/nextsync/actions/workflows/ci.yml/badge.svg" alt="Estado de CI"></a>
<img src="https://img.shields.io/badge/version-0.2.20-blue?style=flat-square" alt="Versión 0.2.20">
<img src="https://img.shields.io/badge/version-0.2.22-blue?style=flat-square" alt="Versión 0.2.22">
<img src="https://img.shields.io/badge/license-GPL--3.0%2B-informational?style=flat-square" alt="GNU GPL v3 o posterior">
</p>
</div>
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</p>
<p>
<a href="https://github.com/gnacho/nextsync/actions/workflows/ci.yml"><img src="https://github.com/gnacho/nextsync/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
<img src="https://img.shields.io/badge/version-0.2.20-blue?style=flat-square" alt="Version 0.2.20">
<img src="https://img.shields.io/badge/version-0.2.22-blue?style=flat-square" alt="Version 0.2.22">
<img src="https://img.shields.io/badge/license-GPL--3.0%2B-informational?style=flat-square" alt="GNU GPL v3 or later">
</p>
</div>
Expand Down Expand Up @@ -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`.
Expand Down
1 change: 1 addition & 0 deletions data/io.github.gnacho.nextsync.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<icon type="stock">io.github.gnacho.nextsync</icon>
<content_rating type="oars-1.1"/>
<releases>
<release version="0.2.22" date="2026-09-17"/>
<release version="0.2.20" date="2026-09-08"/>
<release version="0.2.18" date="2026-09-08"/>
<release version="0.2.16" date="2026-08-27"/>
Expand Down
2 changes: 1 addition & 1 deletion landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ <h3 data-i18n="compare.ht">Lo que NextSync no hace (todavía)</h3>
<h2 class="reveal" data-i18n="install.title">Instalación fácil y rápida</h2>
<p class="lead reveal" data-i18n="install.lead">En Arch, CachyOS y derivadas hay paquete listo en cada release.</p>
<div class="card install-card reveal">
<pre class="code-block"><code id="installCmd">sudo pacman -U nextsync-0.2.20-1-x86_64.pkg.tar.zst</code></pre>
<pre class="code-block"><code id="installCmd">sudo pacman -U nextsync-0.2.22-1-x86_64.pkg.tar.zst</code></pre>
<button class="btn suggested" id="copyBtn" data-i18n="install.copy">Copiar comando</button>
<p class="small-print" data-i18n="install.note">Descarga el paquete <code>.pkg.tar.zst</code> más reciente desde <a href="https://github.com/gnacho/nextsync/releases" target="_blank" rel="noopener">GitHub Releases</a> y ajusta el nombre del fichero.</p>
</div>
Expand Down
9 changes: 9 additions & 0 deletions po/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/core/account_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

Expand Down Expand Up @@ -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:?}");
Expand Down
4 changes: 4 additions & 0 deletions src/core/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -168,6 +171,7 @@ mod tests {
SyncOutcome::Failed,
SyncOutcome::AuthFailed,
SyncOutcome::KeyringLocked,
SyncOutcome::EngineMissing,
] {
let sent = Rc::new(CountingNotifier::default());
let notifier: Rc<dyn DesktopNotifier> = sent.clone();
Expand Down
46 changes: 46 additions & 0 deletions src/core/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/nextcloud/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>) -> Result<String, CommandError> {
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),
Expand Down Expand Up @@ -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]
Expand Down
95 changes: 80 additions & 15 deletions src/nextcloud/sync_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions src/util/translations/es.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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."),
Expand Down
Loading
Loading