Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,10 @@ jobs:
- name: Test
run: cargo test

- name: i18n catalog parity
run: |
python3 tools/gen-translations.py po/es.po /tmp/es-regenerated.rs
diff -u src/util/translations/es.rs /tmp/es-regenerated.rs

- name: Build release
run: cargo build --release
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

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.02 por release** (decisión del usuario, 15-Ago-2026).

## [0.120.0] - 2026-08-21

### Corregido
- **El push se reconecta al cambiar la contraseña (#133)**: tras una re-autenticación, el canal push usa la contraseña nueva de inmediato; antes seguía con la antigua hasta una reconexión casual.
- **El overflow del watcher ya no se pierde (#134)**: la señal de rescan se guarda en un flag atómico que no compite con el buffer lleno; antes podía descartarse y se sincronizaba desde un stream parcial.
- **El avatar se borra al quitar la cuenta y se limita su tamaño (#135)**: la caché de avatares ya no deja huérfanos ni persiste cuerpos desmedidos.
- **Escritura de configuración durable (#136)**: nombres temporales únicos (pid+contador) y fsync del directorio tras el rename; antes dos instancias podían pisarse y un corte de luz deshacía el cambio.
- **schema_version ilegible se rechaza (#137)**: un valor no entero/negativo ya no se trata como v1 y no migra datos corruptos.
- **color_scheme validado (#138)**: solo `system`/`light`/`dark` llegan al conmutador de tema; cualquier otro valor cae al predeterminado.
- **Secreto no UTF-8 ya no se corrompe en silencio (#139)**: la tienda de credenciales devuelve un error en vez de sustituir bytes inválidos.
- **El Debug redacta el secreto (#140)**: `DriverContext` y `CommandSpec` ya no imprimen la contraseña o el token en los logs.
- **Las notificaciones del servidor siembran la línea base una vez (#141)**: un primer sondeo vacío ya no hace que la primera notificación real se trague.
- **Catálogo ES sincronizado con el po y verificado en CI (#142)**: las 18 cadenas huérfanas vuelven al po, es.rs se regenera de él y el CI falla ante cualquier desviación.
- **Título del tray sin em dash (#143)**: "NextSync - {estado}" con guión normal.
- **Código muerto eliminado (#144)**: el parser `parse_metered` que solo usaba su propio test.
- **El progreso ya no reaparece tras terminar el run (#145)**: un flag compartido impide que los eventos residuales del buffer repinten el label después de que la sincronización acabó.

## [0.118.0] - 2026-08-21

### Corregido
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.118.0"
version = "0.120.0"
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.118.0
pkgver=0.120.0
pkgrel=1
pkgdesc='Nextcloud desktop synchronization client for GNOME (Rust rewrite)'
arch=('x86_64' 'aarch64')
Expand Down
2 changes: 1 addition & 1 deletion README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<a href="README.md">English</a>
</p>
<p>
<img src="https://img.shields.io/badge/version-0.118.0-6557e8?style=flat-square" alt="Versión 0.118.0">
<img src="https://img.shields.io/badge/version-0.120.0-6557e8?style=flat-square" alt="Versión 0.120.0">
<img src="https://img.shields.io/badge/platform-Linux-f0c674?style=flat-square&logo=linux&logoColor=111" alt="Linux">
<img src="https://img.shields.io/badge/desktop-GNOME-4a86cf?style=flat-square&logo=gnome&logoColor=white" alt="GNOME">
<img src="https://img.shields.io/badge/GTK-4-4a86cf?style=flat-square&logo=gtk&logoColor=white" alt="GTK 4">
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<a href="README.es.md">Español</a>
</p>
<p>
<img src="https://img.shields.io/badge/version-0.118.0-6557e8?style=flat-square" alt="Version 0.118.0">
<img src="https://img.shields.io/badge/version-0.120.0-6557e8?style=flat-square" alt="Version 0.120.0">
<img src="https://img.shields.io/badge/platform-Linux-f0c674?style=flat-square&logo=linux&logoColor=111" alt="Linux">
<img src="https://img.shields.io/badge/desktop-GNOME-4a86cf?style=flat-square&logo=gnome&logoColor=white" alt="GNOME">
<img src="https://img.shields.io/badge/GTK-4-4a86cf?style=flat-square&logo=gtk&logoColor=white" alt="GTK 4">
Expand Down
59 changes: 57 additions & 2 deletions po/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -1764,8 +1764,8 @@ msgstr "Empezar"

#: src/nextsync/ui/tray.py:266
#, python-brace-format
msgid "NextSync — {state}"
msgstr "NextSync — {state}"
msgid "NextSync - {state}"
msgstr "NextSync - {state}"

#: src/nextsync/ui/tray.py:270
msgid "Syncing"
Expand Down Expand Up @@ -2653,3 +2653,58 @@ msgstr "No hay archivos borrados que resolver"
#: src/ui/conflict_resolver.rs
msgid "The deletion guard has not flagged any files in this folder."
msgstr "El guard de borrado no ha marcado ningún archivo en esta carpeta."

msgid "Could not list the remote folders."
msgstr "No se pudieron listar las carpetas remotas."

msgid "Keep Local for All"
msgstr "Conservar la local en todo"

msgid "Keep Remote for All"
msgstr "Conservar la remota en todo"

msgid "Keep the local version of all {count} conflicted copy(ies)? The conflicted copies are deleted."
msgstr "¿Conservar la versión local de las {count} copias en conflicto? Las copias en conflicto se eliminan."

msgid "Keep the server version of all {count} conflicted copy(ies)? The working files are replaced."
msgstr "¿Conservar la versión del servidor de las {count} copias en conflicto? Los archivos de trabajo se reemplazan."

msgid "Kept the local version of {count} file(s)"
msgstr "Versión local conservada en {count} archivo(s)"

msgid "Kept the server version of {count} file(s)"
msgstr "Versión del servidor conservada en {count} archivo(s)"

msgid "Resolve All Conflicts"
msgstr "Resolver todos los conflictos"

msgid "Synced in local {folder}"
msgstr "Sincronizado en local {folder}"

msgid "Waiting to synchronize"
msgstr "Esperando para sincronizar"

msgid "checking {file}"
msgstr "comprobando {file}"

msgid "conflict on {file}"
msgstr "conflicto en {file}"

msgid "deleting {file}"
msgstr "eliminando {file}"

msgid "downloading {file}"
msgstr "descargando {file}"

msgid "processing {file}"
msgstr "procesando {file}"

msgid "uploading {file}"
msgstr "subiendo {file}"

msgid "{action} · {count}"
msgstr "{action} · {count}"

msgid "{used} used"
msgstr "{used} usados"

16 changes: 16 additions & 0 deletions src/core/account_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,12 @@ impl FolderRuntime {
let task = glib::spawn_future_local(async move {
let mut watcher = watcher;
while let Ok(event) = receiver.recv().await {
// An overflow may have been flagged while the buffer was full
// (issue #134): it cannot be delivered through the full
// channel, so the consumer rescans on the flag instead.
if watcher.take_overflow() {
watcher.rescan();
}
match event {
WatcherEvent::Change(_) | WatcherEvent::Rescan => {
scheduler.request(Trigger::LocalInotify);
Expand All @@ -317,8 +323,18 @@ impl FolderRuntime {
return;
};
let state_for_progress = self.state.clone();
// Issue #145: the engine's sender survives across runs, so events
// still in the buffer after a run finishes are drained AFTER the
// scheduler cleared the label. The scheduler flips this flag off at
// run end; while it is off, residual events are dropped so they
// cannot repaint the row.
let run_active = std::rc::Rc::new(std::cell::Cell::new(false));
self.scheduler.set_run_active(run_active.clone());
glib::spawn_future_local(async move {
while let Ok(progress) = progress_rx.recv().await {
if !run_active.get() {
continue;
}
state_for_progress.set_progress(Some(progress));
}
});
Expand Down
17 changes: 0 additions & 17 deletions src/core/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,6 @@ pub fn parse_active_ssid(output: &str) -> Option<String> {
})
}

/// Parse `nmcli -t -f GENERAL.METERED dev status` output into whether any
/// device reports a metered connection. Each line is `device:metered` in
/// the common case, but tolerate extra fields (`device:state:metered`).
pub fn parse_metered(output: &str) -> bool {
output
.lines()
.any(|line| line.split(':').any(|field| field.trim() == "yes"))
}

/// Parse the raw `network.allowed_ssids` config value (comma separated).
/// Empty entries are dropped; comparison elsewhere is exact.
pub fn parse_allowed_ssids(raw: &str) -> Vec<String> {
Expand Down Expand Up @@ -347,14 +338,6 @@ mod tests {
assert_eq!(parse_active_ssid(""), None);
}

#[test]
fn parse_metered_only_triggers_on_yes() {
assert!(parse_metered("wlan0:connected:yes\neth0:connected:no"));
assert!(!parse_metered("wlan0:connected:no"));
assert!(!parse_metered("wlan0:connected:unknown"));
assert!(!parse_metered(""));
}

#[test]
fn parse_allowed_ssids_drops_empty_entries() {
assert_eq!(
Expand Down
37 changes: 37 additions & 0 deletions src/core/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ struct SchedulerInner {
/// automatic triggers stay queued until the user signs in again, so a
/// revoked password cannot hammer the server into a brute-force lockout.
auth_required: bool,
/// Shared flag: true while a run is in flight, false the moment it
/// finishes. The progress forwarder reads it so stale events drained
/// after the run's `set_progress(None)` cannot repaint the label
/// (issue #145).
run_active: Option<std::rc::Rc<std::cell::Cell<bool>>>,
}

impl Scheduler {
Expand Down Expand Up @@ -188,6 +193,7 @@ impl Scheduler {
active_ssid: None,
quiet_hours: None,
auth_required: false,
run_active: None,
};
let inner = Rc::new(RefCell::new(inner));
{
Expand Down Expand Up @@ -275,6 +281,13 @@ impl Scheduler {
self.inner.borrow_mut().local_root = local_root;
}

/// Share the run-active flag with the progress forwarder (issue #145):
/// the forwarder must not repaint the label from stale events once a run
/// has finished and cleared it.
pub fn set_run_active(&self, flag: std::rc::Rc<std::cell::Cell<bool>>) {
self.inner.borrow_mut().run_active = Some(flag);
}

/// Override the procfs directory scanned for external engine processes.
/// Production leaves this unset (the real `/proc`); tests inject a fake.
pub fn set_proc_scan_root(&self, proc_scan_root: Option<std::path::PathBuf>) {
Expand Down Expand Up @@ -552,6 +565,9 @@ impl SchedulerInner {
}

fn prepare_sync(&mut self, reasons: Vec<Trigger>) {
if let Some(flag) = &self.run_active {
flag.set(true);
}
self.state.set(AppState::Syncing, t("Synchronizing files…"));
self.state.set_progress(None);
self.preparing = true;
Expand Down Expand Up @@ -695,6 +711,12 @@ impl SchedulerInner {
callback(&outcome);
}
self.running = false;
// The run is over: clear the shared run-active flag BEFORE the
// progress is cleared, so the forwarder stops repainting the label
// from stale events drained after this point (issue #145).
if let Some(flag) = &self.run_active {
flag.set(false);
}
if ran {
if self.inotify_during_sync {
if feedback_followup {
Expand Down Expand Up @@ -1389,6 +1411,21 @@ mod tests {
assert_eq!(runner.0.borrow().start_calls, 2);
}

#[test]
fn run_active_flag_tracks_the_run_lifecycle() {
// Issue #145: the flag is true while the run is in flight and off
// once it finishes, so the progress forwarder can drop stale events.
let (scheduler, source, runner) = make_scheduler(None);
let flag = std::rc::Rc::new(std::cell::Cell::new(false));
scheduler.set_run_active(flag.clone());
assert!(!flag.get());
scheduler.request(Trigger::Manual);
run_idle(&source);
assert!(flag.get(), "the flag must be on while the run is in flight");
finish(&runner, SyncOutcome::Success);
assert!(!flag.get(), "the flag must clear when the run finishes");
}

#[test]
fn delete_alert_blocks_and_approve_once_bypasses() {
let (scheduler, source, runner) = make_scheduler(None);
Expand Down
31 changes: 29 additions & 2 deletions src/core/server_notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ pub struct ServerNotificationWatcher {
logger: crate::core::log::LogBuffer,
/// Notification ids already shown (or seeded as the baseline).
seen: Rc<RefCell<HashSet<i64>>>,
/// Whether the baseline has been seeded. A separate flag from `seen`
/// being non-empty: the first poll may legitimately return zero
/// notifications, and the seed must happen exactly once (issue #141).
seeded: Rc<RefCell<bool>>,
/// Guards against overlapping fetches when the push poke and the timer
/// fire together.
running: Rc<RefCell<bool>>,
Expand All @@ -79,6 +83,7 @@ impl ServerNotificationWatcher {
notifier,
logger,
seen: Rc::new(RefCell::new(HashSet::new())),
seeded: Rc::new(RefCell::new(false)),
running: Rc::new(RefCell::new(false)),
source_id: RefCell::new(None),
}
Expand Down Expand Up @@ -119,6 +124,7 @@ impl ServerNotificationWatcher {
let server = self.server.clone();
let login = self.login.clone();
let seen = self.seen.clone();
let seeded = self.seeded.clone();
let notifier = self.notifier.clone();
let logger = self.logger.clone();
let running = self.running.clone();
Expand All @@ -139,13 +145,17 @@ impl ServerNotificationWatcher {
// the join-handle result (the outer layer reports panics).
match task.await {
Ok(Ok(notifications)) => {
if seen.borrow().is_empty() {
if !*seeded.borrow() {
// First run: seed the baseline so enabling the option
// does not replay a backlog of old notifications.
// does not replay a backlog of old notifications. The
// flag (not the set being empty) decides, so a first
// poll that returns zero items still counts as seeded
// (issue #141).
let mut seen = seen.borrow_mut();
for item in &notifications {
seen.insert(item.notification_id);
}
*seeded.borrow_mut() = true;
} else {
let new_items = {
let seen = seen.borrow();
Expand Down Expand Up @@ -216,4 +226,21 @@ mod tests {
let seen = HashSet::from([1i64]);
assert!(unseen(&seen, &items).is_empty());
}

#[test]
fn seeded_flag_decides_the_baseline_not_the_set_size() {
// Issue #141: a first poll that returns zero notifications still
// seeds the baseline, so the next poll with one real notification is
// not swallowed by a second seed.
let seen = Rc::new(RefCell::new(HashSet::new()));
let seeded = Rc::new(RefCell::new(false));
// First poll: zero notifications, but it marks the baseline seeded.
*seeded.borrow_mut() = true;
assert!(seen.borrow().is_empty());
assert!(*seeded.borrow());
// A later poll with a real notification finds the seed flag on.
let items = [sample(7, "new")];
let new_items = unseen(&seen.borrow(), &items);
assert_eq!(new_items.len(), 1);
}
}
Loading
Loading