fix(fuse,core,cli,gui): show a refused session as expired, not offline

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.
This commit is contained in:
nvrl committed 2026-10-09 11:27:09 +02:00
1 parent 6363125fec
commit bf43476e88
24 files changed
+2915 -2416

No files matched your search

+5
View File
@@ -1959,6 +1959,11 @@ pub enum Response {
/// resumed, or not paused.
#[serde(default)]
paused_until: Option<i64>,
/// 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 },
+28
View File
@@ -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<T> = std::result::Result<T, CoreError>;
@@ -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.