From 24046148670967bc069ab03239ca7f8da8d31af2 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 14 Aug 2026 19:45:44 +0200 Subject: [PATCH] feat(ui,core): account summary card, desktop notifications, theme circles, standard back arrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account summary card in the sync view (#23): login@server title with a severity light (aggregate state) and storage usage from the OCS user endpoint (quota; negative totals render as 'unlimited'). The fetch runs off the UI thread and only paints if the row still describes the same account. - Desktop notifications (#22): problem outcomes (Failed/AuthFailed/ KeyringLocked) raise a org.freedesktop.Notifications notification via notify-rust, gated by a new General 'Show desktop notifications' preference (default on). The Power preference is gone from the UI (the runtime plumbing stays, default off). Note: bandwidth limiting is not possible — nextcloudcmd has no throttle flag; 'Edit ignored files' and the push switch were already present in Synchronization. - Theme switcher (#21): three circular flat buttons at the header end (light bulb / half-filled circle / moon), grouped radio behavior, persisted in general.color_scheme. The radio section leaves the menu; About is the menu's last item. - Back arrow (#20): the broken custom undo-2 SVG is replaced by the standard go-previous-symbolic. Closes #20, closes #21, closes #22, closes #23 --- Cargo.lock | 260 +++++++++++++++++- Cargo.toml | 1 + PKGBUILD | 2 +- ...c.svg => nextsync-theme-auto-symbolic.svg} | 8 +- po/es.po | 28 ++ src/core/account_runtime.rs | 20 +- src/core/mod.rs | 1 + src/core/notifications.rs | 115 ++++++++ src/main.rs | 8 +- src/nextcloud/api.rs | 129 +++++++++ src/storage/config.rs | 14 + src/ui/main_window.rs | 257 ++++++++++++----- src/ui/settings.rs | 33 +-- src/util/translations/es.rs | 7 + 14 files changed, 787 insertions(+), 96 deletions(-) rename data/icons/{nextsync-undo-2-symbolic.svg => nextsync-theme-auto-symbolic.svg} (52%) create mode 100644 src/core/notifications.rs diff --git a/Cargo.lock b/Cargo.lock index 10b1fb6..ad468da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -440,6 +449,12 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -462,6 +477,16 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1152,6 +1177,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1201,6 +1240,7 @@ dependencies = [ "ksni", "libadwaita", "notify", + "notify-rust", "rand", "roxmltree", "rustls", @@ -1234,6 +1274,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "notify-types" version = "2.1.0" @@ -1276,6 +1330,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.47" @@ -1315,6 +1375,45 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1415,6 +1514,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1806,6 +1911,17 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1839,6 +1955,25 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny_http" version = "0.12.0" @@ -2049,6 +2184,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -2136,12 +2272,114 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2166,7 +2404,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2191,7 +2429,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -2202,6 +2440,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 894a139..76813b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ gio = "0.22" glib = { version = "0.22", features = ["futures"] } gtk4 = { version = "0.11", features = ["v4_22"] } ksni = { version = "0.3", features = ["blocking"] } +notify-rust = "4" libadwaita = { version = "0.9", features = ["v1_5"] } notify = "8" rand = "0.10" diff --git a/PKGBUILD b/PKGBUILD index b01b927..677f471 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -83,7 +83,7 @@ package() { nextsync-status-syncing-symbolic nextsync-info-symbolic nextsync-settings-2-symbolic - nextsync-undo-2-symbolic + nextsync-theme-auto-symbolic ) for icon in "${symbolic[@]}"; do install -Dm644 "data/icons/$icon.svg" \ diff --git a/data/icons/nextsync-undo-2-symbolic.svg b/data/icons/nextsync-theme-auto-symbolic.svg similarity index 52% rename from data/icons/nextsync-undo-2-symbolic.svg rename to data/icons/nextsync-theme-auto-symbolic.svg index 3654609..819bac7 100644 --- a/data/icons/nextsync-undo-2-symbolic.svg +++ b/data/icons/nextsync-theme-auto-symbolic.svg @@ -2,13 +2,13 @@ xmlns="http://www.w3.org/2000/svg" width="16" height="16" - viewBox="0 0 24 24" + viewBox="0 0 16 16" fill="none" stroke="currentColor" - stroke-width="2" + stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" > - - + + diff --git a/po/es.po b/po/es.po index 39a244f..c12e986 100644 --- a/po/es.po +++ b/po/es.po @@ -2154,3 +2154,31 @@ msgstr "Oscuro" #: src/ui/settings.rs msgid "Remove account" msgstr "Quitar cuenta" + +#: src/ui/settings.rs +msgid "Show desktop notifications" +msgstr "Mostrar notificaciones de escritorio" + +#: src/ui/settings.rs +msgid "Authentication and synchronization failures" +msgstr "Fallo de autenticación y sincronización" + +#: src/ui/settings.rs +msgid "Notifications" +msgstr "Notificaciones" + +#: src/core/notifications.rs +msgid "The server rejected the account credentials." +msgstr "El servidor rechazó las credenciales de la cuenta." + +#: src/core/notifications.rs +msgid "The password keyring is locked." +msgstr "El almacén de contraseñas está bloqueado." + +#: src/core/notifications.rs +msgid "A synchronization failed." +msgstr "Una sincronización ha fallado." + +#: src/core/notifications.rs +msgid "NextSync" +msgstr "NextSync" diff --git a/src/core/account_runtime.rs b/src/core/account_runtime.rs index 2f8640f..af2f6dc 100644 --- a/src/core/account_runtime.rs +++ b/src/core/account_runtime.rs @@ -541,13 +541,21 @@ impl AccountRuntime { /// Connect an activity logger to every folder scheduler: one line per /// finished run with its outcome. The logger is shared (`LogBuffer` is /// `Clone` over a shared buffer), so the UI recent/log views see the same - /// lines the engine writes. - pub fn connect_logger(&self, logger: &crate::core::log::LogBuffer) { + /// lines the engine writes. When `notifications` is enabled, problem + /// outcomes also raise a desktop notification through `notifier`. + pub fn connect_logger( + &self, + logger: &crate::core::log::LogBuffer, + notifier: Option>, + notifications_enabled: bool, + ) { let account_label = self.account.id.clone(); for runtime in self.folders.values() { let account_label = account_label.clone(); let folder_id = runtime.folder.id.clone(); let logger = logger.clone(); + let notifier = notifier.clone(); + let folder_path = runtime.folder.local_root.clone(); runtime .scheduler() .set_on_completed(Some(Box::new(move |outcome| { @@ -555,6 +563,14 @@ impl AccountRuntime { "{account_label}/{folder_id}: {}", outcome_log_line(outcome) )); + if let Some(notifier) = notifier.as_ref() { + crate::core::notifications::notify_for_outcome( + notifier, + notifications_enabled, + &folder_path, + outcome, + ); + } }))); } } diff --git a/src/core/mod.rs b/src/core/mod.rs index 937caaf..1e1344b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -10,6 +10,7 @@ pub mod desktop_integration; pub mod exclusions; pub mod log; pub mod network; +pub mod notifications; pub mod power; pub mod scheduler; pub mod suspend; diff --git a/src/core/notifications.rs b/src/core/notifications.rs new file mode 100644 index 0000000..7c323fb --- /dev/null +++ b/src/core/notifications.rs @@ -0,0 +1,115 @@ +//! Desktop notifications for synchronization problems. +//! +//! Port of the Python app's `Gio.Notification` usage (application.py): +//! when a folder run ends in `AuthFailed`, `KeyringLocked` or `Failed`, a +//! desktop notification is sent so failures on an unfocused window are not +//! missed. The transport is injectable: production uses `notify-rust` +//! (org.freedesktop.Notifications); tests count callbacks instead. + +use std::cell::Cell; +use std::rc::Rc; + +/// Sink for desktop notifications. +pub trait DesktopNotifier { + /// Send a notification; `summary` is the title, `body` the detail. + fn send(&self, summary: &str, body: &str); +} + +/// Production notifier over org.freedesktop.Notifications (notify-rust). +pub struct FreedesktopNotifier; + +impl DesktopNotifier for FreedesktopNotifier { + fn send(&self, summary: &str, body: &str) { + if let Err(error) = notify_rust::Notification::new() + .summary(summary) + .body(body) + .appname("nextsync") + .show() + { + eprintln!("notification failed: {error}"); + } + } +} + +/// Test notifier recording every send. +#[derive(Default)] +pub struct CountingNotifier { + pub sent: Cell, +} + +impl DesktopNotifier for CountingNotifier { + fn send(&self, _summary: &str, _body: &str) { + self.sent.set(self.sent.get() + 1); + } +} + +/// Notification copy for one outcome. +/// +/// Returns `None` for healthy outcomes (Success/Conflict) — notifications +/// exist to surface problems, not to celebrate. +pub fn failure_notification(outcome: &crate::core::scheduler::SyncOutcome) -> Option<&'static str> { + use crate::core::scheduler::SyncOutcome; + match outcome { + SyncOutcome::AuthFailed => Some(crate::util::i18n::t( + "The server rejected the account credentials.", + )), + SyncOutcome::KeyringLocked => Some(crate::util::i18n::t("The password keyring is locked.")), + SyncOutcome::Failed => Some(crate::util::i18n::t("A synchronization failed.")), + SyncOutcome::Success | SyncOutcome::Conflict => None, + } +} + +/// Whether the outcome deserves a notification, resolved against the pure +/// copy table (kept separate so tests do not need a desktop bus). +pub fn notify_for_outcome( + notifier: &Rc, + enabled: bool, + account_label: &str, + outcome: &crate::core::scheduler::SyncOutcome, +) { + if !enabled { + return; + } + let Some(body) = failure_notification(outcome) else { + return; + }; + notifier.send( + crate::util::i18n::t("NextSync"), + &format!("{account_label}: {body}"), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::scheduler::SyncOutcome; + + #[test] + fn healthy_outcomes_do_not_notify() { + let notifier: Rc = Rc::new(CountingNotifier::default()); + notify_for_outcome(¬ifier, true, "acct", &SyncOutcome::Success); + notify_for_outcome(¬ifier, true, "acct", &SyncOutcome::Conflict); + } + + #[test] + fn failure_outcomes_notify_once() { + for outcome in [ + SyncOutcome::Failed, + SyncOutcome::AuthFailed, + SyncOutcome::KeyringLocked, + ] { + let sent = Rc::new(CountingNotifier::default()); + let notifier: Rc = sent.clone(); + notify_for_outcome(¬ifier, true, "acct", &outcome); + assert_eq!(sent.sent.get(), 1, "{outcome:?}"); + } + } + + #[test] + fn disabled_notifications_are_silent() { + let sent = Rc::new(CountingNotifier::default()); + let notifier: Rc = sent.clone(); + notify_for_outcome(¬ifier, false, "acct", &SyncOutcome::Failed); + assert_eq!(sent.sent.get(), 0); + } +} diff --git a/src/main.rs b/src/main.rs index 3fe4d80..0dcd4cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,9 +77,13 @@ fn main() { // Start the per-folder filesystem watchers and progress forwarders // (main-loop consumers, production only). account_manager.connect_all_glue(); - // Feed the activity/recent log from every folder's finished runs. + // Feed the activity/recent log from every folder's finished runs + // and raise desktop notifications for problem outcomes. + let notifier: std::rc::Rc = + std::rc::Rc::new(nextsync::core::notifications::FreedesktopNotifier); + let notifications_enabled = config.general.show_notifications; for runtime in account_manager.runtimes().values() { - runtime.connect_logger(&logger); + runtime.connect_logger(&logger, Some(notifier.clone()), notifications_enabled); } // Wire notify_push for every account that has a push client, diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs index fe0d850..3de91d4 100644 --- a/src/nextcloud/api.rs +++ b/src/nextcloud/api.rs @@ -102,6 +102,46 @@ pub struct HttpResponse { pub body: Vec, } +/// Display name plus storage quota for the account summary card. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AccountSummary { + /// The server-side display name, when present. + pub display_name: Option, + /// Storage used, in bytes. `None` when the server reports no quota. + pub used: Option, + /// Storage total, in bytes. `None` when unlimited. + pub total: Option, +} + +impl AccountSummary { + /// Human-readable usage, e.g. `53.2 GB used · unlimited` or + /// `53.2 GB of 100 GB used`. + pub fn usage_label(&self) -> String { + match (self.used, self.total) { + (Some(used), Some(total)) => { + format!("{} / {}", format_bytes(used), format_bytes(total)) + } + (Some(used), None) => format!("{} · {}", format_bytes(used), "unlimited"), + (None, _) => String::new(), + } + } +} + +/// Format bytes with binary prefixes (KiB/MiB/GiB), one decimal. +pub(crate) fn format_bytes(bytes: u64) -> String { + const UNIT: f64 = 1024.0; + let value = bytes as f64; + if value >= UNIT * UNIT * UNIT { + format!("{:.1} GiB", value / (UNIT * UNIT * UNIT)) + } else if value >= UNIT * UNIT { + format!("{:.1} MiB", value / (UNIT * UNIT)) + } else if value >= UNIT { + format!("{:.1} KiB", value / UNIT) + } else { + format!("{bytes} B") + } +} + /// Minimal HTTP transport abstraction, mirror of the Python `HttpClient`. /// /// `request` returns transport failures as `Err(ApiError::Transport)`; every @@ -176,6 +216,58 @@ impl NextcloudApi { Ok(display_name) } + /// Account summary from the OCS user endpoint: display name plus quota. + /// + /// `used`/`total` are bytes; servers with no quota report negative + /// values (e.g. `-3`), which map to `None` (render "unlimited"). + pub fn account_summary( + &self, + server: &str, + username: &str, + password: &str, + ) -> Result { + let url = format!( + "{}/ocs/v2.php/cloud/user?format=json", + server.trim_end_matches('/') + ); + let authorization = basic_authorization(username, password); + let headers = [ + ("Accept", "application/json"), + ("OCS-APIREQUEST", "true"), + ("Authorization", authorization.as_str()), + ]; + let response = self.http.request("GET", &url, &headers, None)?; + map_status(response.status)?; + let payload: serde_json::Value = + serde_json::from_slice(&response.body).map_err(|_| ApiError::InvalidResponse)?; + let data = payload + .get("ocs") + .and_then(|ocs| ocs.get("data")) + .cloned() + .unwrap_or_default(); + let display_name = data + .get("display-name") + .and_then(|name| name.as_str()) + .map(str::to_owned); + let used = data + .get("quota") + .and_then(|quota| quota.get("used")) + .and_then(|used| used.as_f64()) + .filter(|used| *used >= 0.0) + .map(|used| used as u64); + let total = data + .get("quota") + .and_then(|quota| quota.get("total")) + .and_then(|total| total.as_f64()) + .filter(|total| *total > 0.0) + .map(|total| total as u64); + Ok(AccountSummary { + display_name, + used, + total, + }) + } + /// Create the remote folder (and any missing parents) over WebDAV MKCOL. /// /// `nextcloudcmd` fails silently (exit 1, no output) when `--path` points @@ -1013,4 +1105,41 @@ mod tests { assert_eq!(result, Err(ApiError::AuthRejected)); handle.join().unwrap(); } + #[test] + fn account_summary_parses_quota_and_handles_unlimited() { + let body = br#"{"ocs":{"meta":{"status":"ok"},"data":{"display-name":"Alice","quota":{"used":57077043830,"total":-3,"free":-3}}}}"#; + let http = FakeHttp::new(200, body); + let api = NextcloudApi::with_http(Box::new(http)); + let summary = api + .account_summary("https://cloud.example.com", "alice", "pw") + .unwrap(); + assert_eq!(summary.display_name.as_deref(), Some("Alice")); + assert_eq!(summary.used, Some(57077043830)); + assert_eq!(summary.total, None, "negative total means unlimited"); + assert!(summary.usage_label().ends_with("· unlimited")); + assert!(summary.usage_label().starts_with("53.2 GiB")); + } + + #[test] + fn usage_label_joins_used_and_total() { + let summary = AccountSummary { + display_name: None, + used: Some(1024 * 1024 * 1024), + total: Some(2 * 1024 * 1024 * 1024), + }; + assert_eq!(summary.usage_label(), "1.0 GiB / 2.0 GiB"); + } + + #[test] + fn usage_label_empty_without_quota() { + let summary = AccountSummary::default(); + assert_eq!(summary.usage_label(), ""); + } + + #[test] + fn format_bytes_uses_binary_prefixes() { + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(2048), "2.0 KiB"); + assert_eq!(format_bytes(1024 * 1024 * 5), "5.0 MiB"); + } } diff --git a/src/storage/config.rs b/src/storage/config.rs index 95f61c8..ab58204 100644 --- a/src/storage/config.rs +++ b/src/storage/config.rs @@ -204,10 +204,17 @@ pub struct RuntimeConfig { #[serde(default)] pub struct GeneralConfig { pub autostart: bool, + /// Battery pause preference. The UI switch was removed by user decision + /// (issue #22); the field stays (default false, neutral behavior) so the + /// runtime plumbing and older config files keep working. + #[serde(default)] pub pause_on_battery: bool, /// Color scheme preference: `"system"`, `"light"` or `"dark"`. #[serde(default = "default_color_scheme")] pub color_scheme: String, + /// Whether desktop notifications are sent for sync/auth failures. + #[serde(default = "yes")] + pub show_notifications: bool, } /// Default color scheme (follow the desktop). @@ -215,12 +222,18 @@ fn default_color_scheme() -> String { "system".to_string() } +/// Default for `show_notifications` (on). +fn yes() -> bool { + true +} + impl Default for GeneralConfig { fn default() -> Self { Self { autostart: true, pause_on_battery: false, color_scheme: default_color_scheme(), + show_notifications: yes(), } } } @@ -998,6 +1011,7 @@ fn validate_general(raw: Option<&Value>) -> GeneralConfig { autostart: get_bool(obj, "autostart", true), pause_on_battery: get_bool(obj, "pause_on_battery", false), color_scheme: get_string(obj, "color_scheme", &default_color_scheme()), + show_notifications: get_bool(obj, "show_notifications", true), } } diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index f3ac386..6cf7919 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -120,6 +120,74 @@ impl AccountView { .selection_mode(gtk4::SelectionMode::None) .build(); + // Account summary card: login@server, storage used and an all-ok + // light driven by the aggregate state. The quota fetch runs off the + // UI thread; on any failure the card simply shows no usage line. + let summary_row = libadwaita::ActionRow::builder() + .title(format!( + "{}@{}", + account.login_name, + server_host(&account.server_url) + )) + .build(); + let light = gtk4::Image::builder().pixel_size(14).build(); + light.set_icon_name(Some(summary_light_for(runtime.state().snapshot().state))); + summary_row.add_prefix(&light); + let usage_label = gtk4::Label::builder() + .css_classes(["caption"]) + .halign(gtk4::Align::End) + .build(); + summary_row.add_suffix(&usage_label); + account_list.append(&summary_row); + { + // Quota fetch: only plain data crosses to the blocking thread; + // widget clones are captured in the main-loop continuation. + let account_for_quota = account.clone(); + let handle = gio::spawn_blocking(move || { + crate::nextcloud::credentials::CredentialsStore::get_for_account( + &account_for_quota.id, + &account_for_quota.server_url, + &account_for_quota.login_name, + ) + .ok() + .flatten() + .and_then(|password| { + crate::nextcloud::api::NextcloudApi::new() + .account_summary( + &account_for_quota.server_url, + &account_for_quota.login_name, + &password, + ) + .ok() + }) + }); + let usage_label = usage_label.clone(); + let summary_row = summary_row.clone(); + let title_for_check = summary_row.title().to_string(); + glib::spawn_future_local(async move { + let Ok(Some(summary)) = handle.await else { + return; + }; + // The view may have been rebuilt for another account while + // the fetch ran; a detached row keeps its title, so compare + // against the one captured at fetch start. + if summary_row.title() != title_for_check { + return; + } + let mut parts = Vec::new(); + if let Some(name) = summary.display_name.clone() { + if !name.is_empty() { + parts.push(name); + } + } + let usage = summary.usage_label(); + if !usage.is_empty() { + parts.push(usage); + } + usage_label.set_text(&parts.join(" · ")); + }); + } + let pairs = pair_folder_runtimes(&account.folders, runtime.folders()); for (folder, folder_runtime) in pairs { let local_root = folder.local_root.clone(); @@ -378,7 +446,7 @@ impl MainWindow { // of the header; the hamburger menu sits at the far right, next to // the window close button. let back_button = gtk4::Button::builder() - .icon_name("nextsync-undo-2-symbolic") + .icon_name("go-previous-symbolic") .tooltip_text(t("Synchronization")) .css_classes(["flat"]) .build(); @@ -390,6 +458,51 @@ impl MainWindow { }); header.pack_start(&back_button); + // Color-scheme switcher (gnome-text-editor style): three circular + // buttons — light, system (half-filled) and dark — in a flat group. + let scheme_group = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(2) + .build(); + let make_scheme_button = |icon: &str, tooltip: &str, scheme: &'static str| { + let button = gtk4::ToggleButton::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .css_classes(["flat", "circular"]) + .build(); + let weak = self_weak.clone(); + let store_for_scheme = config_store.clone(); + button.connect_toggled(move |button| { + if !button.is_active() { + return; + } + libadwaita::StyleManager::default().set_color_scheme(color_scheme_for(scheme)); + if let Some(main) = weak.upgrade() { + let mut main = main.borrow_mut(); + main.config.general.color_scheme = scheme.to_string(); + let mut persisted = store_for_scheme.load().unwrap_or_default(); + persisted.general.color_scheme = scheme.to_string(); + let _ = store_for_scheme.save(&persisted); + } + }); + button + }; + let scheme_light = make_scheme_button("display-brightness-symbolic", t("Light"), "light"); + let scheme_system = + make_scheme_button("nextsync-theme-auto-symbolic", t("System"), "system"); + let scheme_dark = make_scheme_button("weather-clear-night-symbolic", t("Dark"), "dark"); + scheme_system.set_group(Some(&scheme_light)); + scheme_dark.set_group(Some(&scheme_light)); + match config.general.color_scheme.as_str() { + "light" => scheme_light.set_active(true), + "dark" => scheme_dark.set_active(true), + _ => scheme_system.set_active(true), + } + scheme_group.append(&scheme_light); + scheme_group.append(&scheme_system); + scheme_group.append(&scheme_dark); + header.pack_end(&scheme_group); + let hamburger = gtk4::MenuButton::builder() .icon_name("open-menu-symbolic") .tooltip_text(t("Settings")) @@ -417,32 +530,6 @@ impl MainWindow { }); action }); - actions.add_action(&{ - let weak = self_weak.clone(); - let store_for_theme = config_store.clone(); - let current = config.general.color_scheme.clone(); - let action = gio::SimpleAction::new_stateful( - "theme", - Some(glib::VariantTy::STRING), - ¤t.to_variant(), - ); - action.connect_activate(move |action, parameter| { - let Some(value) = parameter.and_then(|value| value.str()) else { - return; - }; - let scheme = value.to_string(); - libadwaita::StyleManager::default().set_color_scheme(color_scheme_for(&scheme)); - action.set_state(&scheme.to_variant()); - if let Some(main) = weak.upgrade() { - let mut main = main.borrow_mut(); - main.config.general.color_scheme = scheme.clone(); - let mut persisted = store_for_theme.load().unwrap_or_default(); - persisted.general.color_scheme = scheme; - let _ = store_for_theme.save(&persisted); - } - }); - action - }); hamburger.insert_action_group("app", Some(&actions)); header.pack_end(&hamburger); @@ -1050,8 +1137,7 @@ impl MainWindow { /// /// Extracted from [`MainWindow::new`] so the menu contract (sections, actions /// and icons) is testable without a display. -/// The header menu (gnome-text-editor style): Preferences and About, plus a -/// color-scheme radio section (System / Light / Dark). +/// The header menu (gnome-text-editor style): Preferences and About. fn hamburger_menu_model() -> gio::Menu { let menu = gio::Menu::new(); let preferences_item = gio::MenuItem::new(Some(t("Preferences")), Some("app.preferences")); @@ -1060,18 +1146,6 @@ fn hamburger_menu_model() -> gio::Menu { let about_item = gio::MenuItem::new(Some(t("About")), Some("app.about")); about_item.set_icon(&gio::ThemedIcon::new("nextsync-info-symbolic")); menu.append_item(&about_item); - - let theme_section = gio::Menu::new(); - for (label, value) in [ - (t("System"), "system"), - (t("Light"), "light"), - (t("Dark"), "dark"), - ] { - let item = gio::MenuItem::new(Some(label), None); - item.set_action_and_target_value(Some("app.theme"), Some(&value.to_variant())); - theme_section.append_item(&item); - } - menu.append_section(None, &theme_section); menu } @@ -1086,6 +1160,35 @@ pub fn color_scheme_for(preference: &str) -> libadwaita::ColorScheme { } } +/// The all-ok light icon for the account summary card, from the aggregate +/// state: green when healthy, amber when paused/offline, red on problems. +pub fn summary_light_for(state: crate::state::AppState) -> &'static str { + use crate::state::AppState; + match state { + AppState::IdleOk | AppState::IdleManualOnly | AppState::SyncQueued | AppState::Syncing => { + "nextsync-status-ok-symbolic" + } + AppState::PausedUser | AppState::PausedBattery | AppState::Offline => { + "nextsync-status-paused-symbolic" + } + AppState::Error + | AppState::AuthRequired + | AppState::KeyringLocked + | AppState::DeleteReview => "nextsync-status-error-symbolic", + AppState::Unconfigured => "nextsync-status-offline-symbolic", + } +} + +/// Host part of a server URL (`https://cloud.example.com` -> +/// `cloud.example.com`); the raw URL when it does not parse as expected. +pub fn server_host(server_url: &str) -> &str { + let trimmed = server_url.trim_end_matches('/'); + match trimmed.split_once("://") { + Some((_scheme, host)) => host, + None => trimmed, + } +} + /// Build the sidebar: the container, the accounts list and the Add Account /// button. fn build_sidebar() -> (gtk4::Box, gtk4::ListBox, gtk4::Button) { @@ -1137,6 +1240,48 @@ mod tests { reset_locale(); } + #[test] + fn server_host_strips_scheme_and_trailing_slash() { + assert_eq!( + server_host("https://cloud.example.com"), + "cloud.example.com" + ); + assert_eq!( + server_host("https://cloud.example.com/"), + "cloud.example.com" + ); + assert_eq!(server_host("cloud.example.com"), "cloud.example.com"); + } + + #[test] + fn summary_light_maps_severity() { + use crate::state::AppState; + assert_eq!( + summary_light_for(AppState::IdleOk), + "nextsync-status-ok-symbolic" + ); + assert_eq!( + summary_light_for(AppState::Syncing), + "nextsync-status-ok-symbolic" + ); + assert_eq!( + summary_light_for(AppState::PausedUser), + "nextsync-status-paused-symbolic" + ); + assert_eq!( + summary_light_for(AppState::Offline), + "nextsync-status-paused-symbolic" + ); + assert_eq!( + summary_light_for(AppState::Error), + "nextsync-status-error-symbolic" + ); + assert_eq!( + summary_light_for(AppState::DeleteReview), + "nextsync-status-error-symbolic" + ); + } + #[test] fn window_subtitle_translates_to_spanish() { set_locale(Locale::Spanish); @@ -1216,8 +1361,8 @@ mod tests { set_locale(Locale::English); let menu = hamburger_menu_model(); - // Preferences + About, then the color-scheme section. - assert_eq!(menu.n_items(), 3); + // Preferences + About — About last. + assert_eq!(menu.n_items(), 2); let expected: [(&str, &str); 2] = [("Preferences", "app.preferences"), ("About", "app.about")]; for (index, (label, action)) in expected.iter().enumerate() { @@ -1227,32 +1372,6 @@ mod tests { assert!(attrs.has_icon, "item {index} must carry an icon"); } - // The color-scheme section carries radio items with the target value. - let theme_section = menu.item_link(2, gio::MENU_LINK_SECTION).expect("section"); - let expected_theme: [(&str, &str); 3] = - [("System", "system"), ("Light", "light"), ("Dark", "dark")]; - for (index, (label, target)) in expected_theme.iter().enumerate() { - let mut found_label = None; - let mut found_action = None; - let mut found_target = None; - let iter = theme_section.iterate_item_attributes(index as i32); - while let Some((key, value)) = iter.next() { - match key.as_str() { - "label" => found_label = value.str().map(str::to_string), - "action" => found_action = value.str().map(str::to_string), - "target" => found_target = value.str().map(str::to_string), - _ => {} - } - } - assert_eq!(found_label.as_deref(), Some(*label), "theme item {index}"); - assert_eq!( - found_action.as_deref(), - Some("app.theme"), - "theme items must be radio-style with an action target" - ); - assert_eq!(found_target.as_deref(), Some(*target), "theme item {index}"); - } - // The Spanish catalog covers every menu entry (the menu is // user-visible on every launch). set_locale(Locale::Spanish); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index e26b133..777e0fd 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -193,7 +193,7 @@ pub mod page { // Page builders // --------------------------------------------------------------------------- -/// General page: Startup and Power switches. +/// General page: Startup and Notifications switches. fn build_general_page(store: &ConfigStore, general: &GeneralConfig) -> libadwaita::PreferencesPage { let page = libadwaita::PreferencesPage::builder() .title(t("General")) @@ -207,37 +207,38 @@ fn build_general_page(store: &ConfigStore, general: &GeneralConfig) -> libadwait .title(t("Start NextSync when I sign in")) .active(general.autostart) .build(); - let pause_battery = libadwaita::SwitchRow::builder() - .title(t("Pause synchronization while on battery")) - .subtitle(t("A running synchronization is allowed to finish.")) - .active(general.pause_on_battery) + + let notifications = libadwaita::SwitchRow::builder() + .title(t("Show desktop notifications")) + .subtitle(t("Authentication and synchronization failures")) + .active(general.show_notifications) .build(); { let store = store.clone(); let autostart_guard = autostart.clone(); - let pause_guard = pause_battery.clone(); + let notifications_guard = notifications.clone(); autostart.connect_active_notify(move |_| { - save_general(&store, &autostart_guard, &pause_guard); + save_general(&store, &autostart_guard, ¬ifications_guard); }); } { let store = store.clone(); let autostart_guard = autostart.clone(); - let pause_guard = pause_battery.clone(); - pause_battery.connect_active_notify(move |_| { - save_general(&store, &autostart_guard, &pause_guard); + let notifications_guard = notifications.clone(); + notifications.connect_active_notify(move |_| { + save_general(&store, &autostart_guard, ¬ifications_guard); }); } startup.add(&autostart); page.add(&startup); - let power = libadwaita::PreferencesGroup::builder() - .title(t("Power")) + let notifications_group = libadwaita::PreferencesGroup::builder() + .title(t("Notifications")) .build(); - power.add(&pause_battery); - page.add(&power); + notifications_group.add(¬ifications); + page.add(¬ifications_group); page } @@ -802,11 +803,11 @@ fn invoke(callback: &Option) { fn save_general( store: &ConfigStore, autostart: &libadwaita::SwitchRow, - pause: &libadwaita::SwitchRow, + notifications: &libadwaita::SwitchRow, ) { if let Err(error) = persist_config(store, |config| { config.general.autostart = autostart.is_active(); - config.general.pause_on_battery = pause.is_active(); + config.general.show_notifications = notifications.is_active(); }) { eprintln!("Settings: could not save general settings: {error}"); } diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index c0f8ed1..9c23216 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -8,6 +8,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("A locked Login collection is no longer misreported as a missing stored credential during desktop autostart.", "Una colección de inicio de sesión bloqueada ya no se informa incorrectamente como una credencial almacenada ausente durante el inicio automático del escritorio."), ("A new version is available", "Hay una nueva versión disponible"), ("A running synchronization is allowed to finish.", "Se permite terminar la sincronización en curso."), + ("A synchronization failed.", "Una sincronización ha fallado."), ("About", "Acerca de"), ("About now identifies and displays the GPLv3 license using GTK's native license presentation.", "Acerca de ahora identifica y muestra la licencia GPLv3 mediante la presentación nativa de licencias de GTK."), ("Account", "Cuenta"), @@ -36,6 +37,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Advanced", "Avanzado"), ("Allow invalid or self-signed certificates", "Permitir certificados no válidos o autofirmados"), ("Authentication", "Autenticación"), + ("Authentication and synchronization failures", "Fallo de autenticación y sincronización"), ("Auto-scroll", "Desplazamiento automático"), ("Automatic Sync Is Off", "Sincronización automática desactivada"), ("Automatic synchronization is off. Files synchronize only with Sync Now.", "La sincronización automática está desactivada. Los archivos se sincronizan solo con Sincronizar ahora."), @@ -151,6 +153,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Monitor filesystem changes", "Supervisar cambios del sistema de archivos"), ("Network", "Red"), ("Network error.", "Error de red."), + ("NextSync", "NextSync"), ("NextSync Is Up to Date", "NextSync está actualizado"), ("NextSync is an independent, unofficial third-party project. It is not affiliated with, sponsored by, endorsed by, maintained by, or otherwise connected to Nextcloud GmbH. Nextcloud is a registered trademark of Nextcloud GmbH.\n\nBidirectional synchronization can upload, download, replace, conflict, or delete files on both the computer and the configured server. Use this software entirely at your own risk, test it with non-critical data, and maintain independent, restorable backups. The authors and contributors are not responsible for lost, corrupted, deleted, or otherwise damaged data.\n\nThe application delegates file reconciliation and conflict handling to nextcloudcmd. Availability of notify_push and other server features depends on the Nextcloud installation. This release was tested with Nextcloud Hub 26 Spring (34.0.1) on Nextcloud AIO. Compatibility with other or future Nextcloud versions is not guaranteed.\n\nNo telemetry, advertising, or usage tracking is included. Credentials are stored through the desktop Secret Service. Use of this software is also subject to the GNU General Public License version 3 or later and its warranty disclaimer.", "NextSync es un proyecto independiente, no oficial y desarrollado por terceros. No está afiliado, patrocinado, respaldado, mantenido ni vinculado de ninguna otra forma con Nextcloud GmbH. Nextcloud es una marca registrada de Nextcloud GmbH.\n\nLa sincronización bidireccional puede subir, descargar, reemplazar, crear conflictos o eliminar archivos tanto en el equipo como en el servidor configurado. Use este software bajo su propia responsabilidad, pruébelo con datos no críticos y mantenga copias de seguridad independientes y restaurables. Los autores y colaboradores no se responsabilizan de datos perdidos, dañados, eliminados o corrompidos.\n\nLa aplicación delega la reconciliación de archivos y el tratamiento de conflictos a nextcloudcmd. La disponibilidad de notify_push y de otras funciones del servidor depende de la instalación de Nextcloud. Esta versión se probó con Nextcloud Hub 26 Spring (34.0.1) en Nextcloud AIO. No se garantiza la compatibilidad con otras versiones ni con versiones futuras de Nextcloud.\n\nNo se incluye telemetría, publicidad ni seguimiento de uso. Las credenciales se almacenan mediante el Secret Service del escritorio. El uso de este software también está sujeto a la Licencia Pública General de GNU versión 3 o posterior y a su exención de garantías."), ("NextSync will quit when the current synchronization finishes.", "NextSync se cerrará cuando termine la sincronización actual."), @@ -167,6 +170,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Not Now", "Ahora no"), ("Not connected", "Sin conexión"), ("Not yet synchronized", "Aún no sincronizado"), + ("Notifications", "Notificaciones"), ("Offline", "Sin conexión"), ("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."), ("Open Browser Again", "Abrir el navegador de nuevo"), @@ -235,6 +239,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Set Up NextSync", "Configurar NextSync"), ("Settings", "Configuración"), ("Settings now opens independently from the tray without opening the main window.", "La Configuración ahora se abre de forma independiente desde la bandeja, sin abrir la ventana principal."), + ("Show desktop notifications", "Mostrar notificaciones de escritorio"), ("Show in Files sidebar", "Mostrar en la barra lateral de Archivos"), ("Show on Desktop", "Mostrar en el escritorio"), ("Sign In", "Iniciar sesión"), @@ -279,6 +284,8 @@ pub static CATALOG: &[(&str, &str)] = &[ ("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 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 server rejected the account credentials.", "El servidor rechazó las credenciales de la cuenta."), ("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."),