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