mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
6363125fec
commit
bf43476e88
24 files changed
+2915
-2416
No files matched your search
+26
-13
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<Option<SyncIssue>> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<std::collections::HashSet<u64>>,
|
||||
/// 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());
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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<Ui>, 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");
|
||||
|
||||
@@ -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<bool>,
|
||||
/// 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<Ui>, 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<Ui>) {
|
||||
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.
|
||||
|
||||
@@ -836,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||
.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>) {
|
||||
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<Ui>, status: Response) {
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -1231,7 +1239,9 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, 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<Ui>) {
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<Ui>, 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<Ui>, 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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+289
-264
File diff suppressed because it is too large.
Load diff
+293
-268
File diff suppressed because it is too large.
Load diff
+289
-264
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user