From 51d114a8da0c663a6e0633a67d178f3f02bebd7a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 11:05:12 +0200 Subject: [PATCH 1/8] fix(fuse,core): idle drain workers no longer spin while offline Offline or paused, a drain worker claims nothing and then slept until the earliest queued op fell due. Every queued op was already due and nothing attempted it, so the wait was zero and all 16 workers spun on the queue for the whole outage (issue #35: 17 min of CPU in 2 min 16 s). A worker that claims nothing now sleeps the full idle poll; a reconnect or resume wakes it. earliest_due_at also uses the claim's exclusions, so an op held behind a claimed op on the same node no longer counts as due. docs/BUGS.md B200. --- crates/pdfs-core/src/db/ops.rs | 43 +++++++++++++--------- crates/pdfs-core/src/db/tests.rs | 30 ++++++++++++++++ crates/pdfs-fuse/src/drain.rs | 62 ++++++++++++++++++++++---------- docs/BUGS.md | 28 +++++++++++++++ 4 files changed, 129 insertions(+), 34 deletions(-) diff --git a/crates/pdfs-core/src/db/ops.rs b/crates/pdfs-core/src/db/ops.rs index 25f5e85..9edabc3 100644 --- a/crates/pdfs-core/src/db/ops.rs +++ b/crates/pdfs-core/src/db/ops.rs @@ -252,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li const PARENT_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)"; +/// The `WHERE` terms for a row a drain worker may claim once it falls due: not +/// claimed already, no other claimed op on the same node, and not under a +/// folder that has yet to land. +/// +/// One fragment for [`Db::claim_next_due_op`] and [`Db::earliest_due_at`]. A +/// row the claim skips but the wait counts is due "now" for as long as it is +/// held, and every idle worker spun on it (`docs/BUGS.md` B200). +fn claimable() -> String { + format!( + "claimed_at = 0 + AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~') + AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0) + AND (lid IS NULL OR lid NOT IN ( + SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))", + v = LOCAL_VOLUME, + n = LOCAL_VOLUME.len() + 1, + ) +} + /// The volume id given to a node that exists only on this machine, so far. A /// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~` and /// round-trips through the same `Display`/parse path as any other uid. @@ -1127,15 +1146,9 @@ impl Db { "SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at, attempts, last_error, next_attempt_at FROM pending_op - WHERE next_attempt_at <= ?1 - AND claimed_at = 0 - AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~') - AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0) - AND (lid IS NULL OR lid NOT IN ( - SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL)) + WHERE next_attempt_at <= ?1 AND {claimable} ORDER BY id LIMIT 1", - v = LOCAL_VOLUME, - n = LOCAL_VOLUME.len() + 1, + claimable = claimable(), ))?; stmt.query_row(params![now], |r| { Ok(PendingOp { @@ -1208,19 +1221,17 @@ impl Db { /// debounced or backed-off op becomes eligible rather than waiting the full /// idle-poll interval. /// - /// Claimed ops are excluded: another worker is already on them, so they are - /// not work this one is waiting for, and counting them would have an idle - /// worker spin on a row it cannot have. + /// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take + /// count. A claimed op, or one held behind a claimed op on the same node, + /// is not work this worker is waiting for, and counting it would have an + /// idle worker spin on a row it cannot have. pub fn earliest_due_at(&self) -> Result> { let conn = self.read(); let ts: Option = conn .query_row( &format!( - "SELECT MIN(next_attempt_at) FROM pending_op \ - WHERE claimed_at = 0 \ - AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')", - v = LOCAL_VOLUME, - n = LOCAL_VOLUME.len() + 1, + "SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}", + claimable = claimable(), ), [], |r| r.get(0), diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 6991ba5..1131104 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -6547,6 +6547,36 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() { ); } +/// B200: a rename queued behind a long upload of the same node is due, but no +/// worker may claim it until the upload retires. Counted as due, it had every +/// idle worker wake at once, find nothing, and go round again. +#[test] +fn earliest_due_at_skips_an_op_held_behind_a_claimed_one() { + let db = Db::open_in_memory().unwrap(); + let root = uid("root").to_string(); + let mut revision = bulk_op(0, &root); + revision.parent_uid = None; + db.enqueue_op(&revision).unwrap(); + db.enqueue_op(&PendingOp { + kind: OP_RENAME.to_string(), + parent_uid: Some(root.clone()), + blob_path: None, + meta_json: None, + ..bulk_op(0, &root) + }) + .unwrap(); + + db.claim_next_due_op(10) + .unwrap() + .expect("the revision is due"); + assert!(db.claim_next_due_op(10).unwrap().is_none()); + assert_eq!( + db.earliest_due_at().unwrap(), + None, + "a row no worker can claim is not work an idle worker is waiting for" + ); +} + /// Trashing a photo from the gallery must not leave it behind on an album page: /// the row goes, its album membership goes with it, and nothing else moves. #[test] diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 57861ce..7992bd3 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -347,17 +347,7 @@ impl Core { // its writer has to be noticed. self.sweep_parked_creates(); } - // Paused, nothing due changes that: sleep until a resume - // wakes the worker (or the idle poll notices a timed pause - // ran out), not until the next op falls due — that is - // "now" for everything already queued. - if paused { - self.wait_for_drain_work(); - continue; - } - // A debounced or backed-off op may be waiting: sleep only - // until it becomes due rather than the full idle-poll. - self.wait_for_drain_work_or_due(); + self.wait_for_drain_work_or_due(online && !paused); continue; } }; @@ -732,14 +722,16 @@ impl Core { /// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first /// checks whether a debounced or backed-off op is due before the idle-poll /// ceiling. A 2-second debounce must not sleep 30 seconds. - fn wait_for_drain_work_or_due(&self) { - let timeout = match self.db.earliest_due_at() { - Ok(Some(ts)) => { - let remaining = (ts - now_millis()).max(0) as u64; - Duration::from_millis(remaining).min(DRAIN_IDLE_POLL) - } - _ => DRAIN_IDLE_POLL, + /// + /// `claiming` is false while the worker claims nothing (offline or + /// paused); see [`idle_wait`]. + fn wait_for_drain_work_or_due(&self, claiming: bool) { + let earliest = if claiming { + self.db.earliest_due_at().ok().flatten() + } else { + None }; + let timeout = idle_wait(claiming, earliest, now_millis()); sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout); } @@ -2833,6 +2825,23 @@ fn sleep_for_drain_work( *woken = false; } +/// How long an idle drain worker sleeps: until the earliest queued op falls +/// due, at most the idle poll. +/// +/// A worker that claims nothing — offline or paused — sleeps the full idle +/// poll whatever is due. Every op already queued is due "now" for it, so +/// sleeping until then was a zero wait, and all the workers spun on the queue +/// for as long as the outage lasted (`docs/BUGS.md` B200). A reconnect or a +/// resume wakes the drain, so nothing waits the full poll for it. +fn idle_wait(claiming: bool, earliest_due: Option, now: i64) -> Duration { + match earliest_due { + Some(ts) if claiming => { + Duration::from_millis((ts - now).max(0) as u64).min(DRAIN_IDLE_POLL) + } + _ => DRAIN_IDLE_POLL, + } +} + fn placed_in(st: &crate::state::State, uid: &NodeUid) -> Option<(String, Option)> { let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?; Some((entry.node.name.clone(), entry.node.parent_uid.clone())) @@ -2858,6 +2867,23 @@ mod tests { assert!(started.elapsed() < Duration::from_secs(1)); } + #[test] + fn an_offline_worker_waits_the_idle_poll_for_a_past_due_op() { + // The op fell due long ago, and offline nobody attempts it, so it + // stays due: waiting for it was a zero wait, forever (B200). + assert_eq!(idle_wait(false, Some(0), 10_000), DRAIN_IDLE_POLL); + } + + #[test] + fn a_claiming_worker_waits_only_until_the_next_op_falls_due() { + assert_eq!( + idle_wait(true, Some(12_000), 10_000), + Duration::from_secs(2) + ); + assert_eq!(idle_wait(true, Some(0), 10_000), Duration::ZERO); + assert_eq!(idle_wait(true, None, 10_000), DRAIN_IDLE_POLL); + } + fn pending(kind: &str) -> PendingOp { PendingOp { id: 0, diff --git a/docs/BUGS.md b/docs/BUGS.md index 93cca81..4451403 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -101,6 +101,7 @@ a live account since. | Entry | Title | |---|---| +| [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy | | [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive | | [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored | | [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing | @@ -302,6 +303,33 @@ a live account since. Newest first. +### B200 — Offline with changes queued, the daemon keeps several cores busy + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, issue #35: "offline, 3 queued", and systemd counted 17 minutes of CPU in +2 m 16 s of wall time, 10 hours over a longer run. +**Where:** `crates/pdfs-fuse/src/drain.rs`, `Core::run_pending_drain` and `idle_wait`; +`crates/pdfs-core/src/db/ops.rs`, `Db::earliest_due_at` + +**Repro:** Queue a write, then cut the link (`nmcli networking off`). `pidstat -t -p $(pgrep -x +pdfs) 5` shows the drain threads using most of several cores for as long as the outage lasts. + +**Cause.** Offline, a drain worker claims nothing, and then sleeps until the earliest queued op +falls due. Every queued op was due already, and with no attempts nothing moves its +`next_attempt_at`, so the sleep was zero. All 16 workers ran a query on every pass, the primary +one its idle chores as well. The same spin happened online when a due op sat behind a claimed op +on the same node, such as a rename queued behind a long upload: `earliest_due_at` counted it, +`claim_next_due_op` skipped it. + +**Fix.** A worker that claims nothing, offline or paused, sleeps the full idle poll; a reconnect +or a resume wakes it. `earliest_due_at` now uses the claim's own exclusions, one SQL fragment for +both. + +**Test:** `drain::tests::an_offline_worker_waits_the_idle_poll_for_a_past_due_op`, +`db::tests::earliest_due_at_skips_an_op_held_behind_a_claimed_one`. + +--- + ### B199 — A stopped share stays on the Shared by me page for minutes **Status:** Partly fixed. The share itself is still not deleted. From 6363125fec5da377ad50c9768bc47fc74dc4933f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 11:08:40 +0200 Subject: [PATCH 2/8] fix(core,cli): write rotated tokens only into their own session's blob Proton refresh tokens are single-use. store_tokens replaced the tokens of whatever blob was stored, so after `pdfs login` restarted the daemon, the old daemon's shutdown persist wrote its spent tokens into the new session's blob, and the new daemon failed with InvalidRefreshToken (issue #35). `pdfs unlock` and the legacy migration had the same shape. Each session now writes through a TokenSink that only touches a blob with its session id that still holds the refresh token it last left there. Shutdown only flushes a rotation the keyring refused. unlock and migrate_legacy re-read the blob after verifying. docs/BUGS.md B201. --- crates/pdfs-cli/src/main.rs | 11 +- crates/pdfs-core/src/auth.rs | 249 ++++++++++++++++++++++++++++++----- docs/BUGS.md | 33 +++++ 3 files changed, 260 insertions(+), 33 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 7f8d9a9..42873b4 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2261,9 +2261,14 @@ fn mount_once(mountpoint: Option) -> Result { }, ); - // Clean unmount check (as a best-effort final backup). - if let Err(e) = rt.block_on(auth::persist(&session)) { - tracing::debug!(error = %e, "no new tokens to persist on shutdown"); + // Last chance for a rotation the keyring refused when it happened. Only + // that: by now the keyring may hold a newer login, the one this stop was + // restarted for, and the tokens of this session would overwrite it. + if let Err(e) = auth::persist(&session) { + tracing::error!( + error = %e, + "could not save the refreshed session tokens; the next start may need `pdfs login`" + ); } // Dropping the runtime waits for every blocking task, however long; a call // still stuck on Drive would hold the stop until systemd kills it (B165). diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index a03b66e..160da38 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -21,6 +21,7 @@ //! such separation to protect and are stored, as they always were. use std::collections::BTreeMap; +use std::sync::Arc; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -31,6 +32,7 @@ use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; use proton_sdk::cache::EncryptedCacheRepository; use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::error::ProtonError; +use proton_sdk::http::Tokens; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use proton_sdk::telemetry::TracingTelemetry; use serde::{Deserialize, Serialize}; @@ -307,8 +309,8 @@ pub async fn login_verified( } }; - register_refresh_handler(&session); let tokens = session.current_tokens().await; + register_refresh_handler(&session, tokens.refresh_token.clone()); let stored = StoredSession { session_id: session.session_id().as_str().to_owned(), username: session.username().to_owned(), @@ -492,10 +494,12 @@ pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> { } let mailbox = Zeroizing::new(mailbox.to_owned()); let session = ProtonApiSession::resume(client_config(), stored.to_params())?; - register_refresh_handler(&session); + register_refresh_handler(&session, stored.refresh_token.clone()); let passphrases = derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?; - keep_passphrases(stored, &passphrases, remember) + // Read again: verifying may have rotated the tokens, and the copy read + // above would put the spent refresh token back. + keep_passphrases(load()?, &passphrases, remember) } /// Drop the passphrases held in kernel memory, so the account is locked again @@ -552,7 +556,7 @@ pub fn logout() -> Result<()> { pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { let stored = load()?; let session = ProtonApiSession::resume(client_config(), stored.to_params())?; - register_refresh_handler(&session); + register_refresh_handler(&session, stored.refresh_token.clone()); let passphrases = match find_passphrases(&stored)? { Some((passphrases, _)) => passphrases, @@ -597,11 +601,14 @@ async fn migrate_legacy( stored.key_salts.clone() }; - let mut migrated = stored.clone(); + let verified = derive_verified(session, legacy.as_bytes(), key_salts.clone()).await; + // Read again: the calls above may have rotated the tokens, and `stored` + // would put the spent refresh token back. + let mut migrated = load()?; migrated.mailbox_password = String::new(); - migrated.key_salts = key_salts.clone(); + migrated.key_salts = key_salts; - match derive_verified(session, legacy.as_bytes(), key_salts).await { + match verified { Ok(passphrases) => { migrated.key_passphrases = Some(encode(&passphrases)); write_stored(&migrated)?; @@ -659,37 +666,147 @@ fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveC } } -/// Write the session's current tokens back to the keyring. +/// Write any rotation of the session's tokens that has not reached the keyring +/// yet. A best-effort last chance before the process exits. /// -/// Everything else in the stored blob — salts, passphrases — is preserved, so -/// this never undoes an [`unlock`] or [`lock`] made since the session started. -/// Call whenever the session may have rotated its tokens so a later -/// [`resume_client`] presents a live refresh token. -pub async fn persist(session: &ProtonApiSession) -> Result<()> { - let tokens = session.current_tokens().await; - store_tokens(tokens.access_token, tokens.refresh_token) +/// Rotations are written as they happen (see [`register_refresh_handler`]), so +/// this usually has nothing to do. It never writes the session's current +/// tokens for their own sake: the blob may by now belong to a newer login, or +/// hold a newer rotation, and writing them would put a spent refresh token +/// over a live one. +pub fn persist(session: &ProtonApiSession) -> Result<()> { + let sink = TOKEN_SINKS + .lock() + .get(session.session_id().as_str()) + .cloned(); + match sink { + Some(sink) => sink.flush(), + None => Ok(()), + } } -fn store_tokens(access_token: String, refresh_token: String) -> Result<()> { - let mut stored = load()?; - stored.access_token = access_token; - stored.refresh_token = refresh_token; - write_stored(&stored) +/// Where each live session in this process writes its rotated tokens, by +/// session id, for [`persist`] to find. +static TOKEN_SINKS: parking_lot::Mutex>> = + parking_lot::Mutex::new(BTreeMap::new()); + +/// One session's rotated tokens on their way to the keyring. +/// +/// Proton refresh tokens are single-use, so a rotation has to reach the +/// keyring, and it must reach only the blob it belongs to. That is the blob of +/// this session id that still holds the refresh token this session last knew +/// it to hold. Anything else means the blob moved on without this session: a +/// new login, or another session on the same login (`pdfs unlock`) that +/// rotated first. Writing into it would replace a live refresh token with a +/// spent one, and the next start fails with `InvalidRefreshToken` (issue #35). +struct TokenSink { + session_id: String, + state: parking_lot::Mutex, +} + +struct SinkState { + /// The refresh token the blob holds for this session, as far as this + /// session knows: the one it started from, or the last one it wrote. + stored_refresh: String, + /// The latest rotation, until the keyring has taken it. + unsaved: Option, +} + +/// What became of a rotation offered to a stored blob. +#[derive(Debug, PartialEq, Eq)] +enum Rotation { + /// The blob is this session's and took the tokens. + Taken, + /// The blob moved on without this session; it is left alone. + MovedOn, +} + +/// Put `tokens` into `stored` if it is still this session's blob: the same +/// session id, holding the refresh token this session last left in it. +fn take_rotation( + stored: &mut StoredSession, + session_id: &str, + stored_refresh: &str, + tokens: &Tokens, +) -> Rotation { + if stored.session_id != session_id || stored.refresh_token != stored_refresh { + return Rotation::MovedOn; + } + stored.access_token = tokens.access_token.clone(); + stored.refresh_token = tokens.refresh_token.clone(); + Rotation::Taken +} + +impl TokenSink { + fn new(session_id: &str, stored_refresh: String) -> Self { + Self { + session_id: session_id.to_owned(), + state: parking_lot::Mutex::new(SinkState { + stored_refresh, + unsaved: None, + }), + } + } + + /// Take a new rotation and try to write it. + fn rotated(&self, tokens: Tokens) -> Result<()> { + self.state.lock().unsaved = Some(tokens); + self.flush() + } + + /// Write the unsaved rotation, if there is one. A blob that moved on, or + /// is gone with a logout, drops it: there is nothing it may still go into. + fn flush(&self) -> Result<()> { + let mut state = self.state.lock(); + let Some(tokens) = state.unsaved.clone() else { + return Ok(()); + }; + let mut stored = match load() { + Ok(stored) => stored, + Err(Error::NotLoggedIn) => { + state.unsaved = None; + return Ok(()); + } + Err(e) => return Err(e), + }; + match take_rotation( + &mut stored, + &self.session_id, + &state.stored_refresh, + &tokens, + ) { + Rotation::Taken => { + write_stored(&stored)?; + state.stored_refresh = tokens.refresh_token; + tracing::info!("successfully auto-persisted refreshed tokens in keyring"); + } + Rotation::MovedOn => { + tracing::info!( + "the stored session has moved on; not writing this session's tokens over it" + ); + } + } + state.unsaved = None; + Ok(()) + } } /// Persist rotated tokens the moment the session obtains them. /// -/// The handler re-reads the stored blob instead of capturing one, so it cannot -/// write back a stale copy over a later unlock or lock — and cannot resurrect a -/// session that was logged out while the daemon still ran. -fn register_refresh_handler(session: &ProtonApiSession) { +/// `stored_refresh` is the refresh token the keyring holds for this session +/// now. The handler re-reads the stored blob instead of capturing one, so it +/// cannot write back a stale copy over a later unlock or lock, and it writes +/// only into a blob that is still this session's (see [`TokenSink`]), so it +/// cannot resurrect a logged-out session or overwrite a newer login. +fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) { + let session_id = session.session_id().as_str(); + let sink = Arc::new(TokenSink::new(session_id, stored_refresh)); + TOKEN_SINKS + .lock() + .insert(session_id.to_owned(), Arc::clone(&sink)); session.http().set_on_tokens_refreshed(move |tokens| { - match store_tokens(tokens.access_token, tokens.refresh_token) { - Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"), - Err(Error::NotLoggedIn) => {} - Err(e) => { - tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring") - } + if let Err(e) = sink.rotated(tokens) { + tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring") } }); } @@ -786,4 +903,76 @@ mod tests { assert_eq!(origin, KeyState::Stored); assert_eq!(found, passphrases()); } + + fn tokens(access: &str, refresh: &str) -> Tokens { + Tokens { + access_token: access.into(), + refresh_token: refresh.into(), + } + } + + #[test] + fn own_session_tokens_are_written() { + let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + assert_eq!( + take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")), + Rotation::Taken + ); + assert_eq!(stored.access_token, "a2"); + assert_eq!(stored.refresh_token, "r2"); + } + + /// Issue #35: `pdfs login` stores a new session and restarts the daemon, + /// and the old daemon's stop wrote its spent tokens into the new blob. + #[test] + fn tokens_of_another_session_are_not_written() { + let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + assert_eq!( + take_rotation(&mut stored, "old", "r", &tokens("a2", "r2")), + Rotation::MovedOn + ); + assert_eq!(stored.refresh_token, "r"); + } + + /// `pdfs unlock` resumes a second session on the same login. Once it has + /// rotated, the daemon's copy of the refresh token is spent. + #[test] + fn tokens_are_not_written_over_a_newer_rotation_of_the_same_session() { + let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + assert_eq!( + take_rotation(&mut stored, "s", "spent", &tokens("a2", "r2")), + Rotation::MovedOn + ); + assert_eq!(stored.refresh_token, "r"); + } + + /// The only test that touches the keyring, through `keyring-core`'s + /// in-memory store. + #[test] + fn a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login() { + keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap()); + let stored = |refresh: &str| { + serde_json::from_str::( + &blob("").replace(r#""r""#, &format!("{refresh:?}")), + ) + .unwrap() + }; + write_stored(&stored("r")).unwrap(); + + let sink = TokenSink::new("s", "r".into()); + sink.rotated(tokens("a2", "r2")).unwrap(); + sink.rotated(tokens("a3", "r3")).unwrap(); + assert_eq!(load().unwrap().refresh_token, "r3"); + + // A new login replaces the blob; the old session's next rotation, and + // its stop, leave it alone. + let mut login = stored("fresh"); + login.session_id = "new".into(); + write_stored(&login).unwrap(); + sink.rotated(tokens("a4", "r4")).unwrap(); + sink.flush().unwrap(); + let now = load().unwrap(); + assert_eq!(now.session_id, "new"); + assert_eq!(now.refresh_token, "fresh"); + } } diff --git a/docs/BUGS.md b/docs/BUGS.md index 4451403..f34ef2a 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -101,6 +101,7 @@ a live account since. | Entry | Title | |---|---| +| [B201](#b201--after-signing-in-again-the-old-daemon-puts-its-spent-tokens-back) | After signing in again, the old daemon puts its spent tokens back | | [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy | | [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive | | [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored | @@ -303,6 +304,38 @@ a live account since. Newest first. +### B201 — After signing in again, the old daemon puts its spent tokens back + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, reading the code for issue #35, where every call failed with +`InvalidRefreshToken` and signing in again did not obviously help. +**Where:** `crates/pdfs-core/src/auth.rs`, `persist`, `register_refresh_handler`, `unlock` and +`migrate_legacy`; `crates/pdfs-cli/src/main.rs`, `cmd_daemon` + +**Repro:** With the daemon running, run `pdfs login`. It stores the new session and restarts the +unit. The old daemon's stop runs `auth::persist`, which wrote its own access and refresh tokens +into the blob, now the new session's. The new daemon starts with a refresh token of another +session, and the first refresh fails with `InvalidRefreshToken`. `pdfs unlock` had the same +shape: its session could rotate the tokens, and the restarted daemon's stop put the spent refresh +token back. `unlock` and `migrate_legacy` could do it themselves, writing a copy of the blob read +before verifying the mailbox password, which may have rotated the tokens. + +**Cause.** Proton refresh tokens are single-use. `store_tokens` loaded whatever blob was stored +and replaced its tokens, without checking that the blob was still this session's. + +**Fix.** Each session writes its rotations through a `TokenSink`. It writes only into a blob with +its session id that still holds the refresh token the session last left there; a blob that moved +on, to a new login or to another session's rotation, is left alone. The stop no longer writes the +session's tokens, only a rotation the keyring refused when it happened. `unlock` and +`migrate_legacy` read the blob again after verifying. A failed final write is an error in the log, +not a debug line claiming there was nothing to save. + +**Test:** `auth::tests::tokens_of_another_session_are_not_written`, +`auth::tests::tokens_are_not_written_over_a_newer_rotation_of_the_same_session`, +`auth::tests::a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login`. + +--- + ### B200 — Offline with changes queued, the daemon keeps several cores busy **Status:** Fixed, not verified live From bf43476e88fc3654dd226fe08d728b99b33e7f56 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 11:27:09 +0200 Subject: [PATCH 3/8] fix(fuse,core,cli,gui): show a refused session as expired, not offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refresh token Proton refused, or a deleted or disabled account, was treated as a lost connection: the mount came up "offline", the probe retried forever and logged only at debug, and nothing told the user that only signing in again helps (issue #35). session_revoked() names those errors. The root fetch, probe, drain, event poll, event seed and photos root now set session_expired, log one warning naming `pdfs login`, and stop retrying. Cached files stay readable and changes stay queued. Response::Status carries session_expired (serde default false). The tray shows "Session expired — sign in again" with a Sign In Again… item that opens the app's sign-in page; the app's status and Sync card say the same with a button. `pdfs status` and `pdfs diagnose` report it. New strings are translated in every catalog. docs/BUGS.md B202. --- crates/pdfs-cli/src/main.rs | 39 +- crates/pdfs-core/src/control.rs | 5 + crates/pdfs-core/src/error.rs | 28 + crates/pdfs-fuse/src/background.rs | 11 + crates/pdfs-fuse/src/control.rs | 1 + crates/pdfs-fuse/src/drain.rs | 4 +- crates/pdfs-fuse/src/events.rs | 4 +- crates/pdfs-fuse/src/link.rs | 52 +- crates/pdfs-fuse/src/mount.rs | 36 +- crates/pdfs-gui/src/app/main.rs | 7 + crates/pdfs-gui/src/app/pages/locations.rs | 33 +- crates/pdfs-gui/src/app/pages/status.rs | 32 +- crates/pdfs-gui/src/main.rs | 56 +- crates/pdfs-gui/tests/icon_names.rs | 1 + docs/BUGS.md | 37 ++ po/de.po | 553 ++++++++++---------- po/en_GB.po | 553 ++++++++++---------- po/es.po | 553 ++++++++++---------- po/fr.po | 553 ++++++++++---------- po/it.po | 553 ++++++++++---------- po/nl.po | 553 ++++++++++---------- po/pdfs.pot | 561 +++++++++++---------- po/pl.po | 553 ++++++++++---------- po/pt_BR.po | 553 ++++++++++---------- 24 files changed, 2915 insertions(+), 2416 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 42873b4..253ad7c 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2024,9 +2024,11 @@ fn cmd_status() -> Result<()> { staged_oldest_secs, paused, paused_until, + session_expired, .. }) => { let state = match (online, paused) { + _ if session_expired => ", session expired: run `pdfs login`", (_, true) => ", sync paused", (false, false) => ", offline", (true, false) => "", @@ -3429,6 +3431,7 @@ fn cmd_diagnose() -> Result<()> { Ok(CtlResponse::Status { mountpoint, online, + session_expired, pending_uploads, pending_changes, .. @@ -3450,19 +3453,29 @@ fn cmd_diagnose() -> Result<()> { |kind| format!("{mountpoint} ({kind})"), ), ); - report.finding( - if online { - DiagnoseLevel::Ok - } else { - DiagnoseLevel::Warn - }, - " network", - if online { - "online" - } else { - "offline; cached data remains available" - }, - ); + // A refused session reads as offline too, but no connection will + // bring it back, and the keyring check above cannot tell. + if session_expired { + report.finding( + DiagnoseLevel::Fail, + " session", + "Proton refused it (expired or revoked) — run `pdfs login`", + ); + } else { + report.finding( + if online { + DiagnoseLevel::Ok + } else { + DiagnoseLevel::Warn + }, + " network", + if online { + "online" + } else { + "offline; cached data remains available" + }, + ); + } let pending = pending_uploads + pending_changes; report.finding( if pending == 0 { diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index ae0e043..0620556 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -1959,6 +1959,11 @@ pub enum Response { /// resumed, or not paused. #[serde(default)] paused_until: Option, + /// True when Proton refused the session itself (refresh token refused, + /// account deleted or disabled). `online` is false then too, but no + /// connection brings it back: only signing in again does. + #[serde(default)] + session_expired: bool, }, /// A human-readable success message. Ok { message: String }, diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index 1d18adb..005c446 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -278,6 +278,23 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind { } } +/// Whether `e` says the session itself is gone: its refresh token was refused, +/// or the account was deleted or disabled. +/// +/// Nothing but a new login helps then, and no retry will ever succeed, so a +/// caller must not take it for a lost connection and wait for it to come back +/// (issue #35: a revoked session was shown as "offline" for good). +pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool { + use proton_sdk::api::ResponseCode as C; + match e { + proton_sdk::ProtonError::Api(api) => { + api.is_invalid_refresh_token() + || matches!(api.code, C::AccountDeleted | C::AccountDisabled) + } + _ => false, + } +} + /// The result every request-serving method in the daemon returns. pub type CoreResult = std::result::Result; @@ -309,6 +326,17 @@ mod tests { assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote); } + #[test] + fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() { + assert!(session_revoked(&api(C::InvalidRefreshToken))); + assert!(session_revoked(&api(C::AccountDeleted))); + assert!(session_revoked(&api(C::AccountDisabled))); + // One file the account may not touch says nothing about the session. + assert!(!session_revoked(&api(C::Forbidden))); + assert!(!session_revoked(&api(C::Timeout))); + assert!(!session_revoked(&ProtonError::KeysLocked)); + } + /// The regression this whole pass exists to prevent: before it, every SDK /// failure was hardcoded to `Remote`, so a 403 asked the user to retry a /// request that could never succeed. diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index 7f14d5d..64afdd2 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -2,6 +2,7 @@ use super::*; use crate::shutdown::Shutdown; +use pdfs_core::error::session_revoked; /// Drop `uid` from one mount's tree and tell that mount's kernel session the /// entry is gone. Shared by the trash and delete paths, which differ only in @@ -402,6 +403,12 @@ pub(super) async fn run_event_sync( } break Some(c.clone()); } + // Refused for good: retrying would be refused the same way + // for the life of the daemon. The next login restarts it. + Err(e) if session_revoked(&e) => { + core.mark_session_expired("seed event cursor", &e); + return; + } Err(e) => { warn!(error = %e, ?delay, "seed event cursor failed; retrying"); tokio::time::sleep(delay).await; @@ -502,6 +509,10 @@ pub(super) async fn run_photos_event_sync(core: Core) { debug!("no photos volume; photo event sync not started"); return; } + Err(error) if session_revoked(&error) => { + core.mark_session_expired("read the photos root", &error); + return; + } Err(error) => { warn!(%error, "reading the photos root failed; retrying"); tokio::time::sleep(ONLINE_PROBE_MAX).await; diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index f308493..efe5ae2 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -334,6 +334,7 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> budget: core.cache.budget(), pins, online: core.online.load(Ordering::Relaxed), + session_expired: core.session_expired(), pending_uploads: queued.uploads.max(0) as u64 + landing, pending_changes: queued.changes.max(0) as u64, } diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 7992bd3..5cc7b35 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -2670,7 +2670,9 @@ fn proton_error<'e>(e: &'e (dyn std::error::Error + 'static)) -> Option<&'e Prot /// is, or `Some(None)` for a failure that is not a refusal. fn refusal(e: &(dyn std::error::Error + 'static)) -> Option> { let proton = proton_error(e)?; - if is_network_error(proton) { + // A refused session refuses everything, so it says nothing about this + // op either; the mount is offline for it (`Core::lost_link`). + if is_network_error(proton) || pdfs_core::error::session_revoked(proton) { return None; } let ProtonError::Api(api) = proton else { diff --git a/crates/pdfs-fuse/src/events.rs b/crates/pdfs-fuse/src/events.rs index 1e6db68..72cafa8 100644 --- a/crates/pdfs-fuse/src/events.rs +++ b/crates/pdfs-fuse/src/events.rs @@ -235,6 +235,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) { used, budget, online, + session_expired, pending_uploads, pending_changes, .. @@ -247,7 +248,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) { ); let key = format!( "{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\ - {used}/{budget}/{online}" + {used}/{budget}/{online}/{session_expired}" ); (key, queue) } @@ -325,6 +326,7 @@ mod tests { budget: 0, pins: Vec::new(), online: true, + session_expired: false, pending_uploads: 1, pending_changes: 0, }; diff --git a/crates/pdfs-fuse/src/link.rs b/crates/pdfs-fuse/src/link.rs index 124fc27..b1b715a 100644 --- a/crates/pdfs-fuse/src/link.rs +++ b/crates/pdfs-fuse/src/link.rs @@ -9,10 +9,11 @@ //! queued path, and the probe thread here flips it back when Proton answers. use std::future::Future; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use parking_lot::{Condvar, Mutex}; +use pdfs_core::error::session_revoked; use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError}; use tracing::{debug, info, warn}; @@ -54,6 +55,22 @@ pub(crate) struct Link { changed: Condvar, /// Folders listed from the DB alone while offline, to relist once back. pub(crate) stale_listings: Mutex>, + /// Proton refused the session itself ([`session_revoked`]). The mount is + /// offline then, but no probe brings it back: only a new login does, and + /// that restarts the daemon. Read by front-ends through `Response::Status`, + /// so they can ask the user to sign in again rather than say "offline". + pub(crate) session_expired: AtomicBool, +} + +impl Link { + /// The link of a mount that comes up with Proton refusing its session, or + /// not. + pub(crate) fn starting(session_expired: bool) -> Self { + Self { + session_expired: AtomicBool::new(session_expired), + ..Default::default() + } + } } /// Whether a failed call failed because Proton could not be reached, rather @@ -161,10 +178,20 @@ impl Core { self.rt.block_on(bounded_by(limit, call)) } + /// Whether Proton refused the session itself, so nothing reaches it until + /// the user signs in again. + pub(crate) fn session_expired(&self) -> bool { + self.link.session_expired.load(Ordering::Relaxed) + } + /// Report a failed remote call. Returns whether it failed for want of a - /// network, in which case the mount is now offline and the caller should - /// take its queued path instead of failing. + /// network, or of a session, in which case the mount is now offline and the + /// caller should take its queued path instead of failing. pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool { + if session_revoked(e) { + self.mark_session_expired(what, e); + return true; + } if !is_network_error(e) { return false; } @@ -172,6 +199,17 @@ impl Core { true } + /// Flip the mount offline for good: Proton refused the session, and only a + /// new login brings it back. Changes stay queued for the daemon that login + /// starts. Only the first caller logs it. + pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) { + if !self.link.session_expired.swap(true, Ordering::Relaxed) { + warn!(during = what, %error, + "Proton refused the session; run `pdfs login` to sign in again"); + } + self.mark_offline(what, error); + } + /// Flip the mount offline and wake the probe. Only the first caller of an /// outage logs it; the rest are the same outage. pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) { @@ -276,6 +314,9 @@ impl Core { /// Probe until Proton answers, then flip the mount online. Returns `false` /// if the daemon is stopping. + /// + /// A refused session is not probed: every probe would be refused the same + /// way, forever (issue #35). The thread waits for the stop instead. fn probe_until_online(&self) -> bool { let started = self.link.lost_at.lock().unwrap_or_else(Instant::now); let mut delay = if started.elapsed() < YOUNG_OUTAGE { @@ -287,6 +328,10 @@ impl Core { if !self.shutdown.sleep(delay) { return false; } + if self.session_expired() { + delay = ONLINE_PROBE_MAX; + continue; + } let probe = self.rt.block_on(async { match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await { Ok(result) => result, @@ -308,6 +353,7 @@ impl Core { self.mark_online(); return true; } + Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e), Err(e) => { debug!(error = %e, ?delay, "online probe failed; still offline"); delay = next_probe_delay(delay, started.elapsed()); diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index a5f163a..a673cf0 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -210,30 +210,55 @@ fn fetch_or_recall_root( drive: &dyn DriveApi, rt: &tokio::runtime::Handle, db: &Db, -) -> std::io::Result<(Node, bool)> { +) -> std::io::Result<(Node, Start)> { let err = match rt.block_on(drive.get_my_files_folder()) { Ok(root) => { if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) { warn!(error = %e, "persist root uid failed"); } - return Ok((root, true)); + return Ok((root, Start::Online)); } Err(e) => e, }; + // Still mounted from the cache, so cached files stay readable and changes + // queue for the daemon the next login starts. + let start = if pdfs_core::error::session_revoked(&err) { + Start::SessionExpired + } else { + Start::Offline + }; let cached = db .state_str(ROOT_UID_KEY) .ok() .flatten() .and_then(|uid| db.node_by_uid(&uid).ok().flatten()); match cached { + Some(root) if start == Start::SessionExpired => { + warn!(error = %err, + "Proton refused the session; mounting from cache until `pdfs login`"); + Ok((root, start)) + } Some(root) => { warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)"); - Ok((root, false)) + Ok((root, start)) } + None if start == Start::SessionExpired => Err(std::io::Error::other(format!( + "Proton refused the session ({err}); run `pdfs login`" + ))), None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))), } } +/// How a mount comes up, from what fetching its root said. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Start { + Online, + /// Proton could not be reached: serve the cache and probe for the link. + Offline, + /// Proton refused the session: serve the cache until a new login. + SessionExpired, +} + /// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the /// caller, so `mount` itself never reads config or the environment. pub struct MountOptions { @@ -393,7 +418,8 @@ pub(crate) fn mount_with( // Start the uptime clock here rather than at the first `pdfs diagnostics`, // so the age it reports is the daemon's own. super::diagnostics::start_clock(); - let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?; + let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?; + let online = start == Start::Online; let scope = root.tree_event_scope_id(); db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None) .map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?; @@ -429,7 +455,7 @@ pub(crate) fn mount_with( shared_generation: Arc::new(AtomicU64::new(0)), shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())), online: Arc::new(AtomicBool::new(online)), - link: Arc::new(Default::default()), + link: Arc::new(link::Link::starting(start == Start::SessionExpired)), sync_paused_until: Arc::new(AtomicI64::new(paused_until)), pending: Arc::new(Mutex::new(HashMap::new())), hidden: Arc::new(Mutex::new(HashSet::new())), diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 7f8f67e..f80ef8c 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -579,6 +579,7 @@ fn build_window(app: &adw::Application) { detail: locations_widgets.card_detail.clone(), pause: locations_widgets.pause.clone(), paused: Cell::new(false), + sign_in: locations_widgets.card_sign_in.clone(), }, queue: QueueState { group: locations_widgets.queue_group.clone(), @@ -1290,6 +1291,12 @@ fn install_launch_actions(ui: &Rc, app: &adw::Application, window: &adw::App if ui_page.session.borrow().is_none() { return; } + // Signed in, but Proton refused the session (the tray's "Sign In + // Again…"): the login page, with the rest of the app still reachable. + if page == "login" { + show_login(&ui_page); + return; + } // Activity lives on as the Sync page's History view. if page == "activity" { ui_page.locations.views.set_visible_child_name("history"); diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index 7f2b3f9..68e9967 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -46,6 +46,8 @@ pub(crate) struct SyncCard { pub(crate) pause: adw::SplitButton, /// Whether the last status said paused, so the button knows which way to go. pub(crate) paused: Cell, + /// Shown only while Proton refuses the session; opens the sign-in page. + pub(crate) sign_in: gtk4::Button, } /// What a folder row's controls are built from. The subtitle and progress bar @@ -108,6 +110,7 @@ pub(crate) struct LocationsWidgets { pub(crate) card_icon: gtk4::Image, pub(crate) card_title: gtk4::Label, pub(crate) card_detail: gtk4::Label, + pub(crate) card_sign_in: gtk4::Button, pub(crate) pause: adw::SplitButton, pub(crate) queue_group: adw::PreferencesGroup, pub(crate) retry_all: gtk4::Button, @@ -134,7 +137,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc .build(); add_folder.add_css_class("flat"); let transfers_group = build_transfers_group(); - let (card, card_icon, card_title, card_detail) = build_sync_card(); + let (card, card_icon, card_title, card_detail, card_sign_in) = build_sync_card(); let pause = build_pause_button(); let (queue_group, retry_all) = build_queue_group(); let conflicts_group = adw::PreferencesGroup::builder() @@ -253,6 +256,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc card_icon, card_title, card_detail, + card_sign_in, pause, queue_group, retry_all, @@ -290,6 +294,11 @@ pub(crate) fn wire_locations(ui: &Rc, retry: >k4::Button, add_folder: >k let ui_add = ui.clone(); add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); wire_pause(ui); + let ui_sign_in = ui.clone(); + ui.locations + .card + .sign_in + .connect_clicked(move |_| show_login(&ui_sign_in)); let ui_retry = ui.clone(); ui.locations .queue @@ -379,9 +388,15 @@ pub(crate) fn paint_sync_banner(ui: &Rc) { state.banner.set_revealed(away && title.is_some()); } -/// The status hero: a large state icon, the state in words, and what it -/// means. -fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) { +/// The status hero: a large state icon, the state in words, what it means, +/// and the way out when there is one only the user can take. +fn build_sync_card() -> ( + gtk4::Box, + gtk4::Image, + gtk4::Label, + gtk4::Label, + gtk4::Button, +) { let icon = gtk4::Image::builder() .icon_name("pdfs-sync-symbolic") .pixel_size(48) @@ -408,9 +423,17 @@ fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) { let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18); card.add_css_class("card"); card.add_css_class("sync-card"); + let sign_in = gtk4::Button::builder() + .label(gettext("Sign In Again")) + .valign(gtk4::Align::Center) + .visible(false) + .build(); + sign_in.add_css_class("pill"); + sign_in.add_css_class("suggested-action"); card.append(&icon); card.append(&text); - (card, icon, title, detail) + card.append(&sign_in); + (card, icon, title, detail, sign_in) } /// Pause/Resume, with timed pauses in its menu. diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index fab7142..3f93c45 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -836,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc) { .connect_clicked(move |_| restart_service_then(&ui_trash, load_trash)); } +/// Show the sign-in page while a session is still stored, because Proton has +/// refused it. The sidebar stays, so the user can go back to cached files; a +/// login moves on to the files as from any sign-in ([`refresh`]). +pub(crate) fn show_login(ui: &Rc) { + ui.stack.set_visible_child_name("login"); +} + /// Repaint the window from the cached login identity, then ask the daemon for /// its status and transfers. Runs at startup, after a login and after a service /// restart; the daemon's event feed keeps both current in between. The identity @@ -1221,6 +1228,7 @@ pub(crate) fn paint_status(ui: &Rc, status: Response) { failing_error, paused, paused_until, + session_expired, .. } = status else { @@ -1231,7 +1239,9 @@ pub(crate) fn paint_status(ui: &Rc, status: Response) { // in it: it is why a file that looks saved is not on the remote // yet, and offline is usually the reason it is still queued. let queued = i18n::pending_summary(pending_uploads, pending_changes); - let state = if paused { + let state = if session_expired { + SyncState::SessionExpired { queued } + } else if paused { SyncState::Paused { until: paused_until, queued, @@ -1294,6 +1304,11 @@ pub(crate) fn paint_disconnected(ui: &Rc) { /// What the sidebar's status strip reports, most urgent first. pub(crate) enum SyncState { + /// Proton refused the session: nothing reaches it until the user signs in + /// again. Outranks a pause, which it makes moot. + SessionExpired { + queued: Option, + }, /// The user paused syncing; nothing goes up until it resumes. Outranks /// everything else, because it is the reason for everything else. Paused { @@ -1328,7 +1343,21 @@ pub(crate) enum SyncState { fn paint_sync_status(ui: &Rc, state: SyncState) { let paused = matches!(state, SyncState::Paused { .. }); let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding); + let expired = matches!(state, SyncState::SessionExpired { .. }); let (icon, class, title, detail) = match state { + SyncState::SessionExpired { queued } => ( + "dialog-password-symbolic", + Some("error"), + gettext("Session expired"), + Some(match queued { + // Translators: {queued} says what is waiting, such as "3 uploads + // queued". + Some(queued) => { + gettext_f("{queued} · sign in again to sync", &[("queued", &queued)]) + } + None => gettext("Sign in again to sync"), + }), + ), SyncState::Paused { until, queued } => ( "media-playback-pause-symbolic", Some("warning"), @@ -1399,6 +1428,7 @@ fn paint_sync_status(ui: &Rc, state: SyncState) { if let Some(class) = class { image.add_css_class(class); } + ui.locations.card.sign_in.set_visible(expired); ui.status.status_title.set_label(&title); ui.status.status_detail.set_visible(detail.is_some()); ui.status diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 7e6cd8b..3e21160 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -45,6 +45,8 @@ enum Phase { Offline, /// Changes keep failing; the user has to look. Attention, + /// Mounted, but Proton refused the session: only signing in again helps. + SessionExpired, /// Signed in, but no mount daemon is running. Disconnected, SignedOut, @@ -60,6 +62,7 @@ impl Phase { Phase::Paused => "media-playback-pause-symbolic", Phase::Offline => "network-offline-symbolic", Phase::Attention => "dialog-warning-symbolic", + Phase::SessionExpired => "dialog-password-symbolic", Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic", } } @@ -234,6 +237,7 @@ fn status_state( pending_changes, failing_ops, paused, + session_expired, .. } = status else { @@ -241,14 +245,21 @@ fn status_state( }; let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused); let queued = i18n::pending_summary(*pending_uploads, *pending_changes); - let phase = phase_of( - paused, - failing_ops, - online, - queued.is_some() || busy(items, jobs), - ); + let phase = if *session_expired { + Phase::SessionExpired + } else { + phase_of( + paused, + failing_ops, + online, + queued.is_some() || busy(items, jobs), + ) + }; DriveState { line: match (online, queued) { + // Outranks everything: it is why nothing else moves, and the one + // thing that fixes it is the user's to do. + _ if *session_expired => gettext("Session expired — sign in again"), _ if paused => gettext("Sync paused"), _ if failing_ops > 0 => ngettext_f( "{n} change needs attention", @@ -365,7 +376,7 @@ impl Tray for DriveTray { fn status(&self) -> Status { match self.state.phase { - Phase::Attention => Status::NeedsAttention, + Phase::Attention | Phase::SessionExpired => Status::NeedsAttention, _ => Status::Active, } } @@ -423,6 +434,9 @@ impl Tray for DriveTray { // Nothing to mount without a session; the app's sign-in page is the // way in. Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))), + Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| { + open_manager(&["--page", "login"]) + })), _ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))), } @@ -606,6 +620,34 @@ mod tests { assert_eq!(phase_of(false, 0, true, false), Phase::Synced); } + fn status(extra: &str) -> Response { + serde_json::from_str(&format!( + r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0, + "pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true{extra}}}}}"# + )) + .unwrap() + } + + /// Issue #35: a refused session read as "Offline — 3 uploads queued" for + /// good, and nothing said that signing in again was the way out. + #[test] + fn a_refused_session_outranks_everything_else() { + let state = status_state( + &status(r#","session_expired":true"#), + &[], + &[], + Path::new("/m"), + ); + assert_eq!(state.phase, Phase::SessionExpired); + assert_eq!(state.line, "Session expired — sign in again"); + } + + #[test] + fn a_daemon_that_does_not_say_has_a_live_session() { + let state = status_state(&status(""), &[], &[], Path::new("/m")); + assert_eq!(state.phase, Phase::Paused); + } + fn job(title: &str, background: bool) -> JobItem { JobItem { title: title.to_string(), diff --git a/crates/pdfs-gui/tests/icon_names.rs b/crates/pdfs-gui/tests/icon_names.rs index eb16703..5e46db8 100644 --- a/crates/pdfs-gui/tests/icon_names.rs +++ b/crates/pdfs-gui/tests/icon_names.rs @@ -28,6 +28,7 @@ const STOCK: &[&str] = &[ "checkbox-symbolic", "computer-symbolic", "dialog-error-symbolic", + "dialog-password-symbolic", "dialog-warning-symbolic", "document-edit-symbolic", "document-open-recent-symbolic", diff --git a/docs/BUGS.md b/docs/BUGS.md index f34ef2a..be5b103 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -101,6 +101,7 @@ a live account since. | Entry | Title | |---|---| +| [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again | | [B201](#b201--after-signing-in-again-the-old-daemon-puts-its-spent-tokens-back) | After signing in again, the old daemon puts its spent tokens back | | [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy | | [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive | @@ -304,6 +305,42 @@ a live account since. Newest first. +### B202 — A refused session shows as offline forever, and nothing says to sign in again + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, issue #35: the tray and `pdfs diagnose` said "offline, 3 queued", and +diagnose also said "keyring session: ok", while every call in the log failed with +`InvalidRefreshToken (http 400)`. `pdfs sync now` and restarting the service changed nothing. +**Where:** `crates/pdfs-fuse/src/link.rs`, `Core::lost_link` and `Core::probe_until_online`; +`crates/pdfs-fuse/src/mount.rs`, `fetch_or_recall_root`; `crates/pdfs-fuse/src/background.rs`; +`crates/pdfs-core/src/control.rs`, `Response::Status`; the tray, the app's status, `pdfs status` +and `pdfs diagnose` + +**Repro:** Make the stored refresh token invalid (B201 is one way), then start the daemon. It +mounts from the cache and says offline; the probe retries every 30 s, then every 5 minutes, and +logs the refusal only at debug. A daemon that started online and loses its session later does not +even go offline: its failures show up as EIO and drain backoff. + +**Cause.** Every failure to fetch the root at startup counted as offline, and nothing told a +refused session apart from a lost connection. No status field could carry it, so no front end +could say it. + +**Fix.** `pdfs_core::error::session_revoked` names the errors only a new login fixes: +`InvalidRefreshToken`, `AccountDeleted`, `AccountDisabled`. When the root fetch, the probe, the +drain, an event poll, the event seed or the photos root sees one, the mount goes offline with +`session_expired` set, logs one warning naming `pdfs login`, and stops probing and retrying. +Cached files stay readable and changes stay queued for the daemon the next login starts. +`Response::Status` carries `session_expired`. The tray shows "Session expired — sign in again" +with **Sign In Again…**, which opens the app's sign-in page; the app's status and Sync card say the +same, with a **Sign In Again** button. `pdfs status` and `pdfs diagnose` report it, the latter as +a FAIL. A refused session is not recorded as a sync issue of the op that ran into it. + +**Test:** `error::tests::only_a_refused_refresh_or_a_closed_account_revokes_the_session`, the +tray's `tests::a_refused_session_outranks_everything_else` and +`tests::a_daemon_that_does_not_say_has_a_live_session`. + +--- + ### B201 — After signing in again, the old daemon puts its spent tokens back **Status:** Fixed, not verified live diff --git a/po/de.po b/po/de.po index b26f638..ce1a53e 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 02:57+0200\n" +"POT-Creation-Date: 2026-10-09 11:21+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,86 +57,86 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:761 +#: crates/pdfs-gui/src/app/main.rs:762 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 #: crates/pdfs-gui/src/app/pages/browser.rs:2083 #: crates/pdfs-gui/src/app/pages/browser.rs:2160 -#: crates/pdfs-gui/src/app/pages/locations.rs:1624 +#: crates/pdfs-gui/src/app/pages/locations.rs:1647 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:764 crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:765 crates/pdfs-gui/src/app/main.rs:1396 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:769 +#: crates/pdfs-gui/src/app/main.rs:770 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:774 +#: crates/pdfs-gui/src/app/main.rs:775 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:778 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:485 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:778 +#: crates/pdfs-gui/src/app/main.rs:779 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:780 +#: crates/pdfs-gui/src/app/main.rs:781 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1096 +#: crates/pdfs-gui/src/app/main.rs:1097 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1115 +#: crates/pdfs-gui/src/app/main.rs:1116 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1116 +#: crates/pdfs-gui/src/app/main.rs:1117 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1117 +#: crates/pdfs-gui/src/app/main.rs:1118 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1118 +#: crates/pdfs-gui/src/app/main.rs:1119 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1119 +#: crates/pdfs-gui/src/app/main.rs:1120 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1206 +#: crates/pdfs-gui/src/app/main.rs:1207 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1214 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -148,23 +148,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1219 +#: crates/pdfs-gui/src/app/main.rs:1220 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1223 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1227 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1232 +#: crates/pdfs-gui/src/app/main.rs:1233 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -189,7 +189,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1245 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -209,7 +209,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +206,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -208,7 +208,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +206,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -207,7 +207,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,86 +60,86 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:761 +#: crates/pdfs-gui/src/app/main.rs:762 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 #: crates/pdfs-gui/src/app/pages/browser.rs:2083 #: crates/pdfs-gui/src/app/pages/browser.rs:2160 -#: crates/pdfs-gui/src/app/pages/locations.rs:1624 +#: crates/pdfs-gui/src/app/pages/locations.rs:1647 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:764 crates/pdfs-gui/src/app/main.rs:1389 +#: crates/pdfs-gui/src/app/main.rs:765 crates/pdfs-gui/src/app/main.rs:1396 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:769 +#: crates/pdfs-gui/src/app/main.rs:770 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:774 +#: crates/pdfs-gui/src/app/main.rs:775 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:778 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:485 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:778 +#: crates/pdfs-gui/src/app/main.rs:779 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:780 +#: crates/pdfs-gui/src/app/main.rs:781 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1096 +#: crates/pdfs-gui/src/app/main.rs:1097 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1115 +#: crates/pdfs-gui/src/app/main.rs:1116 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1116 +#: crates/pdfs-gui/src/app/main.rs:1117 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1117 +#: crates/pdfs-gui/src/app/main.rs:1118 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1118 +#: crates/pdfs-gui/src/app/main.rs:1119 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1119 +#: crates/pdfs-gui/src/app/main.rs:1120 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1206 +#: crates/pdfs-gui/src/app/main.rs:1207 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1214 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -147,23 +147,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1219 +#: crates/pdfs-gui/src/app/main.rs:1220 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1223 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1227 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1232 +#: crates/pdfs-gui/src/app/main.rs:1233 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1236 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -176,21 +176,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1244 +#: crates/pdfs-gui/src/app/main.rs:1245 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -210,7 +210,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1252 +#: crates/pdfs-gui/src/app/main.rs:1253 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -206,7 +206,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1260 +#: crates/pdfs-gui/src/app/main.rs:1261 msgid "" "The street map, when turned on, comes from OpenFreeMap in the Date: Fri, 9 Oct 2026 11:29:14 +0200 Subject: [PATCH 4/8] fix(core): retry rotated tokens the keyring refused A failed keyring write after a refresh was only a warning, and the one live refresh token then lived in memory only (B61). The sink now keeps the rotation as unsaved and retries it in the background after 1 s, 5 s, 30 s and then every 5 minutes, until it is written or the blob has moved on. The refusal is logged as an error naming `pdfs login`. docs/BUGS.md B61. --- crates/pdfs-core/src/auth.rs | 130 ++++++++++++++++++++++++++++++----- docs/BUGS.md | 14 +++- 2 files changed, 126 insertions(+), 18 deletions(-) diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 160da38..f9ac0d8 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -22,6 +22,8 @@ use std::collections::BTreeMap; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; @@ -702,8 +704,21 @@ static TOKEN_SINKS: parking_lot::Mutex>> = struct TokenSink { session_id: String, state: parking_lot::Mutex, + /// Whether a thread is retrying a write the keyring refused. + retrying: AtomicBool, } +/// How long to wait before each new try at a write the keyring refused; the +/// last delay repeats until it succeeds. A locked or restarting keyring +/// usually comes back within seconds, and one that does not must not be asked +/// in a tight loop. +const KEYRING_RETRY: [Duration; 4] = [ + Duration::from_secs(1), + Duration::from_secs(5), + Duration::from_secs(30), + Duration::from_secs(300), +]; + struct SinkState { /// The refresh token the blob holds for this session, as far as this /// session knows: the one it started from, or the last one it wrote. @@ -745,13 +760,59 @@ impl TokenSink { stored_refresh, unsaved: None, }), + retrying: AtomicBool::new(false), } } - /// Take a new rotation and try to write it. - fn rotated(&self, tokens: Tokens) -> Result<()> { + /// Take a new rotation and try to write it. If the keyring refuses, keep + /// trying in the background: until then the only live refresh token is in + /// memory, and a stop that cannot write it either loses the session + /// (`docs/BUGS.md` B61). + fn rotated(self: &Arc, tokens: Tokens) -> Result<()> { self.state.lock().unsaved = Some(tokens); - self.flush() + let written = self.flush(); + if written.is_err() { + self.retry_in_background(); + } + written + } + + /// Retry the unsaved rotation on [`KEYRING_RETRY`] until it is written or + /// has nowhere left to go. One thread per sink; a newer rotation simply + /// replaces what it is retrying. + fn retry_in_background(self: &Arc) { + if self.retrying.swap(true, Ordering::SeqCst) { + return; + } + let sink = Arc::clone(self); + let spawned = std::thread::Builder::new() + .name("pdfs-keyring-retry".into()) + .spawn(move || { + loop { + let delays = KEYRING_RETRY + .into_iter() + .chain(std::iter::repeat(KEYRING_RETRY[KEYRING_RETRY.len() - 1])); + for delay in delays { + std::thread::sleep(delay); + match sink.flush() { + Ok(()) => break, + Err(e) => tracing::debug!(error = %e, ?delay, "keyring still refuses"), + } + } + sink.retrying.store(false, Ordering::SeqCst); + // A rotation refused after the last try succeeded found + // this thread still running, and started none of its own. + if sink.state.lock().unsaved.is_none() + || sink.retrying.swap(true, Ordering::SeqCst) + { + return; + } + } + }); + if let Err(e) = spawned { + self.retrying.store(false, Ordering::SeqCst); + tracing::error!(error = %e, "could not start retrying the keyring write"); + } } /// Write the unsaved rotation, if there is one. A blob that moved on, or @@ -806,7 +867,11 @@ fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) .insert(session_id.to_owned(), Arc::clone(&sink)); session.http().set_on_tokens_refreshed(move |tokens| { if let Err(e) = sink.rotated(tokens) { - tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring") + tracing::error!( + error = %e, + "the keyring refused the refreshed session tokens; retrying. \ + If the daemon stops first, the next start needs `pdfs login`" + ) } }); } @@ -946,27 +1011,32 @@ mod tests { assert_eq!(stored.refresh_token, "r"); } - /// The only test that touches the keyring, through `keyring-core`'s - /// in-memory store. + /// The keyring is one store per process, so the tests that use it, + /// through `keyring-core`'s in-memory store, take turns. + fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> { + static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(()); + let turn = KEYRING.lock(); + keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap()); + turn + } + + fn stored_with(refresh: &str) -> StoredSession { + serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap() + } + #[test] fn a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login() { - keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap()); - let stored = |refresh: &str| { - serde_json::from_str::( - &blob("").replace(r#""r""#, &format!("{refresh:?}")), - ) - .unwrap() - }; - write_stored(&stored("r")).unwrap(); + let _turn = mock_keyring(); + write_stored(&stored_with("r")).unwrap(); - let sink = TokenSink::new("s", "r".into()); + let sink = Arc::new(TokenSink::new("s", "r".into())); sink.rotated(tokens("a2", "r2")).unwrap(); sink.rotated(tokens("a3", "r3")).unwrap(); assert_eq!(load().unwrap().refresh_token, "r3"); // A new login replaces the blob; the old session's next rotation, and // its stop, leave it alone. - let mut login = stored("fresh"); + let mut login = stored_with("fresh"); login.session_id = "new".into(); write_stored(&login).unwrap(); sink.rotated(tokens("a4", "r4")).unwrap(); @@ -975,4 +1045,32 @@ mod tests { assert_eq!(now.session_id, "new"); assert_eq!(now.refresh_token, "fresh"); } + + /// B61: a rotation the keyring refused lived only in memory. + #[test] + fn a_rotation_the_keyring_refused_is_written_once_it_takes_it() { + let _turn = mock_keyring(); + write_stored(&stored_with("r")).unwrap(); + let sink = Arc::new(TokenSink::new("s", "r".into())); + + let entry = keyring_entry().unwrap(); + let cred = entry + .as_any() + .downcast_ref::() + .unwrap(); + cred.set_error(keyring_core::Error::NoStorageAccess( + std::io::Error::other("locked").into(), + )); + assert!(sink.rotated(tokens("a2", "r2")).is_err()); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while load().unwrap().refresh_token != "r2" { + assert!( + std::time::Instant::now() < deadline, + "the retry never wrote the rotation" + ); + std::thread::sleep(Duration::from_millis(50)); + } + assert!(sink.state.lock().unsaved.is_none()); + } } diff --git a/docs/BUGS.md b/docs/BUGS.md index be5b103..b26c705 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -73,7 +73,6 @@ original wording. | [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about | | [B66](#b66--local-only-pending-data-is-not-protected-at-shutdown-high-21) | Local-only pending data is not protected at shutdown (HIGH-21) | No shutdown protection and no `pdfs sync flush` | | [B65](#b65--release-artifacts-lack-supply-chain-verification-med-11) | Release artifacts lack supply-chain verification (MED-11) | No SBOM, provenance or checksums | -| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | Keyring write failures are only logged | | [B15](#b15--an-empty-but-listed-folder-and-duplicated-folder-uids) | An empty-but-`listed` folder, and duplicated folder uids | Cause not attributed | | [B10](#b10--glib-critical-when-the-launcher-closes-over-an-in-flight-open) | GLib critical when the launcher closes over an in-flight open | | @@ -88,6 +87,7 @@ original wording. | [B68](#b68--profile-backup-cannot-be-written-to-the-device-root) | Profile backup cannot be written to the device root | Backup health is not shown in the app; the restore was not re-run | | [B64](#b64--stable-publishing-lacks-data-safety-and-recovery-gates-crit-12) | Stable publishing lacks data-safety and recovery gates (CRIT-12) | Recovery approval before a release | | [B63](#b63--release-version-identity-is-inconsistent-and-unenforced-high-20) | Release version identity is inconsistent and unenforced (HIGH-20) | Protocol version identity | +| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | A crash or SIGKILL before a retried write lands still loses it; the GUI is not told | | [B57](#b57--event-cursor-can-advance-past-unapplied-state-high-14) | Event cursor can advance past unapplied state (HIGH-14) | Database and apply fault tests | | [B56](#b56--database-schema-version-parsing-fails-open-high-13) | Database schema version parsing fails open (HIGH-13) | Malformed schema versions | | [B12](#b12--cold-enumeration-is-slow-per-entry-and-goes-superlinear-past-500) | Cold enumeration is slow per entry, and goes superlinear past ~500 | The attribute-invalidation follow-up is unverified | @@ -4921,7 +4921,8 @@ daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state. ### B61 — Rotated single-use credentials can be lost (HIGH-18) -**Status:** Open — 1.0 authentication/recovery blocker +**Status:** Partly fixed. A process that dies before a retried write lands still loses the +rotation, and only the log says so. **Found:** 2026-07-22, 1.0 authentication audit **Where:** `crates/pdfs-core/src/auth.rs`, refresh callback @@ -4933,6 +4934,15 @@ keyring retains an invalid single-use refresh token, so restart loses login. notify the user, and define a safe re-login/shutdown path. Test locked/full/ unavailable keyrings, callback races, repeated rotations, death, and restart. +**Fix (2026-10-09, issue #35).** A rotation the keyring refuses is kept as unsaved and retried +in the background after 1 s, 5 s, 30 s and then every 5 minutes, until it is written or the blob +has moved on (B201). The refusal is an error in the log naming `pdfs login`, and the daemon's stop +writes a rotation still unsaved, logging an error if that fails too. The re-login path is B201's: +an old session never writes into a newer login. Left: a crash or SIGKILL before a retry lands, +and telling the GUI. + +**Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it`. + --- ### B60 — Config writes are non-atomic and parse errors are overwritten (HIGH-17) From 0ac5928138a61179ac99b82d9d6dbdb14d128031 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 12:08:17 +0200 Subject: [PATCH 5/8] fix(fuse,core,cli): resume a session another process refreshed `pdfs unlock` resumes the daemon's session and may refresh it. The daemon then holds a spent refresh token, and since B202 a refusal of it read as an expired session although the keyring held a live one. Before calling a refused session expired, compare the keyring with what this process last stored. When it holds a newer rotation or another login, end the mount with `MountOutcome::ResumeStored` and mount again from the keyring. A rotation another process already wrote counts as this session's, so the daemon keeps writing its later ones (B203). --- crates/pdfs-cli/src/main.rs | 3 ++ crates/pdfs-core/src/auth.rs | 60 +++++++++++++++++++++++++++++++++-- crates/pdfs-fuse/src/link.rs | 16 ++++++++++ crates/pdfs-fuse/src/mount.rs | 21 ++++++++++++ docs/BUGS.md | 30 ++++++++++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 253ad7c..58e6dcb 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2193,6 +2193,9 @@ fn cmd_daemon(mountpoint: Option) -> Result<()> { tracing::info!("daemon stopping"); return Ok(()); } + Ok(pdfs_fuse::MountOutcome::ResumeStored) => { + tracing::info!("resuming the session stored in the keyring"); + } Ok(pdfs_fuse::MountOutcome::Unmounted) => { tracing::warn!("mount ended externally; remounting in 2s"); pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2)); diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index f9ac0d8..2f08a96 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -687,6 +687,28 @@ pub fn persist(session: &ProtonApiSession) -> Result<()> { } } +/// Whether the keyring holds a session newer than any this process runs on: +/// another login, or one of this process's sessions rotated by another +/// process (`pdfs unlock` resumes the same login and may refresh it). +/// +/// The refresh token this process holds is spent then, and Proton refuses it +/// as if the session were gone. A daemon asks this before it reports the +/// session expired, and resumes the stored one instead. +pub fn stored_session_is_newer() -> bool { + // Nothing to compare with, and no keyring to wake up for it. + if TOKEN_SINKS.lock().is_empty() { + return false; + } + let Ok(stored) = load() else { + return false; + }; + let sinks = TOKEN_SINKS.lock(); + match sinks.get(&stored.session_id) { + Some(sink) => sink.state.lock().stored_refresh != stored.refresh_token, + None => !sinks.is_empty(), + } +} + /// Where each live session in this process writes its rotated tokens, by /// session id, for [`persist`] to find. static TOKEN_SINKS: parking_lot::Mutex>> = @@ -737,14 +759,17 @@ enum Rotation { } /// Put `tokens` into `stored` if it is still this session's blob: the same -/// session id, holding the refresh token this session last left in it. +/// session id, holding the refresh token this session last left in it, or +/// already holding this very rotation (another process wrote it first). fn take_rotation( stored: &mut StoredSession, session_id: &str, stored_refresh: &str, tokens: &Tokens, ) -> Rotation { - if stored.session_id != session_id || stored.refresh_token != stored_refresh { + if stored.session_id != session_id + || (stored.refresh_token != stored_refresh && stored.refresh_token != tokens.refresh_token) + { return Rotation::MovedOn; } stored.access_token = tokens.access_token.clone(); @@ -1011,6 +1036,16 @@ mod tests { assert_eq!(stored.refresh_token, "r"); } + /// `pdfs unlock` wrote this very rotation first, from the same session. + #[test] + fn a_rotation_already_in_the_blob_is_taken() { + let mut stored = stored_with("r2"); + assert_eq!( + take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")), + Rotation::Taken + ); + } + /// The keyring is one store per process, so the tests that use it, /// through `keyring-core`'s in-memory store, take turns. fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> { @@ -1046,6 +1081,27 @@ mod tests { assert_eq!(now.refresh_token, "fresh"); } + /// `pdfs unlock` rotated the daemon's session behind its back: the + /// daemon's refresh token is spent, and the keyring holds the live one. + #[test] + fn a_session_rotated_by_another_process_is_newer_in_the_keyring() { + let _turn = mock_keyring(); + write_stored(&stored_with("r")).unwrap(); + TOKEN_SINKS + .lock() + .insert("s".into(), Arc::new(TokenSink::new("s", "r".into()))); + assert!(!stored_session_is_newer()); + + write_stored(&stored_with("r2")).unwrap(); + assert!(stored_session_is_newer()); + + let mut login = stored_with("fresh"); + login.session_id = "new".into(); + write_stored(&login).unwrap(); + assert!(stored_session_is_newer()); + TOKEN_SINKS.lock().remove("s"); + } + /// B61: a rotation the keyring refused lived only in memory. #[test] fn a_rotation_the_keyring_refused_is_written_once_it_takes_it() { diff --git a/crates/pdfs-fuse/src/link.rs b/crates/pdfs-fuse/src/link.rs index b1b715a..e2c4df0 100644 --- a/crates/pdfs-fuse/src/link.rs +++ b/crates/pdfs-fuse/src/link.rs @@ -60,6 +60,10 @@ pub(crate) struct Link { /// that restarts the daemon. Read by front-ends through `Response::Status`, /// so they can ask the user to sign in again rather than say "offline". pub(crate) session_expired: AtomicBool, + /// Proton refused the session, but only because another process rotated + /// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop + /// ends the mount for the daemon to mount again from the keyring. + pub(crate) resume_stored: AtomicBool, } impl Link { @@ -202,7 +206,19 @@ impl Core { /// Flip the mount offline for good: Proton refused the session, and only a /// new login brings it back. Changes stay queued for the daemon that login /// starts. Only the first caller logs it. + /// + /// Unless the keyring holds a newer session than the refused one: then + /// the mount ends, for the daemon to mount again on that one. pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) { + if self.link.resume_stored.load(Ordering::Relaxed) { + return self.mark_offline(what, error); + } + if pdfs_core::auth::stored_session_is_newer() { + info!(during = what, %error, + "Proton refused a session another process has since refreshed; resuming it"); + self.link.resume_stored.store(true, Ordering::Relaxed); + return self.mark_offline(what, error); + } if !self.link.session_expired.swap(true, Ordering::Relaxed) { warn!(during = what, %error, "Proton refused the session; run `pdfs login` to sign in again"); diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index a673cf0..1aeccdc 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -12,6 +12,10 @@ pub enum MountOutcome { /// The kernel mount ended on its own (e.g. an external `fusermount -u`). /// The caller may want to remount. Unmounted, + /// Proton refused the session, and the keyring holds a newer one: another + /// process (`pdfs unlock`) refreshed it, or a new login replaced it. We + /// unmounted ourselves; the caller should mount again, which resumes it. + ResumeStored, } /// Whether `path` is a mountpoint whose FUSE connection is dead — the state a @@ -227,6 +231,11 @@ fn fetch_or_recall_root( } else { Start::Offline }; + if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() { + return Err(std::io::Error::other(format!( + "Proton refused a session another process has since refreshed ({err}); resuming it" + ))); + } let cached = db .state_str(ROOT_UID_KEY) .ok() @@ -745,6 +754,18 @@ pub(crate) fn mount_with( } break MountOutcome::Shutdown; } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + if core.link.resume_stored.load(Ordering::Relaxed) => + { + info!("unmounting to resume the stored session"); + core.mounts.lock().close(); + if let Err(e) = teardown_session(&core.session_live, || { + umount_session_unblocked(bg, main_conn) + }) { + warn!(error = %e, "umount_and_join failed"); + } + break MountOutcome::ResumeStored; + } Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { if bg.guard.is_finished() { info!("mount ended externally"); diff --git a/docs/BUGS.md b/docs/BUGS.md index b26c705..2d92b8d 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -101,6 +101,7 @@ a live account since. | Entry | Title | |---|---| +| [B203](#b203--pdfs-unlock-leaves-a-running-daemon-a-spent-refresh-token) | `pdfs unlock` leaves a running daemon a spent refresh token | | [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again | | [B201](#b201--after-signing-in-again-the-old-daemon-puts-its-spent-tokens-back) | After signing in again, the old daemon puts its spent tokens back | | [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy | @@ -305,6 +306,35 @@ a live account since. Newest first. +### B203 — `pdfs unlock` leaves a running daemon a spent refresh token + +**Status:** Fixed, not verified live +**Found:** 2026-10-09, reading the code for issue #35 after the B201 fix +**Where:** `crates/pdfs-core/src/auth.rs`, `stored_session_is_newer`; `crates/pdfs-fuse/src/link.rs`, +`Core::mark_session_expired`; `crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-cli/src/main.rs`, +`cmd_daemon` + +**Repro:** With the daemon running, run `pdfs unlock` while the stored access token has expired. +Unlock resumes the same session, refreshes it and stores the new tokens. The daemon still holds +the old refresh token. If it refreshes before unlock restarts it, Proton refuses the spent token, +and since B202 the mount reports the session expired, although the keyring holds a live one. + +**Cause.** Two processes ran the same session, and only one of them could hold its live refresh +token. B201 stopped the daemon from writing its spent token over the live one, but nothing made the +daemon pick up the live one. + +**Fix.** Before a refused session counts as expired, `auth::stored_session_is_newer` compares the +keyring with what this process last stored. When the keyring holds a newer rotation of the same +session, or another login, the mount ends with `MountOutcome::ResumeStored`, and the daemon mounts +again at once from the keyring. At startup the same check turns the refusal into a mount error, +which the daemon retries. A rotation that another process already wrote into the blob is now +taken as this session's, so the daemon keeps writing its later rotations. + +**Test:** `auth::tests::a_session_rotated_by_another_process_is_newer_in_the_keyring` and +`auth::tests::a_rotation_already_in_the_blob_is_taken`. + +--- + ### B202 — A refused session shows as offline forever, and nothing says to sign in again **Status:** Fixed, not verified live From 3458e2e72d61b2c54fa149227ced1c10c29972b0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 12:14:25 +0200 Subject: [PATCH 6/8] fix(core): keep refused token rotations in the kernel keyring A rotation the Secret Service refused lived only in the daemon's memory, so a crash or SIGKILL before the retry landed lost the only live refresh token. Keep it in the kernel keyring as well until it is written. The next resume, by the daemon or `pdfs unlock`, puts it back in when the stored blob still holds the refresh token it spent, and writes it; one left over from an older login is dropped. `kernelkey` now holds named keys, the passphrases and the unsaved tokens (B61). --- crates/pdfs-core/src/auth.rs | 171 ++++++++++++++++++++--- crates/pdfs-core/src/kernelkey.rs | 225 +++++++++++++++++------------- docs/BUGS.md | 16 ++- 3 files changed, 289 insertions(+), 123 deletions(-) diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 2f08a96..46e909a 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -364,11 +364,11 @@ fn keep_passphrases( let outcome = if remember { stored.key_passphrases = Some(encode(passphrases)); // Best-effort: a system without a kernel keyring has nothing to clear. - let _ = kernelkey::clear(); + let _ = kernelkey::PASSPHRASES.clear(); Ok(()) } else { stored.key_passphrases = None; - kernelkey::store(&encode_secret(passphrases)).map_err(|e| { + kernelkey::PASSPHRASES.store(&encode_secret(passphrases)).map_err(|e| { Error::Other(format!( "the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}" )) @@ -469,7 +469,7 @@ fn find_passphrases(stored: &StoredSession) -> Result Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))), Ok(None) => Ok(None), // No keyring on this system: nothing was ever kept there. @@ -495,8 +495,7 @@ pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> { return Err(Error::ReloginRequired); } let mailbox = Zeroizing::new(mailbox.to_owned()); - let session = ProtonApiSession::resume(client_config(), stored.to_params())?; - register_refresh_handler(&session, stored.refresh_token.clone()); + let session = resume_stored(&stored)?; let passphrases = derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?; // Read again: verifying may have rotated the tokens, and the copy read @@ -507,7 +506,7 @@ pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> { /// Drop the passphrases held in kernel memory, so the account is locked again /// until [`unlock`]. With `forget`, also those in the keyring blob. pub fn lock(forget: bool) -> Result<()> { - kernelkey::clear()?; + kernelkey::PASSPHRASES.clear()?; if forget { let mut stored = load()?; if stored.key_passphrases.take().is_some() { @@ -535,9 +534,10 @@ pub fn logout() -> Result<()> { let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); } } - if let Err(e) = kernelkey::clear() { + if let Err(e) = kernelkey::PASSPHRASES.clear() { tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed"); } + let _ = kernelkey::UNSAVED_TOKENS.clear(); match keyring_entry()?.delete_credential() { Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), @@ -557,8 +557,7 @@ pub fn logout() -> Result<()> { /// a daemon starting offline can still mount from its cache. pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { let stored = load()?; - let session = ProtonApiSession::resume(client_config(), stored.to_params())?; - register_refresh_handler(&session, stored.refresh_token.clone()); + let session = resume_stored(&stored)?; let passphrases = match find_passphrases(&stored)? { Some((passphrases, _)) => passphrases, @@ -573,6 +572,48 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { Ok((client, session)) } +/// Resume `stored`, with the rotation the keyring refused last time put in +/// first, if there is one: Proton has spent the stored refresh token then, +/// and the kernel keyring holds the one that replaced it (B61). +fn resume_stored(stored: &StoredSession) -> Result { + let unsaved = recover_unsaved(stored); + let mut resumed = stored.clone(); + if let Some(tokens) = &unsaved { + tracing::info!("resuming with session tokens the keyring refused last time"); + resumed.access_token = tokens.access_token.clone(); + resumed.refresh_token = tokens.refresh_token.clone(); + } + let session = ProtonApiSession::resume(client_config(), resumed.to_params())?; + let sink = register_refresh_handler(&session, stored.refresh_token.clone()); + if let Some(tokens) = unsaved { + let _ = sink.rotated(tokens); + } + Ok(session) +} + +/// The rotation the kernel keyring holds for `stored`, if it belongs there. +/// One that does not is from an older login, or was written since, and is +/// dropped. +fn recover_unsaved(stored: &StoredSession) -> Option { + let kept = match kernelkey::UNSAVED_TOKENS.load() { + Ok(kept) => kept?, + Err(e) => { + tracing::debug!(error = %e, "kernel keyring unavailable; no unsaved tokens to recover"); + return None; + } + }; + match serde_json::from_slice::(&kept) { + Ok(unsaved) if unsaved.belongs_in(stored) => Some(Tokens { + access_token: unsaved.access_token, + refresh_token: unsaved.refresh_token, + }), + _ => { + let _ = kernelkey::UNSAVED_TOKENS.clear(); + None + } + } +} + /// Turn a blob written by 3.0.x — which holds the mailbox password — into one /// that holds passphrases, so the password stops being stored. /// @@ -747,6 +788,27 @@ struct SinkState { stored_refresh: String, /// The latest rotation, until the keyring has taken it. unsaved: Option, + /// Whether `unsaved` is also in [`kernelkey::UNSAVED_TOKENS`]. + kept: bool, +} + +/// A rotation the keyring refused, as kept in the kernel keyring until it is +/// written: the tokens, and the blob they go into. +#[derive(Serialize, Deserialize)] +struct UnsavedRotation { + session_id: String, + /// The refresh token this rotation spent, which the blob still holds. + spent: String, + access_token: String, + refresh_token: String, +} + +impl UnsavedRotation { + /// Whether `stored` is the blob this rotation was refused for, still + /// holding the refresh token it spent. + fn belongs_in(&self, stored: &StoredSession) -> bool { + self.session_id == stored.session_id && self.spent == stored.refresh_token + } } /// What became of a rotation offered to a stored blob. @@ -784,6 +846,7 @@ impl TokenSink { state: parking_lot::Mutex::new(SinkState { stored_refresh, unsaved: None, + kept: false, }), retrying: AtomicBool::new(false), } @@ -796,12 +859,44 @@ impl TokenSink { fn rotated(self: &Arc, tokens: Tokens) -> Result<()> { self.state.lock().unsaved = Some(tokens); let written = self.flush(); - if written.is_err() { + if let Err(e) = &written { + tracing::error!( + error = %e, + "the keyring refused the refreshed session tokens; retrying" + ); + self.keep_in_kernel(); self.retry_in_background(); } written } + /// Keep the unsaved rotation in the kernel keyring too, so a process that + /// dies before the retry lands does not take the only live refresh token + /// with it. The next resume puts it back (see [`resume_stored`]). + fn keep_in_kernel(&self) { + let mut state = self.state.lock(); + let Some(tokens) = &state.unsaved else { + return; + }; + let unsaved = UnsavedRotation { + session_id: self.session_id.clone(), + spent: state.stored_refresh.clone(), + access_token: tokens.access_token.clone(), + refresh_token: tokens.refresh_token.clone(), + }; + let Ok(json) = serde_json::to_vec(&unsaved).map(Zeroizing::new) else { + return; + }; + match kernelkey::UNSAVED_TOKENS.store(&json) { + Ok(()) => state.kept = true, + Err(e) => tracing::error!( + error = %e, + "the kernel keyring refused them too; if this process stops first, \ + the next start needs `pdfs login`" + ), + } + } + /// Retry the unsaved rotation on [`KEYRING_RETRY`] until it is written or /// has nowhere left to go. One thread per sink; a newer rotation simply /// replaces what it is retrying. @@ -850,7 +945,7 @@ impl TokenSink { let mut stored = match load() { Ok(stored) => stored, Err(Error::NotLoggedIn) => { - state.unsaved = None; + state.settle(); return Ok(()); } Err(e) => return Err(e), @@ -872,11 +967,22 @@ impl TokenSink { ); } } - state.unsaved = None; + state.settle(); Ok(()) } } +impl SinkState { + /// The unsaved rotation has gone where it can: drop it, and its copy in + /// the kernel keyring. + fn settle(&mut self) { + self.unsaved = None; + if std::mem::take(&mut self.kept) { + let _ = kernelkey::UNSAVED_TOKENS.clear(); + } + } +} + /// Persist rotated tokens the moment the session obtains them. /// /// `stored_refresh` is the refresh token the keyring holds for this session @@ -884,21 +990,18 @@ impl TokenSink { /// cannot write back a stale copy over a later unlock or lock, and it writes /// only into a blob that is still this session's (see [`TokenSink`]), so it /// cannot resurrect a logged-out session or overwrite a newer login. -fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) { +fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) -> Arc { let session_id = session.session_id().as_str(); let sink = Arc::new(TokenSink::new(session_id, stored_refresh)); TOKEN_SINKS .lock() .insert(session_id.to_owned(), Arc::clone(&sink)); + let handler = Arc::clone(&sink); session.http().set_on_tokens_refreshed(move |tokens| { - if let Err(e) = sink.rotated(tokens) { - tracing::error!( - error = %e, - "the keyring refused the refreshed session tokens; retrying. \ - If the daemon stops first, the next start needs `pdfs login`" - ) - } + // A refusal is logged and retried by the sink itself. + let _ = handler.rotated(tokens); }); + sink } #[cfg(test)] @@ -1118,6 +1221,15 @@ mod tests { std::io::Error::other("locked").into(), )); assert!(sink.rotated(tokens("a2", "r2")).is_err()); + // Until it is written, a stop does not lose it: the next resume finds + // it in the kernel keyring, where one is offered. + let kept = sink.state.lock().kept; + if kept { + assert_eq!( + recover_unsaved(&stored_with("r")).unwrap().refresh_token, + "r2" + ); + } let deadline = std::time::Instant::now() + Duration::from_secs(5); while load().unwrap().refresh_token != "r2" { @@ -1128,5 +1240,24 @@ mod tests { std::thread::sleep(Duration::from_millis(50)); } assert!(sink.state.lock().unsaved.is_none()); + if kept { + assert!(kernelkey::UNSAVED_TOKENS.load().unwrap().is_none()); + } + } + + #[test] + fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() { + let unsaved = UnsavedRotation { + session_id: "s".into(), + spent: "r".into(), + access_token: "a2".into(), + refresh_token: "r2".into(), + }; + assert!(unsaved.belongs_in(&stored_with("r"))); + // Written since, by a retry or another process. + assert!(!unsaved.belongs_in(&stored_with("r2"))); + let mut login = stored_with("r"); + login.session_id = "new".into(); + assert!(!unsaved.belongs_in(&login)); } } diff --git a/crates/pdfs-core/src/kernelkey.rs b/crates/pdfs-core/src/kernelkey.rs index a090cb8..d0ea652 100644 --- a/crates/pdfs-core/src/kernelkey.rs +++ b/crates/pdfs-core/src/kernelkey.rs @@ -5,6 +5,8 @@ //! kept in kernel memory — not on disk, not in the Secret Service — and vanish //! when the user's last session ends or the machine reboots. Both the daemon and //! `pdfs unlock` reach the same entry because it hangs off the *user* keyring. +//! It is also where session tokens wait while the Secret Service refuses them +//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them. //! //! Kernel keys are not paged to swap, are invisible to other users, and are //! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a @@ -20,12 +22,34 @@ use std::io; use zeroize::Zeroizing; -/// Key description. Namespaced so it cannot collide with another program's key. +/// One secret in the user keyring, by its description. Descriptions are +/// namespaced so they cannot collide with another program's keys. +pub struct Key { + description: &'static [u8], +} + +/// The account's key passphrases (see the module docs). #[cfg(not(test))] -const DESCRIPTION: &[u8] = b"pdfs:key-passphrases\0"; +pub const PASSPHRASES: Key = Key { + description: b"pdfs:key-passphrases\0", +}; /// Tests must never touch a real unlock. #[cfg(test)] -const DESCRIPTION: &[u8] = b"pdfs:key-passphrases:test\0"; +pub const PASSPHRASES: Key = Key { + description: b"pdfs:key-passphrases:test\0", +}; + +/// Session tokens the Secret Service refused to store, until it takes them. +/// A refresh token held only in a process's memory dies with the process. +#[cfg(not(test))] +pub const UNSAVED_TOKENS: Key = Key { + description: b"pdfs:unsaved-tokens\0", +}; +#[cfg(test)] +pub const UNSAVED_TOKENS: Key = Key { + description: b"pdfs:unsaved-tokens:test\0", +}; + const KEY_TYPE: &[u8] = b"user\0"; const KEY_SPEC_USER_KEYRING: i32 = -4; @@ -49,7 +73,7 @@ fn last_error() -> io::Result { Err(io::Error::last_os_error()) } -fn find() -> io::Result> { +fn find(description: &[u8]) -> io::Result> { // SAFETY: both strings are NUL-terminated and outlive the call. let id = unsafe { libc::syscall( @@ -57,7 +81,7 @@ fn find() -> io::Result> { KEYCTL_SEARCH, KEY_SPEC_USER_KEYRING, KEY_TYPE.as_ptr(), - DESCRIPTION.as_ptr(), + description.as_ptr(), 0, ) }; @@ -83,100 +107,102 @@ pub fn forbid_dumps() { } } -/// Store `secret`, replacing any earlier one. -pub fn store(secret: &[u8]) -> io::Result<()> { - // SAFETY: strings are NUL-terminated; the payload pointer/length pair - // describes `secret`, which outlives the call. - let id = unsafe { - libc::syscall( - libc::SYS_add_key, - KEY_TYPE.as_ptr(), - DESCRIPTION.as_ptr(), - secret.as_ptr(), - secret.len(), - KEY_SPEC_USER_KEYRING, - ) - }; - if id < 0 { - return last_error(); - } - // SAFETY: plain integer arguments. - let rc = unsafe { - libc::syscall( - libc::SYS_keyctl, - KEYCTL_SETPERM, - id, - PERM_OWNER_AND_POSSESSOR, - ) - }; - if rc < 0 { - // A key whose permissions are unknown is not one to leave behind. - let err = io::Error::last_os_error(); - let _ = clear(); - return Err(err); - } - Ok(()) -} - -/// Read the stored secret, or `None` when there is none. -pub fn load() -> io::Result>>> { - let Some(id) = find()? else { - return Ok(None); - }; - // Size first, then read; retry if the key was replaced in between. - for _ in 0..3 { - // SAFETY: a null buffer asks the kernel only for the payload length. - let len = unsafe { +impl Key { + /// Store `secret`, replacing any earlier one. + pub fn store(&self, secret: &[u8]) -> io::Result<()> { + // SAFETY: strings are NUL-terminated; the payload pointer/length pair + // describes `secret`, which outlives the call. + let id = unsafe { libc::syscall( - libc::SYS_keyctl, - KEYCTL_READ, - id, - std::ptr::null_mut::(), - 0usize, + libc::SYS_add_key, + KEY_TYPE.as_ptr(), + self.description.as_ptr(), + secret.as_ptr(), + secret.len(), + KEY_SPEC_USER_KEYRING, ) }; - if len < 0 { + if id < 0 { + return last_error(); + } + // SAFETY: plain integer arguments. + let rc = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_SETPERM, + id, + PERM_OWNER_AND_POSSESSOR, + ) + }; + if rc < 0 { + // A key whose permissions are unknown is not one to leave behind. + let err = io::Error::last_os_error(); + let _ = self.clear(); + return Err(err); + } + Ok(()) + } + + /// Read the stored secret, or `None` when there is none. + pub fn load(&self) -> io::Result>>> { + let Some(id) = find(self.description)? else { + return Ok(None); + }; + // Size first, then read; retry if the key was replaced in between. + for _ in 0..3 { + // SAFETY: a null buffer asks the kernel only for the payload length. + let len = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_READ, + id, + std::ptr::null_mut::(), + 0usize, + ) + }; + if len < 0 { + return match io::Error::last_os_error().raw_os_error() { + Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None), + _ => last_error(), + }; + } + let mut buf = Zeroizing::new(vec![0u8; len as usize]); + // SAFETY: `buf` is `len` writable bytes. + let read = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_READ, + id, + buf.as_mut_ptr(), + buf.len(), + ) + }; + if read < 0 { + return last_error(); + } + if read as usize <= buf.len() { + buf.truncate(read as usize); + return Ok(Some(buf)); + } + } + Err(io::Error::other("the key changed while it was being read")) + } + + /// Remove the stored secret. Succeeds when there is none. + pub fn clear(&self) -> io::Result<()> { + let Some(id) = find(self.description)? else { + return Ok(()); + }; + // SAFETY: plain integer arguments. + let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) }; + if rc < 0 { return match io::Error::last_os_error().raw_os_error() { - Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None), + Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()), _ => last_error(), }; } - let mut buf = Zeroizing::new(vec![0u8; len as usize]); - // SAFETY: `buf` is `len` writable bytes. - let read = unsafe { - libc::syscall( - libc::SYS_keyctl, - KEYCTL_READ, - id, - buf.as_mut_ptr(), - buf.len(), - ) - }; - if read < 0 { - return last_error(); - } - if read as usize <= buf.len() { - buf.truncate(read as usize); - return Ok(Some(buf)); - } + Ok(()) } - Err(io::Error::other("the key changed while it was being read")) -} - -/// Remove the stored secret. Succeeds when there is none. -pub fn clear() -> io::Result<()> { - let Some(id) = find()? else { - return Ok(()); - }; - // SAFETY: plain integer arguments. - let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) }; - if rc < 0 { - return match io::Error::last_os_error().raw_os_error() { - Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()), - _ => last_error(), - }; - } - Ok(()) } #[cfg(test)] @@ -187,18 +213,21 @@ mod tests { // so one test covers the whole round trip and skips when it is unavailable. #[test] fn stored_secret_round_trips_and_clears() { - if store(b"probe").is_err() { + if PASSPHRASES.store(b"probe").is_err() { eprintln!("kernel keyring unavailable; skipping"); return; } - let saved = load().unwrap().expect("stored secret is readable"); + let saved = PASSPHRASES + .load() + .unwrap() + .expect("stored secret is readable"); assert_eq!(saved.as_slice(), b"probe"); - store(b"replaced").unwrap(); - assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced"); + PASSPHRASES.store(b"replaced").unwrap(); + assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced"); - clear().unwrap(); - assert!(load().unwrap().is_none()); - clear().unwrap(); + PASSPHRASES.clear().unwrap(); + assert!(PASSPHRASES.load().unwrap().is_none()); + PASSPHRASES.clear().unwrap(); } } diff --git a/docs/BUGS.md b/docs/BUGS.md index 2d92b8d..7dab2d2 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -4951,8 +4951,7 @@ daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state. ### B61 — Rotated single-use credentials can be lost (HIGH-18) -**Status:** Partly fixed. A process that dies before a retried write lands still loses the -rotation, and only the log says so. +**Status:** Partly fixed. Only the log says that the keyring refused a rotation. **Found:** 2026-07-22, 1.0 authentication audit **Where:** `crates/pdfs-core/src/auth.rs`, refresh callback @@ -4968,10 +4967,17 @@ unavailable keyrings, callback races, repeated rotations, death, and restart. in the background after 1 s, 5 s, 30 s and then every 5 minutes, until it is written or the blob has moved on (B201). The refusal is an error in the log naming `pdfs login`, and the daemon's stop writes a rotation still unsaved, logging an error if that fails too. The re-login path is B201's: -an old session never writes into a newer login. Left: a crash or SIGKILL before a retry lands, -and telling the GUI. +an old session never writes into a newer login. -**Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it`. +Until it is written, the rotation also waits in the kernel keyring (`kernelkey::UNSAVED_TOKENS`), +so a crash or SIGKILL before a retry lands no longer loses it. The next resume, by the daemon or +`pdfs unlock`, puts it back in when the blob still holds the refresh token it spent, and writes +it. One that belongs to an older login is dropped. The kernel keyring lasts until the user's last +session ends, so a refused write followed by a crash and a logout still loses the rotation. Left: +telling the GUI. + +**Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it` and +`auth::tests::an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for`. --- From 2b6c37f54355daa21280b574aba44b90d7915e8f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 12:22:02 +0200 Subject: [PATCH 7/8] fix(fuse,core,cli,gui): say when the keyring refuses the refreshed sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rotation the keyring refused was retried and kept in the kernel keyring, but only the log said so, and a reboot before the keyring took it still signed the user out. `Response::Status` carries `tokens_unsaved` while one waits. The tray says "Sign-in not saved — unlock your keyring" and needs attention, the app's status says the same, `pdfs status` prints a Keyring line and `pdfs diagnose` warns. New strings are translated in every catalog (B61). --- crates/pdfs-cli/src/main.rs | 16 ++++ crates/pdfs-core/src/auth.rs | 10 +++ crates/pdfs-core/src/control.rs | 5 ++ crates/pdfs-fuse/src/control.rs | 1 + crates/pdfs-fuse/src/events.rs | 4 +- crates/pdfs-gui/src/app/pages/status.rs | 14 +++ crates/pdfs-gui/src/main.rs | 18 ++++ docs/BUGS.md | 14 +-- po/de.po | 108 +++++++++++++----------- po/en_GB.po | 108 +++++++++++++----------- po/es.po | 108 +++++++++++++----------- po/fr.po | 108 +++++++++++++----------- po/it.po | 108 +++++++++++++----------- po/nl.po | 108 +++++++++++++----------- po/pdfs.pot | 108 +++++++++++++----------- po/pl.po | 108 +++++++++++++----------- po/pt_BR.po | 108 +++++++++++++----------- 17 files changed, 616 insertions(+), 438 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 58e6dcb..ccba21b 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2025,6 +2025,7 @@ fn cmd_status() -> Result<()> { paused, paused_until, session_expired, + tokens_unsaved, .. }) => { let state = match (online, paused) { @@ -2061,6 +2062,11 @@ fn cmd_status() -> Result<()> { failing_error.as_deref().unwrap_or("no error recorded"), ); } + if tokens_unsaved { + println!( + "Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`" + ); + } } Ok(other) => println!("Mount: unexpected response {other:?}"), Err(_) => println!("Mount: not running."), @@ -3435,6 +3441,7 @@ fn cmd_diagnose() -> Result<()> { mountpoint, online, session_expired, + tokens_unsaved, pending_uploads, pending_changes, .. @@ -3456,6 +3463,15 @@ fn cmd_diagnose() -> Result<()> { |kind| format!("{mountpoint} ({kind})"), ), ); + // The keyring check above read the blob, which still holds the + // tokens the daemon could not replace. + if tokens_unsaved { + report.finding( + DiagnoseLevel::Warn, + " session tokens", + "the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`", + ); + } // A refused session reads as offline too, but no connection will // bring it back, and the keyring check above cannot tell. if session_expired { diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 46e909a..796db3c 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -750,6 +750,16 @@ pub fn stored_session_is_newer() -> bool { } } +/// Whether a session in this process holds refreshed tokens the keyring has +/// not taken yet. They are retried and kept in the kernel keyring meanwhile, +/// which a reboot clears, so the user is asked to unlock the keyring (B61). +pub fn tokens_unsaved() -> bool { + TOKEN_SINKS + .lock() + .values() + .any(|sink| sink.state.lock().unsaved.is_some()) +} + /// Where each live session in this process writes its rotated tokens, by /// session id, for [`persist`] to find. static TOKEN_SINKS: parking_lot::Mutex>> = diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 0620556..f2eebd4 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -1964,6 +1964,11 @@ pub enum Response { /// connection brings it back: only signing in again does. #[serde(default)] session_expired: bool, + /// True while the keyring refuses the session's refreshed tokens. The + /// daemon keeps retrying; until it succeeds, a reboot signs the user + /// out. + #[serde(default)] + tokens_unsaved: bool, }, /// A human-readable success message. Ok { message: String }, diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index efe5ae2..86646fd 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -335,6 +335,7 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> pins, online: core.online.load(Ordering::Relaxed), session_expired: core.session_expired(), + tokens_unsaved: pdfs_core::auth::tokens_unsaved(), pending_uploads: queued.uploads.max(0) as u64 + landing, pending_changes: queued.changes.max(0) as u64, } diff --git a/crates/pdfs-fuse/src/events.rs b/crates/pdfs-fuse/src/events.rs index 72cafa8..90c7462 100644 --- a/crates/pdfs-fuse/src/events.rs +++ b/crates/pdfs-fuse/src/events.rs @@ -236,6 +236,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) { budget, online, session_expired, + tokens_unsaved, pending_uploads, pending_changes, .. @@ -248,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) { ); let key = format!( "{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\ - {used}/{budget}/{online}/{session_expired}" + {used}/{budget}/{online}/{session_expired}/{tokens_unsaved}" ); (key, queue) } @@ -327,6 +328,7 @@ mod tests { pins: Vec::new(), online: true, session_expired: false, + tokens_unsaved: false, pending_uploads: 1, pending_changes: 0, }; diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 3f93c45..19e674e 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -1229,6 +1229,7 @@ pub(crate) fn paint_status(ui: &Rc, status: Response) { paused, paused_until, session_expired, + tokens_unsaved, .. } = status else { @@ -1241,6 +1242,8 @@ pub(crate) fn paint_status(ui: &Rc, status: Response) { let queued = i18n::pending_summary(pending_uploads, pending_changes); let state = if session_expired { SyncState::SessionExpired { queued } + } else if tokens_unsaved { + SyncState::TokensUnsaved } else if paused { SyncState::Paused { until: paused_until, @@ -1309,6 +1312,9 @@ pub(crate) enum SyncState { SessionExpired { queued: Option, }, + /// The keyring refuses the session's refreshed tokens. Sync goes on, but + /// a reboot before the keyring takes them signs the user out. + TokensUnsaved, /// The user paused syncing; nothing goes up until it resumes. Outranks /// everything else, because it is the reason for everything else. Paused { @@ -1358,6 +1364,14 @@ fn paint_sync_status(ui: &Rc, state: SyncState) { None => gettext("Sign in again to sync"), }), ), + SyncState::TokensUnsaved => ( + "dialog-warning-symbolic", + Some("warning"), + gettext("Sign-in not saved"), + Some(gettext( + "Unlock your keyring, or the next reboot signs you out", + )), + ), SyncState::Paused { until, queued } => ( "media-playback-pause-symbolic", Some("warning"), diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 3e21160..fd4b53a 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -238,6 +238,7 @@ fn status_state( failing_ops, paused, session_expired, + tokens_unsaved, .. } = status else { @@ -247,6 +248,8 @@ fn status_state( let queued = i18n::pending_summary(*pending_uploads, *pending_changes); let phase = if *session_expired { Phase::SessionExpired + } else if *tokens_unsaved { + Phase::Attention } else { phase_of( paused, @@ -260,6 +263,8 @@ fn status_state( // Outranks everything: it is why nothing else moves, and the one // thing that fixes it is the user's to do. _ if *session_expired => gettext("Session expired — sign in again"), + // Sync goes on meanwhile, but a reboot ends it until the next login. + _ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"), _ if paused => gettext("Sync paused"), _ if failing_ops > 0 => ngettext_f( "{n} change needs attention", @@ -642,6 +647,19 @@ mod tests { assert_eq!(state.line, "Session expired — sign in again"); } + /// B61: the keyring refusing the refreshed tokens was only in the log. + #[test] + fn a_sign_in_the_keyring_refused_needs_attention() { + let state = status_state( + &status(r#","tokens_unsaved":true"#), + &[], + &[], + Path::new("/m"), + ); + assert_eq!(state.phase, Phase::Attention); + assert_eq!(state.line, "Sign-in not saved — unlock your keyring"); + } + #[test] fn a_daemon_that_does_not_say_has_a_live_session() { let state = status_state(&status(""), &[], &[], Path::new("/m")); diff --git a/docs/BUGS.md b/docs/BUGS.md index 7dab2d2..10d0c81 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -87,7 +87,6 @@ original wording. | [B68](#b68--profile-backup-cannot-be-written-to-the-device-root) | Profile backup cannot be written to the device root | Backup health is not shown in the app; the restore was not re-run | | [B64](#b64--stable-publishing-lacks-data-safety-and-recovery-gates-crit-12) | Stable publishing lacks data-safety and recovery gates (CRIT-12) | Recovery approval before a release | | [B63](#b63--release-version-identity-is-inconsistent-and-unenforced-high-20) | Release version identity is inconsistent and unenforced (HIGH-20) | Protocol version identity | -| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | A crash or SIGKILL before a retried write lands still loses it; the GUI is not told | | [B57](#b57--event-cursor-can-advance-past-unapplied-state-high-14) | Event cursor can advance past unapplied state (HIGH-14) | Database and apply fault tests | | [B56](#b56--database-schema-version-parsing-fails-open-high-13) | Database schema version parsing fails open (HIGH-13) | Malformed schema versions | | [B12](#b12--cold-enumeration-is-slow-per-entry-and-goes-superlinear-past-500) | Cold enumeration is slow per entry, and goes superlinear past ~500 | The attribute-invalidation follow-up is unverified | @@ -193,6 +192,7 @@ a live account since. | [B81](#b81--prompt-labels-every-drive-hit-my-files-including-device-folders) | Prompt labels every Drive hit "My files", including device folders | | [B80](#b80--29-of-the-account-is-absent-from-the-search-index) | 29% of the account is absent from the search index | | [B62](#b62--debian-artifact-omits-required-service-and-autostart-units-high-19) | Debian artifact omits required service and autostart units (HIGH-19) | +| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | | [B60](#b60--config-writes-are-non-atomic-and-parse-errors-are-overwritten-high-17) | Config writes are non-atomic and parse errors are overwritten (HIGH-17) | | [B59](#b59--private-state-permission-enforcement-fails-open-high-16) | Private state permission enforcement fails open (HIGH-16) | | [B58](#b58--control-socket-has-unbounded-frames-and-connections-high-15) | Control socket has unbounded frames and connections (HIGH-15) | @@ -4951,7 +4951,7 @@ daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state. ### B61 — Rotated single-use credentials can be lost (HIGH-18) -**Status:** Partly fixed. Only the log says that the keyring refused a rotation. +**Status:** Fixed, not verified live **Found:** 2026-07-22, 1.0 authentication audit **Where:** `crates/pdfs-core/src/auth.rs`, refresh callback @@ -4973,11 +4973,15 @@ Until it is written, the rotation also waits in the kernel keyring (`kernelkey:: so a crash or SIGKILL before a retry lands no longer loses it. The next resume, by the daemon or `pdfs unlock`, puts it back in when the blob still holds the refresh token it spent, and writes it. One that belongs to an older login is dropped. The kernel keyring lasts until the user's last -session ends, so a refused write followed by a crash and a logout still loses the rotation. Left: -telling the GUI. +session ends, so a refused write followed by a crash and a logout still loses the rotation. + +While a rotation waits, `Response::Status` carries `tokens_unsaved`. The tray says "Sign-in not +saved — unlock your keyring" and needs attention, the app's status says the same, `pdfs status` +prints a `Keyring` line and `pdfs diagnose` warns. **Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it` and -`auth::tests::an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for`. +`auth::tests::an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for`, and the tray's +`tests::a_sign_in_the_keyring_refused_needs_attention`. --- diff --git a/po/de.po b/po/de.po index ce1a53e..c9e8ce9 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -413,7 +413,7 @@ msgstr "Zum Umbenennen genau ein Element auswählen" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Nicht verbunden" @@ -424,7 +424,7 @@ msgstr "Der Proton-Drive-Dienst läuft nicht." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Verbindung wird hergestellt …" @@ -1279,7 +1279,7 @@ msgstr "Alben konnten nicht geladen werden" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1547,7 +1547,7 @@ msgid "Make available offline" msgstr "Offline verfügbar machen" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Nur online verfügbar machen" @@ -2755,7 +2755,7 @@ msgid "Sync paused until you resume" msgstr "Synchronisierung bis zur Fortsetzung pausiert" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Synchronisierung pausiert" @@ -4852,126 +4852,134 @@ msgstr "{arrow} {name} – {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name} – {done} von {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} zwischengespeichert – keine Grenze festgelegt" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "{used} von {limit} belegt" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Sitzung abgelaufen" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · zum Synchronisieren erneut anmelden" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Zum Synchronisieren erneut anmelden" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Anmeldung nicht gespeichert" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Entsperren Sie Ihren Schlüsselbund, sonst werden Sie beim nächsten Neustart abgemeldet" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · wird {time} fortgesetzt" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Wird {time} fortgesetzt" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Bis zur manuellen Fortsetzung" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "{n} Änderung erfordert Aufmerksamkeit" msgstr[1] "{n} Änderungen erfordern Aufmerksamkeit" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Nur zwischengespeicherte Dateien" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Wird synchronisiert" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "Auf dem neuesten Stand" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "Proton Drive läuft nicht" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "Reagiert nicht" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "Proton Drive braucht zu lange für eine Antwort" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "später" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "um %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "am %a um %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "am %-d. %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "Keine offline verfügbaren Dateien" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "" "Mit einem Rechtsklick auf eine Datei „Offline verfügbar machen“ auswählen." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "Konnte nicht auf „nur online“ umgestellt werden" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Weniger anzeigen" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -6056,25 +6064,29 @@ msgstr "Fehler: {error}" msgid "Proton Drive sent an unexpected reply" msgstr "Proton Drive hat unerwartet geantwortet" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Sitzung abgelaufen – erneut anmelden" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Anmeldung nicht gespeichert – Schlüsselbund entsperren" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "Aktuell in {mountpoint} ({n} angeheftet)" msgstr[1] "Aktuell in {mountpoint} ({n} angeheftet)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "Wird synchronisiert – {queued} ({n} angeheftet)" msgstr[1] "Wird synchronisiert – {queued} ({n} angeheftet)" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -6082,56 +6094,56 @@ msgstr[0] "Offline – nur zwischengespeicherte Dateien ({n} angeheftet)" msgstr[1] "Offline – nur zwischengespeicherte Dateien ({n} angeheftet)" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Offline – {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "{n} Problem – Anzeigen" msgstr[1] "{n} Probleme – Anzeigen" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Anmelden …" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Erneut anmelden …" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Proton Drive öffnen" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Ordner öffnen" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Synchronisierung fortsetzen" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Synchronisierung pausieren" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Verbinden" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Symbol im Benachrichtigungsfeld ausblenden" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Proton Drive beenden …" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Beenden" diff --git a/po/en_GB.po b/po/en_GB.po index 8e6783c..68e7a3b 100644 --- a/po/en_GB.po +++ b/po/en_GB.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: English (United Kingdom)\n" @@ -409,7 +409,7 @@ msgstr "Select a single item to rename it" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Not connected" @@ -420,7 +420,7 @@ msgstr "The Proton Drive service isn't running." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Connecting…" @@ -1267,7 +1267,7 @@ msgstr "Couldn't load albums" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1535,7 +1535,7 @@ msgid "Make available offline" msgstr "Make available offline" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Make online only" @@ -2716,7 +2716,7 @@ msgid "Sync paused until you resume" msgstr "Sync paused until you resume" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Sync paused" @@ -4755,125 +4755,133 @@ msgstr "{arrow} {name} — {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name} — {done} of {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} cached — no limit set" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "{used} of {limit} used" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Session expired" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · sign in again to sync" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Sign in again to sync" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Sign-in not saved" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Unlock your keyring, or the next reboot signs you out" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · resumes {time}" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Resumes {time}" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Until you resume" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "{n} change needs attention" msgstr[1] "{n} changes need attention" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Cached files only" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Syncing" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "Up to date" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "Proton Drive isn't running" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "Not responding" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "Proton Drive is taking too long to answer" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "later" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "at %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "%a %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "%e %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "No files kept offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "Right-click a file and choose “Make available offline”." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "Couldn't make it online only" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Show fewer" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -5939,25 +5947,29 @@ msgstr "Error: {error}" msgid "Proton Drive sent an unexpected reply" msgstr "Proton Drive sent an unexpected reply" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Session expired — sign in again" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Sign-in not saved — unlock your keyring" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "Up to date in {mountpoint} ({n} pinned)" msgstr[1] "Up to date in {mountpoint} ({n} pinned)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "Syncing — {queued} ({n} pinned)" msgstr[1] "Syncing — {queued} ({n} pinned)" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -5965,56 +5977,56 @@ msgstr[0] "Offline — cached files only ({n} pinned)" msgstr[1] "Offline — cached files only ({n} pinned)" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Offline — {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "{n} issue — View" msgstr[1] "{n} issues — View" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Sign In…" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Sign In Again…" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Open Proton Drive" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Open Folder" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Resume Sync" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Pause Sync" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Connect" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Hide Tray Icon" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Stop Proton Drive…" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Quit" diff --git a/po/es.po b/po/es.po index 6810b1c..e99dcff 100644 --- a/po/es.po +++ b/po/es.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: Spanish\n" @@ -411,7 +411,7 @@ msgstr "Seleccione un único elemento para renombrarlo" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Sin conexión" @@ -422,7 +422,7 @@ msgstr "El servicio de Proton Drive no está en ejecución." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Conectando…" @@ -1273,7 +1273,7 @@ msgstr "No se pudieron cargar los álbumes" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1541,7 +1541,7 @@ msgid "Make available offline" msgstr "Hacer disponible sin conexión" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Hacer solo en línea" @@ -2738,7 +2738,7 @@ msgid "Sync paused until you resume" msgstr "Sincronización en pausa hasta que la reanude" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Sincronización en pausa" @@ -4812,127 +4812,135 @@ msgstr "{arrow} {name}: {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name}: {done} de {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} en caché — sin límite" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "{used} de {limit} usados" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Sesión caducada" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · vuelva a iniciar sesión para sincronizar" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Vuelva a iniciar sesión para sincronizar" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Inicio de sesión no guardado" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Desbloquee el depósito de claves o el próximo reinicio cerrará su sesión" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · se reanuda {time}" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Se reanuda {time}" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Hasta que la reanude" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "{n} cambio requiere atención" msgstr[1] "{n} cambios requieren atención" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Sin conexión" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Solo archivos en caché" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Sincronizando" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "Actualizado" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "Proton Drive no está en ejecución" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "No responde" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "Proton Drive está tardando demasiado en responder" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "más tarde" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "a las %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "%a %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "%-d %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "No hay archivos disponibles sin conexión" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "" "Pulse con el botón derecho en un archivo y elija “Hacer disponible sin " "conexión”." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "No se pudo hacer solo en línea" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Mostrar menos" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -6015,25 +6023,29 @@ msgstr "Error: {error}" msgid "Proton Drive sent an unexpected reply" msgstr "Proton Drive envió una respuesta inesperada" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Sesión caducada: vuelva a iniciar sesión" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Inicio de sesión no guardado: desbloquee el depósito de claves" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "Actualizado en {mountpoint} ({n} fijado)" msgstr[1] "Actualizado en {mountpoint} ({n} fijados)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "Sincronizando: {queued} ({n} anclado)" msgstr[1] "Sincronizando: {queued} ({n} anclados)" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -6041,56 +6053,56 @@ msgstr[0] "Sin conexión: solo archivos en caché ({n} anclado)" msgstr[1] "Sin conexión: solo archivos en caché ({n} anclados)" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Sin conexión: {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "{n} problema: ver" msgstr[1] "{n} problemas: ver" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Iniciar sesión…" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Volver a iniciar sesión…" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Abrir Proton Drive" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Abrir carpeta" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Reanudar la sincronización" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Pausar la sincronización" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Conectar" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Ocultar el icono de la bandeja" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Detener Proton Drive…" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Salir" diff --git a/po/fr.po b/po/fr.po index 440e2e9..5dfaa8b 100644 --- a/po/fr.po +++ b/po/fr.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: French\n" @@ -413,7 +413,7 @@ msgstr "Sélectionnez un seul élément pour le renommer" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Non connecté" @@ -424,7 +424,7 @@ msgstr "Le service Proton Drive n’est pas en cours d’exécution." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Connexion…" @@ -1284,7 +1284,7 @@ msgstr "Impossible de charger les albums" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1552,7 +1552,7 @@ msgid "Make available offline" msgstr "Rendre disponible hors ligne" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Garder en ligne uniquement" @@ -2755,7 +2755,7 @@ msgid "Sync paused until you resume" msgstr "Synchronisation en pause jusqu’à la reprise" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Synchronisation en pause" @@ -4853,127 +4853,135 @@ msgstr "{arrow} {name} — {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name} — {done} sur {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} en cache — aucune limite définie" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "{used} utilisés sur {limit}" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Session expirée" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · reconnectez-vous pour synchroniser" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Reconnectez-vous pour synchroniser" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Connexion non enregistrée" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Déverrouillez votre trousseau, sinon le prochain redémarrage vous déconnectera" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · reprise {time}" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Reprise {time}" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Jusqu’à la reprise" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "{n} modification requiert votre attention" msgstr[1] "{n} modifications requièrent votre attention" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Hors ligne" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Fichiers en cache uniquement" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Synchronisation" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "À jour" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "Proton Drive n’est pas en cours d’exécution" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "Ne répond pas" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "Proton Drive met trop de temps à répondre" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "plus tard" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "à %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "%a %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "%e %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "Aucun fichier disponible hors ligne" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "" "Faites un clic droit sur un fichier et choisissez « Rendre disponible hors " "ligne »." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "Impossible de le garder en ligne uniquement" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Afficher moins" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -6060,25 +6068,29 @@ msgstr "Erreur : {error}" msgid "Proton Drive sent an unexpected reply" msgstr "Proton Drive a envoyé une réponse inattendue" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Session expirée — reconnectez-vous" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Connexion non enregistrée — déverrouillez votre trousseau" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "À jour dans {mountpoint} ({n} épinglé)" msgstr[1] "À jour dans {mountpoint} ({n} épinglés)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "Synchronisation — {queued} ({n} épinglé)" msgstr[1] "Synchronisation — {queued} ({n} épinglés)" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -6086,56 +6098,56 @@ msgstr[0] "Hors ligne — fichiers en cache uniquement ({n} épinglé)" msgstr[1] "Hors ligne — fichiers en cache uniquement ({n} épinglés)" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Hors ligne — {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "{n} problème — Afficher" msgstr[1] "{n} problèmes — Afficher" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Se connecter…" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Se reconnecter…" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Ouvrir Proton Drive" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Ouvrir le dossier" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Reprendre la synchronisation" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Mettre en pause la synchronisation" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Connecter" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Masquer l’icône de la zone de notification" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Arrêter Proton Drive…" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Quitter" diff --git a/po/it.po b/po/it.po index 30d0479..07ba03f 100644 --- a/po/it.po +++ b/po/it.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: Italian\n" @@ -409,7 +409,7 @@ msgstr "Seleziona un solo elemento per rinominarlo" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Non connesso" @@ -420,7 +420,7 @@ msgstr "Il servizio di Proton Drive non è in esecuzione." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Connessione…" @@ -1278,7 +1278,7 @@ msgstr "Impossibile caricare gli album" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1546,7 +1546,7 @@ msgid "Make available offline" msgstr "Rendi disponibile offline" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Rendi solo online" @@ -2753,7 +2753,7 @@ msgid "Sync paused until you resume" msgstr "Sincronizzazione in pausa finché non riprendi" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Sincronizzazione in pausa" @@ -4831,125 +4831,133 @@ msgstr "{arrow} {name}: {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name}: {done} di {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} in cache — nessun limite impostato" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "{used} di {limit} utilizzati" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Sessione scaduta" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · accedi di nuovo per sincronizzare" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Accedi di nuovo per sincronizzare" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Accesso non salvato" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Sblocca il portachiavi, altrimenti il prossimo riavvio ti disconnetterà" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · riprende {time}" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Riprende {time}" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Finché non riprendi" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "{n} modifica richiede attenzione" msgstr[1] "{n} modifiche richiedono attenzione" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Solo file in cache" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Sincronizzazione in corso" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "Aggiornato" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "Proton Drive non è in esecuzione" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "Non risponde" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "Proton Drive impiega troppo tempo a rispondere" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "più tardi" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "alle %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "%a %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "%e %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "Nessun file mantenuto offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "Fai clic destro su un file e scegli “Rendi disponibile offline”." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "Impossibile renderlo solo online" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Mostra meno" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -6037,25 +6045,29 @@ msgstr "Errore: {error}" msgid "Proton Drive sent an unexpected reply" msgstr "Proton Drive ha inviato una risposta inattesa" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Sessione scaduta: accedi di nuovo" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Accesso non salvato: sblocca il portachiavi" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "Aggiornato in {mountpoint} ({n} fissato)" msgstr[1] "Aggiornato in {mountpoint} ({n} fissati)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "Sincronizzazione: {queued} ({n} fissato)" msgstr[1] "Sincronizzazione: {queued} ({n} fissati)" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -6063,56 +6075,56 @@ msgstr[0] "Offline: solo file in cache ({n} fissato)" msgstr[1] "Offline: solo file in cache ({n} fissati)" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Offline: {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "{n} problema: visualizza" msgstr[1] "{n} problemi: visualizza" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Accedi…" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Accedi di nuovo…" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Apri Proton Drive" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Apri cartella" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Riprendi sincronizzazione" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Metti in pausa sincronizzazione" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Connetti" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Nascondi icona nell'area di notifica" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Arresta Proton Drive…" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Esci" diff --git a/po/nl.po b/po/nl.po index 36db83e..58056c6 100644 --- a/po/nl.po +++ b/po/nl.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: Dutch\n" @@ -410,7 +410,7 @@ msgstr "Selecteer één item om het te hernoemen" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Niet verbonden" @@ -421,7 +421,7 @@ msgstr "De Proton Drive-service draait niet." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Verbinden…" @@ -1274,7 +1274,7 @@ msgstr "Kon albums niet laden" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1542,7 +1542,7 @@ msgid "Make available offline" msgstr "Offline beschikbaar maken" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Alleen online maken" @@ -2743,7 +2743,7 @@ msgid "Sync paused until you resume" msgstr "Synchronisatie gepauzeerd totdat je hervat" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Synchronisatie gepauzeerd" @@ -4825,125 +4825,133 @@ msgstr "{arrow} {name} — {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name} — {done} van {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} in cache — geen limiet ingesteld" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "{used} van {limit} gebruikt" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Sessie verlopen" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · meld je opnieuw aan om te synchroniseren" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Meld je opnieuw aan om te synchroniseren" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Aanmelding niet opgeslagen" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Ontgrendel je sleutelbos, anders word je bij de volgende herstart afgemeld" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · hervat {time}" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Hervat {time}" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Totdat je hervat" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "{n} wijziging vereist aandacht" msgstr[1] "{n} wijzigingen vereisen aandacht" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Alleen bestanden in cache" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Synchroniseren" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "Bijgewerkt" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "Proton Drive draait niet" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "Reageert niet" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "Proton Drive doet er te lang over om te antwoorden" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "later" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "om %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "%a %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "%e %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "Geen offline bewaarde bestanden" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "Klik met rechts op een bestand en kies “Offline beschikbaar maken”." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "Kon het niet alleen online maken" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Minder tonen" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -6024,25 +6032,29 @@ msgstr "Fout: {error}" msgid "Proton Drive sent an unexpected reply" msgstr "Proton Drive gaf een onverwacht antwoord" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Sessie verlopen — meld je opnieuw aan" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Aanmelding niet opgeslagen — ontgrendel je sleutelbos" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "Bijgewerkt in {mountpoint} ({n} vastgezet)" msgstr[1] "Bijgewerkt in {mountpoint} ({n} vastgezet)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "Synchroniseren — {queued} ({n} vastgezet)" msgstr[1] "Synchroniseren — {queued} ({n} vastgezet)" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -6050,56 +6062,56 @@ msgstr[0] "Offline — alleen bestanden in cache ({n} vastgezet)" msgstr[1] "Offline — alleen bestanden in cache ({n} vastgezet)" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Offline — {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "{n} probleem — Bekijken" msgstr[1] "{n} problemen — Bekijken" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Aanmelden…" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Opnieuw aanmelden…" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Proton Drive openen" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Map openen" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Synchronisatie hervatten" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Synchronisatie pauzeren" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Verbinden" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Pictogram in systeemvak verbergen" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Proton Drive stoppen…" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Afsluiten" diff --git a/po/pdfs.pot b/po/pdfs.pot index 74e79cf..a6bd097 100644 --- a/po/pdfs.pot +++ b/po/pdfs.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 3.2.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -386,7 +386,7 @@ msgstr "" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "" @@ -397,7 +397,7 @@ msgstr "" #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "" @@ -1232,7 +1232,7 @@ msgstr "" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1500,7 +1500,7 @@ msgid "Make available offline" msgstr "" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "" @@ -2641,7 +2641,7 @@ msgid "Sync paused until you resume" msgstr "" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "" @@ -4618,125 +4618,133 @@ msgstr "" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "" msgstr[1] "" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -5782,25 +5790,29 @@ msgstr "" msgid "Proton Drive sent an unexpected reply" msgstr "" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "" msgstr[1] "" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "" msgstr[1] "" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -5808,56 +5820,56 @@ msgstr[0] "" msgstr[1] "" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "" msgstr[1] "" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "" diff --git a/po/pl.po b/po/pl.po index 8bf4ddf..81d53f2 100644 --- a/po/pl.po +++ b/po/pl.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: Polish\n" @@ -414,7 +414,7 @@ msgstr "Należy zaznaczyć jeden element, aby zmienić jego nazwę" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Brak połączenia" @@ -425,7 +425,7 @@ msgstr "Usługa Proton Drive nie jest uruchomiona." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Łączenie…" @@ -1305,7 +1305,7 @@ msgstr "Nie można wczytać albumów" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1578,7 +1578,7 @@ msgid "Make available offline" msgstr "Oznacz jako dostępne offline" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Oznacz jako tylko online" @@ -2802,7 +2802,7 @@ msgid "Sync paused until you resume" msgstr "Wstrzymano synchronizację do czasu wznowienia" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Wstrzymano synchronizację" @@ -4898,46 +4898,54 @@ msgstr "{arrow} {name} — {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name} — {done} z {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} w pamięci podręcznej — bez limitu" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "Użyto {used} z {limit}" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Sesja wygasła" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · zaloguj się ponownie, aby synchronizować" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Zaloguj się ponownie, aby synchronizować" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Logowanie nie zostało zapisane" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Odblokuj bazę kluczy, inaczej następne ponowne uruchomienie spowoduje wylogowanie" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · wznowienie {time}" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Wznowienie {time}" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Do czasu wznowienia" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" @@ -4945,81 +4953,81 @@ msgstr[0] "{n} zmiana wymaga uwagi" msgstr[1] "{n} zmiany wymagają uwagi" msgstr[2] "{n} zmian wymaga uwagi" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Tylko pliki w pamięci podręcznej" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Synchronizowanie" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "Aktualne" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "Proton Drive nie jest uruchomiony" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "Nie odpowiada" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "Proton Drive zbyt długo nie odpowiada" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "później" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "o %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "%a %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "%-d %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "Brak plików dostępnych offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "" "Należy kliknąć plik prawym przyciskiem myszy i wybrać „Oznacz jako dostępne " "offline”." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "Nie można oznaczyć jako tylko online" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Wyświetl mniej" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -6118,11 +6126,15 @@ msgstr "Błąd: {error}" msgid "Proton Drive sent an unexpected reply" msgstr "Proton Drive wysłał nieoczekiwaną odpowiedź" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Sesja wygasła — zaloguj się ponownie" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Logowanie nie zostało zapisane — odblokuj bazę kluczy" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" @@ -6130,7 +6142,7 @@ msgstr[0] "Aktualne w {mountpoint} ({n} przypięty)" msgstr[1] "Aktualne w {mountpoint} ({n} przypięte)" msgstr[2] "Aktualne w {mountpoint} ({n} przypiętych)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" @@ -6138,7 +6150,7 @@ msgstr[0] "Synchronizowanie — {queued} (przypięte: {n})" msgstr[1] "Synchronizowanie — {queued} (przypięte: {n})" msgstr[2] "Synchronizowanie — {queued} (przypięte: {n})" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -6147,13 +6159,13 @@ msgstr[1] "Offline — tylko pliki w pamięci podręcznej (przypięte: {n})" msgstr[2] "Offline — tylko pliki w pamięci podręcznej (przypięte: {n})" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Offline — {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" @@ -6161,43 +6173,43 @@ msgstr[0] "{n} problem — wyświetl" msgstr[1] "{n} problemy — wyświetl" msgstr[2] "{n} problemów — wyświetl" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Zaloguj się…" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Zaloguj się ponownie…" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Otwórz Proton Drive" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Otwórz katalog" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Wznów synchronizację" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Wstrzymaj synchronizację" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Połącz" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Ukryj ikonę w obszarze powiadamiania" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Zatrzymaj Proton Drive…" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Zakończ" diff --git a/po/pt_BR.po b/po/pt_BR.po index 2fababe..4e205d5 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-09 11:21+0200\n" +"POT-Creation-Date: 2026-10-09 12:17+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: Portuguese (Brazil)\n" @@ -409,7 +409,7 @@ msgstr "Selecione um único item para renomeá-lo" #: crates/pdfs-gui/src/app/main.rs:1662 #: crates/pdfs-gui/src/app/pages/albums.rs:85 #: crates/pdfs-gui/src/app/pages/places.rs:104 -#: crates/pdfs-gui/src/app/pages/status.rs:1412 +#: crates/pdfs-gui/src/app/pages/status.rs:1425 msgid "Not connected" msgstr "Não conectado" @@ -420,7 +420,7 @@ msgstr "O serviço do Proton Drive não está em execução." #: crates/pdfs-gui/src/app/main.rs:1671 #: crates/pdfs-gui/src/app/pages/status.rs:336 #: crates/pdfs-gui/src/hud/mod.rs:274 crates/pdfs-gui/src/hud/mod.rs:595 -#: crates/pdfs-gui/src/main.rs:481 +#: crates/pdfs-gui/src/main.rs:486 msgid "Connecting…" msgstr "Conectando…" @@ -1275,7 +1275,7 @@ msgstr "Não foi possível carregar os álbuns" #: crates/pdfs-gui/src/app/pages/shared.rs:999 #: crates/pdfs-gui/src/app/pages/shared.rs:1115 #: crates/pdfs-gui/src/app/pages/status.rs:761 -#: crates/pdfs-gui/src/app/pages/status.rs:1560 +#: crates/pdfs-gui/src/app/pages/status.rs:1573 #: crates/pdfs-gui/src/app/pages/takeout.rs:424 #: crates/pdfs-gui/src/app/pages/takeout.rs:617 #: crates/pdfs-gui/src/app/widgets/share_dialog.rs:449 @@ -1543,7 +1543,7 @@ msgid "Make available offline" msgstr "Disponibilizar offline" #: crates/pdfs-gui/src/app/pages/browser.rs:347 -#: crates/pdfs-gui/src/app/pages/status.rs:1536 +#: crates/pdfs-gui/src/app/pages/status.rs:1549 msgid "Make online only" msgstr "Tornar somente online" @@ -2739,7 +2739,7 @@ msgid "Sync paused until you resume" msgstr "Sincronização pausada até você retomar" #: crates/pdfs-gui/src/app/pages/locations.rs:552 -#: crates/pdfs-gui/src/app/pages/status.rs:1365 crates/pdfs-gui/src/main.rs:263 +#: crates/pdfs-gui/src/app/pages/status.rs:1378 crates/pdfs-gui/src/main.rs:268 msgid "Sync paused" msgstr "Sincronização pausada" @@ -4810,126 +4810,134 @@ msgstr "{arrow} {name} — {done} ({speed}/s)" msgid "{arrow} {name} — {done} of {total} ({speed}/s)" msgstr "{arrow} {name} — {done} de {total} ({speed}/s)" -#: crates/pdfs-gui/src/app/pages/status.rs:1276 +#: crates/pdfs-gui/src/app/pages/status.rs:1279 #, rust-format msgid "{used} cached — no limit set" msgstr "{used} em cache — sem limite definido" -#: crates/pdfs-gui/src/app/pages/status.rs:1282 +#: crates/pdfs-gui/src/app/pages/status.rs:1285 #, rust-format msgid "{used} of {limit} used" msgstr "{used} de {limit} usados" -#: crates/pdfs-gui/src/app/pages/status.rs:1351 +#: crates/pdfs-gui/src/app/pages/status.rs:1357 msgid "Session expired" msgstr "Sessão expirada" -#: crates/pdfs-gui/src/app/pages/status.rs:1356 +#: crates/pdfs-gui/src/app/pages/status.rs:1362 #, rust-format msgid "{queued} · sign in again to sync" msgstr "{queued} · entre novamente para sincronizar" -#: crates/pdfs-gui/src/app/pages/status.rs:1359 +#: crates/pdfs-gui/src/app/pages/status.rs:1364 msgid "Sign in again to sync" msgstr "Entre novamente para sincronizar" #: crates/pdfs-gui/src/app/pages/status.rs:1370 +msgid "Sign-in not saved" +msgstr "Login não salvo" + +#: crates/pdfs-gui/src/app/pages/status.rs:1372 +msgid "Unlock your keyring, or the next reboot signs you out" +msgstr "Desbloqueie o chaveiro, ou a próxima reinicialização encerrará sua sessão" + +#: crates/pdfs-gui/src/app/pages/status.rs:1383 #, rust-format msgid "{queued} · resumes {time}" msgstr "{queued} · retoma {time}" #. Translators: {time} is when syncing resumes, such as "at 14:30" #. or "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1375 +#: crates/pdfs-gui/src/app/pages/status.rs:1388 #, rust-format msgid "Resumes {time}" msgstr "Retoma {time}" -#: crates/pdfs-gui/src/app/pages/status.rs:1377 +#: crates/pdfs-gui/src/app/pages/status.rs:1390 msgid "Until you resume" msgstr "Até você retomar" -#: crates/pdfs-gui/src/app/pages/status.rs:1384 crates/pdfs-gui/src/main.rs:265 +#: crates/pdfs-gui/src/app/pages/status.rs:1397 crates/pdfs-gui/src/main.rs:270 #, rust-format msgid "{n} change needs attention" msgid_plural "{n} changes need attention" msgstr[0] "{n} alteração requer atenção" msgstr[1] "{n} alterações requerem atenção" -#: crates/pdfs-gui/src/app/pages/status.rs:1394 +#: crates/pdfs-gui/src/app/pages/status.rs:1407 #: crates/pdfs-gui/src/app/widgets/details.rs:108 msgctxt "state" msgid "Offline" msgstr "Offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1395 +#: crates/pdfs-gui/src/app/pages/status.rs:1408 msgid "Cached files only" msgstr "Somente arquivos em cache" -#: crates/pdfs-gui/src/app/pages/status.rs:1400 +#: crates/pdfs-gui/src/app/pages/status.rs:1413 msgctxt "state" msgid "Syncing" msgstr "Sincronizando" -#: crates/pdfs-gui/src/app/pages/status.rs:1406 +#: crates/pdfs-gui/src/app/pages/status.rs:1419 msgid "Up to date" msgstr "Atualizado" -#: crates/pdfs-gui/src/app/pages/status.rs:1413 +#: crates/pdfs-gui/src/app/pages/status.rs:1426 #: crates/pdfs-gui/src/hud/mod.rs:608 crates/pdfs-gui/src/hud/mod.rs:622 msgid "Proton Drive isn't running" msgstr "O Proton Drive não está em execução" -#: crates/pdfs-gui/src/app/pages/status.rs:1418 +#: crates/pdfs-gui/src/app/pages/status.rs:1431 msgid "Not responding" msgstr "Não está respondendo" -#: crates/pdfs-gui/src/app/pages/status.rs:1419 +#: crates/pdfs-gui/src/app/pages/status.rs:1432 msgid "Proton Drive is taking too long to answer" msgstr "O Proton Drive está demorando demais para responder" #. Translators: stands in for a time that could not be read, as in #. "Resumes later". -#: crates/pdfs-gui/src/app/pages/status.rs:1458 -#: crates/pdfs-gui/src/app/pages/status.rs:1473 +#: crates/pdfs-gui/src/app/pages/status.rs:1471 +#: crates/pdfs-gui/src/app/pages/status.rs:1486 msgid "later" msgstr "mais tarde" #. Translators: strftime format for a time later today, such as "at 14:30". -#: crates/pdfs-gui/src/app/pages/status.rs:1462 +#: crates/pdfs-gui/src/app/pages/status.rs:1475 msgid "at %H:%M" msgstr "às %H:%M" #. Translators: strftime format for a time within the next week, such as #. "Tue 09:00". -#: crates/pdfs-gui/src/app/pages/status.rs:1466 +#: crates/pdfs-gui/src/app/pages/status.rs:1479 msgid "%a %H:%M" msgstr "%a às %H:%M" #. Translators: strftime format for a date further ahead, such as "23 Sep". -#: crates/pdfs-gui/src/app/pages/status.rs:1469 +#: crates/pdfs-gui/src/app/pages/status.rs:1482 msgid "%e %b" msgstr "em %-d de %b" -#: crates/pdfs-gui/src/app/pages/status.rs:1499 +#: crates/pdfs-gui/src/app/pages/status.rs:1512 msgid "No files kept offline" msgstr "Nenhum arquivo mantido offline" -#: crates/pdfs-gui/src/app/pages/status.rs:1501 +#: crates/pdfs-gui/src/app/pages/status.rs:1514 msgid "Right-click a file and choose “Make available offline”." msgstr "" "Clique com o botão direito em um arquivo e escolha “Disponibilizar offline”." -#: crates/pdfs-gui/src/app/pages/status.rs:1552 -#: crates/pdfs-gui/src/app/pages/status.rs:1559 +#: crates/pdfs-gui/src/app/pages/status.rs:1565 +#: crates/pdfs-gui/src/app/pages/status.rs:1572 msgid "Couldn't make it online only" msgstr "Não foi possível torná-lo somente online" -#: crates/pdfs-gui/src/app/pages/status.rs:1576 +#: crates/pdfs-gui/src/app/pages/status.rs:1589 msgid "Show fewer" msgstr "Mostrar menos" -#: crates/pdfs-gui/src/app/pages/status.rs:1579 +#: crates/pdfs-gui/src/app/pages/status.rs:1592 #, rust-format msgid "Show all {n} file" msgid_plural "Show all {n} files" @@ -6012,25 +6020,29 @@ msgstr "Erro: {error}" msgid "Proton Drive sent an unexpected reply" msgstr "O Proton Drive enviou uma resposta inesperada" -#: crates/pdfs-gui/src/main.rs:262 +#: crates/pdfs-gui/src/main.rs:265 msgid "Session expired — sign in again" msgstr "Sessão expirada — entre novamente" -#: crates/pdfs-gui/src/main.rs:272 +#: crates/pdfs-gui/src/main.rs:267 +msgid "Sign-in not saved — unlock your keyring" +msgstr "Login não salvo — desbloqueie o chaveiro" + +#: crates/pdfs-gui/src/main.rs:277 #, rust-format msgid "Up to date in {mountpoint} ({n} pinned)" msgid_plural "Up to date in {mountpoint} ({n} pinned)" msgstr[0] "Atualizado em {mountpoint} ({n} fixado)" msgstr[1] "Atualizado em {mountpoint} ({n} fixados)" -#: crates/pdfs-gui/src/main.rs:279 +#: crates/pdfs-gui/src/main.rs:284 #, rust-format msgid "Syncing — {queued} ({n} pinned)" msgid_plural "Syncing — {queued} ({n} pinned)" msgstr[0] "Sincronizando — {queued} ({n} fixado)" msgstr[1] "Sincronizando — {queued} ({n} fixados)" -#: crates/pdfs-gui/src/main.rs:286 +#: crates/pdfs-gui/src/main.rs:291 #, rust-format msgid "Offline — cached files only ({n} pinned)" msgid_plural "Offline — cached files only ({n} pinned)" @@ -6038,56 +6050,56 @@ msgstr[0] "Offline — somente arquivos em cache ({n} fixado)" msgstr[1] "Offline — somente arquivos em cache ({n} fixados)" #. Translators: {queued} is a queue summary such as "3 uploads queued". -#: crates/pdfs-gui/src/main.rs:292 +#: crates/pdfs-gui/src/main.rs:297 #, rust-format msgid "Offline — {queued}" msgstr "Offline — {queued}" #. Translators: a menu item that opens the list of failing changes. -#: crates/pdfs-gui/src/main.rs:330 +#: crates/pdfs-gui/src/main.rs:335 #, rust-format msgid "{n} issue — View" msgid_plural "{n} issues — View" msgstr[0] "{n} problema — Ver" msgstr[1] "{n} problemas — Ver" -#: crates/pdfs-gui/src/main.rs:436 +#: crates/pdfs-gui/src/main.rs:441 msgid "Sign In…" msgstr "Entrar…" -#: crates/pdfs-gui/src/main.rs:437 +#: crates/pdfs-gui/src/main.rs:442 msgid "Sign In Again…" msgstr "Entrar novamente…" -#: crates/pdfs-gui/src/main.rs:440 +#: crates/pdfs-gui/src/main.rs:445 msgid "Open Proton Drive" msgstr "Abrir o Proton Drive" -#: crates/pdfs-gui/src/main.rs:446 +#: crates/pdfs-gui/src/main.rs:451 msgid "Open Folder" msgstr "Abrir pasta" -#: crates/pdfs-gui/src/main.rs:458 +#: crates/pdfs-gui/src/main.rs:463 msgid "Resume Sync" msgstr "Retomar sincronização" -#: crates/pdfs-gui/src/main.rs:460 +#: crates/pdfs-gui/src/main.rs:465 msgid "Pause Sync" msgstr "Pausar sincronização" -#: crates/pdfs-gui/src/main.rs:479 +#: crates/pdfs-gui/src/main.rs:484 msgid "Connect" msgstr "Conectar" -#: crates/pdfs-gui/src/main.rs:486 +#: crates/pdfs-gui/src/main.rs:491 msgid "Hide Tray Icon" msgstr "Ocultar ícone da bandeja" -#: crates/pdfs-gui/src/main.rs:490 +#: crates/pdfs-gui/src/main.rs:495 msgid "Stop Proton Drive…" msgstr "Parar o Proton Drive…" -#: crates/pdfs-gui/src/main.rs:494 +#: crates/pdfs-gui/src/main.rs:499 msgid "Quit" msgstr "Sair" From 43031ecf9949d9dfa71497dc8791713af40a7d73 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 9 Oct 2026 12:34:02 +0200 Subject: [PATCH 8/8] chore(deps) updated sdk to 0.8.2 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 4 ++-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1eb4a04..b2b6b76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,7 +166,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1254,7 +1254,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1896,7 +1896,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps 9.0.0", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3100,7 +3100,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3778,9 +3778,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21" +checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da" dependencies = [ "async-trait", "base64 0.23.1", @@ -3805,9 +3805,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81" +checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619" dependencies = [ "aes-gcm 0.11.1", "async-trait", @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4374,7 +4374,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4431,7 +4431,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4764,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4932,10 +4932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5266,7 +5266,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5590,7 +5590,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7092855..d5259ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"] categories = ["filesystem", "command-line-utilities"] [workspace.dependencies] -proton-sdk = { version = "0.8.1" } -proton-drive-rs = { version = "0.8.1" } +proton-sdk = { version = "0.8.2" } +proton-drive-rs = { version = "0.8.2" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" }