Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e518aca
fix(ui): fresh add-account window, Add Account title, scroll-free wel…
gnacho Aug 20, 2026
04490eb
fix(api,ui): wire the avatar fetch and trust image bytes on 404
gnacho Aug 20, 2026
a5f03d2
feat(tray): cloud-check glyph when everything is synced
gnacho Aug 20, 2026
ed07138
fix(ui): drop the wizard summary page and finish setup from folders
gnacho Aug 20, 2026
a62858d
fix(ui): one-line warning for previously synced folders
gnacho Aug 20, 2026
d97b360
fix(ui): fold the home directory in wizard paths and widen the window
gnacho Aug 20, 2026
98c78ef
fix(ui): pass the row tooltip as a plain string for the builder
gnacho Aug 20, 2026
f3e7b68
feat(ui): resolve all conflicts with one bulk action
gnacho Aug 20, 2026
3028b8e
feat(ui): bold folder names and synced-in-local subtitle
gnacho Aug 20, 2026
45b790c
fix(ui): both account-removal dialogs say that no files are deleted
gnacho Aug 20, 2026
70b6c47
feat(ui): two-line account summary with accented Add Folder
gnacho Aug 20, 2026
b960682
feat(ui): list existing remote folders in the wizard add-folder dialog
gnacho Aug 20, 2026
7ffc405
style(icons): stroke width 3 for the tray glyphs
gnacho Aug 20, 2026
3fd30f8
feat(tray): drop Settings and Pause Everything from the menu
gnacho Aug 20, 2026
057cf1e
fix(core): retry transient keyring failures instead of latching auth-…
gnacho Aug 20, 2026
fc44da3
feat(ui): live per-file progress on the folder rows
gnacho Aug 20, 2026
bf554d4
feat(tray): state-specific glyphs for syncing, problems and unconfigured
gnacho Aug 20, 2026
38b5330
fix(api): percent-decode remote folder names from WebDAV hrefs
gnacho Aug 20, 2026
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
3 changes: 3 additions & 0 deletions PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ package() {
io.github.gnacho.nextsync
io.github.gnacho.nextsync-folder
nextsync-tray-cloud
nextsync-tray-cloud-alert
nextsync-tray-cloud-check
nextsync-tray-cloud-off
nextsync-tray-cloud-sync
nextsync-tray-settings
)
for icon in "${colored[@]}"; do
Expand Down
15 changes: 15 additions & 0 deletions data/icons/nextsync-tray-cloud-alert.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions data/icons/nextsync-tray-cloud-check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion data/icons/nextsync-tray-cloud-off.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions data/icons/nextsync-tray-cloud-sync.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion data/icons/nextsync-tray-cloud.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion data/icons/nextsync-tray-settings.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 10 additions & 11 deletions po/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -1457,9 +1457,10 @@ msgstr "¿Eliminar la cuenta de Nextcloud?"

#: src/nextsync/ui/settings.py:709
msgid ""
"The account credential will be removed from the password keyring. Your local "
"NextCloud folder and all files inside it will remain untouched."
msgstr "La credencial de la cuenta se eliminará del almacén de contraseñas. Tu carpeta local de NextCloud y todos los archivos que contiene quedarán intactos."
"No files are deleted: neither the local synchronized folders nor anything "
"on the server. Only this app forgets the account, its credentials and its "
"sync configuration."
msgstr "No se elimina ningún archivo: ni las carpetas locales sincronizadas ni nada del servidor. Solo esta aplicación olvida la cuenta, sus credenciales y su configuración de sincronización."

#: src/nextsync/ui/settings.py:712 src/nextsync/ui/setup.py:133
msgid "Continue"
Expand All @@ -1473,10 +1474,12 @@ msgstr ""
#: src/nextsync/ui/settings.py:725
msgid ""
"Type “remove” to confirm. This cannot be undone and stops synchronization "
"immediately."
"immediately. No files are deleted, locally or on the server; the account "
"is simply forgotten in this app."
msgstr ""
"Escribe «remove» para confirmar. Esta acción no se puede deshacer y detiene "
"la sincronización de inmediato."
"la sincronización de inmediato. No se elimina ningún archivo, ni en local "
"ni en el servidor; la aplicación simplemente olvida la cuenta."

#: src/nextsync/ui/settings.py:727
msgid "Type “remove”"
Expand All @@ -1486,10 +1489,6 @@ msgstr "Escribe «remove»"
msgid "Type “remove” to confirm account removal."
msgstr "Escribe «remove» para confirmar la eliminación de la cuenta."

#: src/nextsync/ui/setup.py:34
msgid "Set Up NextSync"
msgstr "Configurar NextSync"

#: src/nextsync/ui/setup.py:84
msgid "A lightweight desktop synchronizer for Nextcloud."
msgstr "Un sincronizador de escritorio ligero para Nextcloud."
Expand Down Expand Up @@ -2222,8 +2221,8 @@ msgid "Both the local folder and the remote folder contain files. Confirm that y
msgstr "Tanto la carpeta local como la remota contienen archivos. Confirma que quieres fusionar su contenido."

#: src/core/sync_safety.rs
msgid "This folder was synchronized before. Hidden sync journal files were found: {names}. They record what the engine already transferred. Starting fresh moves them to the trash and re-downloads from the server; keeping them resumes the previous sync history."
msgstr "Esta carpeta se sincronizó anteriormente. Se encontraron archivos ocultos de registro de sincronización: {names}. Registran lo que el motor ya transfirió. Empezar de cero los mueve a la papelera y vuelve a descargar todo del servidor; mantenerlos reanuda el historial de sincronización anterior."
msgid "This folder was synchronized before."
msgstr "Esta carpeta se sincronizó anteriormente."

#: src/ui/safety_review.rs
msgid "Keep Synchronization History"
Expand Down
21 changes: 12 additions & 9 deletions src/core/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,13 @@ impl SchedulerInner {
return;
}
if self.keyring_locked && trigger != Trigger::Manual {
// Issue #85: the keyring was locked or unreachable when the last
// run started (common right after login while the secret service
// comes up). Re-attempt on the next automatic trigger instead of
// parking the folder in needs-attention: the lookup is cheap and
// this state carries no brute-force risk (that is the auth gate).
self.queue.add(trigger);
self.state
.set(AppState::KeyringLocked, t("Password keyring is locked"));
self.schedule_start();
return;
}
if let Some((_reason, message)) = self.environment_gate(trigger == Trigger::Manual) {
Expand Down Expand Up @@ -1140,7 +1144,7 @@ mod tests {
}

#[test]
fn locked_keyring_defers_automatic_triggers_until_manual_unlock() {
fn locked_keyring_retries_on_the_next_automatic_trigger() {
let (scheduler, source, runner) = make_scheduler(None);
scheduler.request(Trigger::Startup);
run_idle(&source);
Expand All @@ -1149,16 +1153,15 @@ mod tests {
assert!(scheduler.keyring_locked());
assert_eq!(scheduler.state().snapshot().state, AppState::KeyringLocked);

// Issue #85: an automatic trigger retries the run instead of parking
// the folder in needs-attention; the keyring may have come up in the
// meantime and the lookup is cheap.
scheduler.request(Trigger::LocalInotify);
scheduler.request(Trigger::RemoteInterval);
assert_eq!(runner.0.borrow().start_calls, 1);
assert_eq!(scheduler.queue_len(), 2);

scheduler.request(Trigger::Manual);
assert_eq!(scheduler.queue_len(), 1);
run_idle(&source);
assert_eq!(runner.0.borrow().start_calls, 2);
finish(&runner, SyncOutcome::Success);
assert!(!scheduler.keyring_locked());
assert_eq!(runner.0.borrow().start_calls, 2);
assert_eq!(scheduler.state().snapshot().state, AppState::IdleOk);
}

Expand Down
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ fn main() {
.application_id(APPLICATION_ID)
.build();

// App-wide style: the folder rows show their title in bold (issue #78).
// The internal title label has no public handle, so the weight is set
// through CSS on every folder-source row.
if let Some(display) = gtk4::gdk::Display::default() {
let provider = gtk4::CssProvider::new();
provider.load_from_bytes(&gtk4::glib::Bytes::from(
"row.folder-source .title label { font-weight: 700; }".as_bytes(),
));
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}

let window_slot: WindowSlot = Rc::new(RefCell::new(None));
let tray_slot: TraySlot = Rc::new(RefCell::new(None));
let tray_subscription: TraySubscriptionSlot = Rc::new(RefCell::new(None));
Expand Down
107 changes: 101 additions & 6 deletions src/nextcloud/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,10 @@ impl NextcloudApi {
}) {
continue;
}
let name = entry.href_path.rsplit('/').next().unwrap_or_default();
// WebDAV hrefs arrive percent-encoded; decode so pickers show
// and store the real name (issue #88). The engine re-encodes
// when building URLs.
let name = percent_decode_path(entry.href_path.rsplit('/').next().unwrap_or_default());
folders.push(format!("/{name}"));
}
folders.sort();
Expand Down Expand Up @@ -937,14 +940,31 @@ impl NextcloudApi {
let authorization = basic_authorization(username, password);
let headers = [("Authorization", authorization.as_str())];
let response = self.http.request("GET", &url, &headers, None)?;
// Nextcloud answers 404 with the generated placeholder avatar in the
// body when the user never uploaded one, so trust image bytes over
// the status code (verified against a real server).
if (response.status == 200 || response.status == 404)
&& Self::has_image_magic(&response.body)
{
return Ok(Some(response.body));
}
match response.status {
200 if !response.body.is_empty() => Ok(Some(response.body)),
200 | 404 | 301..=308 => Ok(None),
401 | 403 => Err(ApiError::AuthRejected),
status => Err(ApiError::Http { status }),
}
}

/// Whether the payload starts with a known image signature (PNG, JPEG,
/// GIF or WebP). Avatar endpoints answer errors with JSON/text bodies,
/// which must not be painted as images.
fn has_image_magic(body: &[u8]) -> bool {
body.starts_with(&[0x89, b'P', b'N', b'G'])
|| body.starts_with(&[0xFF, 0xD8, 0xFF])
|| body.starts_with(b"GIF8")
|| (body.len() >= 12 && body.starts_with(b"RIFF") && &body[8..12] == b"WEBP")
}

/// Run a Depth-1 PROPFIND and parse the multistatus response.
fn propfind(
&self,
Expand Down Expand Up @@ -986,6 +1006,31 @@ fn percent_encode_path(value: &str) -> String {
encoded
}

/// Percent-decode a path segment: `%XX` escapes (and bare `+`, never used
/// for spaces in paths) back to their bytes. WebDAV hrefs arrive encoded,
/// so folder names must be decoded before display or storage (issue #88);
/// the engine re-encodes when it builds URLs, keeping a single encoding
/// end-to-end.
fn percent_decode_path(value: &str) -> String {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
// Two hex digits must follow the percent sign; anything else is
// kept literally.
if let Ok(byte) = u8::from_str_radix(&value[i + 1..i + 3], 16) {
decoded.push(byte);
i += 3;
continue;
}
}
decoded.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&decoded).into_owned()
}

/// Base URL of the per-user WebDAV root.
fn dav_base(server: &str, username: &str) -> String {
format!(
Expand Down Expand Up @@ -1219,6 +1264,13 @@ mod tests {
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/alice/M%C3%BAsica%20Albums/</d:href>
<d:propstat>
<d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/alice/report.pdf</d:href>
<d:propstat>
Expand Down Expand Up @@ -1824,7 +1876,7 @@ mod tests {
let folders = api
.list_remote_folders("https://cloud.example.com", "alice", "secret")
.unwrap();
assert_eq!(folders, ["/Documents", "/Photos"]);
assert_eq!(folders, ["/Documents", "/Música Albums", "/Photos"]);
}

#[test]
Expand Down Expand Up @@ -2323,10 +2375,26 @@ mod tests {

let api = NextcloudApi::new();
let folders = api.list_remote_folders(&base, "alice", "secret").unwrap();
assert_eq!(folders, ["/Documents", "/Photos"]);
// Percent-encoded hrefs decode to the real names (issue #88).
assert_eq!(folders, ["/Documents", "/Música Albums", "/Photos"]);
handle.join().unwrap();
}

#[test]
fn percent_decode_round_trips_spaces_and_accents() {
// The inverse of the encoder used when building URLs: decoding what
// the encoder produced must return the original, so the engine's
// single re-encoding yields exactly one %XX per special byte. The
// picker works with single-segment names (the folder name).
for name in ["Música Albums", "a b&c", "plain", "100% seguro", "üñïçø∂é"] {
let encoded = percent_encode_path(name);
assert_eq!(percent_decode_path(&encoded), name, "name: {name}");
}
// Malformed escapes and bare percents stay literal.
assert_eq!(percent_decode_path("100%"), "100%");
assert_eq!(percent_decode_path("%zz"), "%zz");
}

/// The real transport maps a local 401 to `AuthRejected`.
#[test]
fn integration_auth_rejection_against_local_server() {
Expand Down Expand Up @@ -2430,8 +2498,8 @@ mod tests {
}

#[test]
fn avatar_is_none_on_404_redirect_or_empty_body() {
for status in [404u16, 301, 302, 200] {
fn avatar_is_none_on_redirect_or_empty_body() {
for status in [301u16, 302, 200] {
let body = if status == 200 { b"" } else { AVATAR_PNG };
let api = NextcloudApi::with_http(Box::new(FakeHttp::new(status, body)));
let avatar = api
Expand All @@ -2446,6 +2514,33 @@ mod tests {
}
}

#[test]
fn avatar_uses_image_body_even_on_404() {
// Nextcloud ships the generated placeholder avatar with a 404 status
// when the user has no custom one; a JSON error body stays None.
let api = NextcloudApi::with_http(Box::new(FakeHttp::new(404, AVATAR_PNG)));
let avatar = api
.fetch_avatar(
crate::nextcloud::driver::Provider::Nextcloud,
"https://cloud.example.com",
"alice",
"secret",
)
.unwrap();
assert_eq!(avatar.as_deref(), Some(AVATAR_PNG));

let api = NextcloudApi::with_http(Box::new(FakeHttp::new(404, b"[]")));
let avatar = api
.fetch_avatar(
crate::nextcloud::driver::Provider::Nextcloud,
"https://cloud.example.com",
"alice",
"secret",
)
.unwrap();
assert_eq!(avatar, None);
}

#[test]
fn avatar_maps_auth_and_http_failures() {
let api = NextcloudApi::with_http(Box::new(FakeHttp::new(401, b"")));
Expand Down
Loading
Loading