Files
proton-drive-linux/crates/pdfs-gui/src/app/pages/status.rs
T
nvrl bf43476e88 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.
2026-10-09 11:27:09 +02:00

1627 lines
63 KiB
Rust

use crate::*;
pub(crate) struct StatusState {
/// Whether a [`Request::Status`] round-trip is already in flight, so the 2s
/// refresh tick doesn't pile worker threads up on a slow/wedged daemon.
pub(crate) status_inflight: Cell<bool>,
/// Guards the [`Request::GetQueueStatus`] poll the same way: at most one
/// in-flight at a time so a wedged daemon can't stack worker threads.
pub(crate) transfers_inflight: Cell<bool>,
/// Transfers group (on the Sync page) + its current rows, hidden when no
/// transfer is in flight.
pub(crate) transfers_group: adw::PreferencesGroup,
pub(crate) transfer_rows: RefCell<Vec<TransferRow>>,
/// The Preferences dialog, built once and presented on demand.
pub(crate) prefs: adw::PreferencesDialog,
/// Sidebar footer: signed-in identity.
pub(crate) account_name: gtk4::Label,
pub(crate) avatar: adw::Avatar,
/// Sidebar footer: the one-line sync state. The mount is driven by the
/// systemd user service, not by the user — so this only reports.
pub(crate) status_icon: gtk4::Image,
/// Stands in for [`Self::status_icon`] until the first status arrives.
pub(crate) status_spinner: Spinner,
pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label,
pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label,
/// Sidebar footer: account quota bar/label. Hidden until the first reading.
pub(crate) quota_box: gtk4::Box,
pub(crate) quota_bar: gtk4::ProgressBar,
pub(crate) quota_label: gtk4::Label,
/// One `AccountQuota` in flight at a time, and when it last succeeded — quota
/// barely moves, so [`refresh_quota`] refetches on a long TTL, not every tick.
pub(crate) quota_inflight: Cell<bool>,
pub(crate) quota_checked_at: Cell<Option<Instant>>,
/// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic
/// sets so reflecting the systemd state doesn't fire the toggle handler.
pub(crate) autostart_row: adw::SwitchRow,
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
pub(crate) budget_row: adw::SpinRow,
/// Bandwidth caps (MiB/s, `0` = none). Populated once from config; an edit
/// to either sends both in one `SetBandwidthLimits`. Guarded by
/// [`Self::settings_suppress`].
pub(crate) upload_limit_row: adw::SpinRow,
pub(crate) download_limit_row: adw::SpinRow,
/// Shows where the primary mount lives; its Change button picks a new one.
pub(crate) mountpoint_row: adw::ActionRow,
/// "Proton theme" toggle. Guarded by [`Self::settings_suppress`].
pub(crate) theme_row: adw::SwitchRow,
/// Street map on the Places map. Guarded by [`Self::settings_suppress`].
pub(crate) online_map_row: adw::SwitchRow,
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
pub(crate) tray_row: adw::SwitchRow,
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
/// [`Self::settings_suppress`].
pub(crate) language_row: adw::ComboRow,
/// Set while a settings widget is being populated programmatically, so its
/// change handler skips the IPC/systemd side effect.
pub(crate) settings_suppress: Cell<bool>,
/// Pending debounce for the cache-budget editor. Each `+` click is a value
/// change, and firing a `SetCacheBudget` (and a toast) per click means a
/// stack of toasts and a series of caps the user never asked to apply — so
/// only the value they settle on is sent.
pub(crate) budget_source: RefCell<Option<glib::SourceId>>,
/// Pending debounce for the bandwidth editors, as for the cache budget.
pub(crate) limit_source: RefCell<Option<glib::SourceId>>,
pub(crate) pins_group: adw::PreferencesGroup,
/// Whether the pin list is showing every pin or only the first
/// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below
/// it off the end of the Storage page.
pub(crate) pins_expanded: Cell<bool>,
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
/// diff against them and only rebuild when the pin set actually changes.
pub(crate) pin_rows: RefCell<Vec<PinRow>>,
/// The pin paths last rendered, the diff baseline for [`repaint_pins`].
/// `None` = nothing built yet; `Some(empty)` = the placeholder is shown.
pub(crate) pins_state: RefCell<Option<Vec<String>>>,
/// The mount state the *last* desktop notification reported, so a flap only
/// notifies on the edge. `None` until the first status reply, so a cold start
/// doesn't announce "disconnected" before the service has had a chance to come
/// up.
pub(crate) notified_mounted: Cell<Option<bool>>,
/// How many transfers were in flight on the previous poll. A drop to zero is
/// what "sync complete" means; there's no completion event on the wire.
pub(crate) active_transfers: Cell<usize>,
/// The run of transfers since the count last left zero, for deciding
/// whether its end is worth a "Sync complete" notification.
pub(crate) transfer_batch: RefCell<Option<TransferBatch>>,
}
/// One run of transfers, from the first file to the count falling back to 0.
pub(crate) struct TransferBatch {
started: Instant,
/// Every file seen in flight during the run, so the notification can count
/// files rather than the handful still moving on the last poll.
names: HashSet<String>,
}
/// A run of transfers earns a "Sync complete" notification only when it moved
/// at least this many files or lasted at least [`SYNC_NOTIFY_MIN_DURATION`].
/// Saving one document at a time would otherwise notify on every save.
const SYNC_NOTIFY_MIN_FILES: usize = 5;
const SYNC_NOTIFY_MIN_DURATION: Duration = Duration::from_secs(30);
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
pub(crate) struct PinRow {
pub(crate) row: gtk4::ListBoxRow,
/// The unpin button, absent on the placeholder row.
pub(crate) unpin: Option<gtk4::Button>,
}
/// One rendered row in the Activity group: a description over a progress bar.
/// Retained so [`repaint_transfers`] can update the bar and label in place each
/// tick when the active set is unchanged, instead of rebuilding.
pub(crate) struct TransferRow {
pub(crate) row: adw::PreferencesRow,
pub(crate) label: gtk4::Label,
pub(crate) bar: gtk4::ProgressBar,
}
/// What one Activity row should say this tick, and how far along it is —
/// `None` meaning "no total known", which the bar shows by pulsing. Jobs and
/// transfers both render to this, so the group is one list in the order the
/// daemon reports: the jobs that frame the work, then the files moving under it.
pub(crate) struct ActivityLine {
pub(crate) text: String,
pub(crate) fraction: Option<f64>,
}
/// Widgets the Preferences dialog and the sidebar footer hand back for the
/// refresh loop and action wiring.
pub(crate) struct MainWidgets {
pub(crate) prefs: adw::PreferencesDialog,
/// The sidebar footer: sync status, quota, account.
pub(crate) footer: gtk4::Box,
/// Opens the Sync page; the footer's status strip is this button.
pub(crate) status_button: gtk4::Button,
pub(crate) account_name: gtk4::Label,
pub(crate) avatar: adw::Avatar,
pub(crate) status_icon: gtk4::Image,
pub(crate) status_spinner: Spinner,
pub(crate) status_title: gtk4::Label,
pub(crate) status_detail: gtk4::Label,
pub(crate) quota_box: gtk4::Box,
pub(crate) quota_bar: gtk4::ProgressBar,
pub(crate) quota_label: gtk4::Label,
pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label,
pub(crate) pins_group: adw::PreferencesGroup,
/// "Start on login" toggle, reflecting the systemd unit's enabled state.
pub(crate) autostart_row: adw::SwitchRow,
/// Cache soft-cap editor, in GiB; `0` = unlimited.
pub(crate) budget_row: adw::SpinRow,
/// Upload and download caps, in MiB/s; `0` = unlimited.
pub(crate) upload_limit_row: adw::SpinRow,
pub(crate) download_limit_row: adw::SpinRow,
/// Purges all unpinned cached content.
pub(crate) purge_button: gtk4::Button,
/// Shows the active mountpoint; its suffix button picks a new one.
pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button,
pub(crate) theme_row: adw::SwitchRow,
pub(crate) online_map_row: adw::SwitchRow,
pub(crate) tray_row: adw::SwitchRow,
pub(crate) language_row: adw::ComboRow,
}
/// Build the two surfaces that replaced the old Settings page:
///
/// - the Preferences dialog (General: start on login, mount location,
/// appearance; Storage: cache usage, budget, clear, offline files), and
/// - the sidebar footer (sync status strip, account quota, account menu).
///
/// Live state (transfers, the folder list) lives on the Sync page instead, and
/// the app version and user agent in About → Troubleshooting.
pub(crate) fn build_main_page() -> MainWidgets {
// ---- Preferences: General
let startup_group = adw::PreferencesGroup::builder()
.title(gettext("Startup"))
.build();
let autostart_row = adw::SwitchRow::builder()
.title(gettext("Start on login"))
.subtitle(gettext(
"Connect Proton Drive automatically when you log in",
))
.build();
startup_group.add(&autostart_row);
let location_group = adw::PreferencesGroup::builder()
.title(gettext("Location"))
.build();
let mountpoint_row = adw::ActionRow::builder()
.title(gettext("Proton Drive folder"))
.subtitle("—")
.build();
mountpoint_row.add_css_class("property");
let mountpoint_button = gtk4::Button::builder()
.label(gettext("Change…"))
.tooltip_text(gettext("Choose where My files appears on this computer"))
.valign(gtk4::Align::Center)
.build();
mountpoint_button.add_css_class("flat");
mountpoint_row.add_suffix(&mountpoint_button);
location_group.add(&mountpoint_row);
// Bandwidth caps in MiB/s. 0 = unlimited; the smallest cap is one 0.5 step,
// since anything slower would stall a single block for most of a minute.
let network_group = adw::PreferencesGroup::builder()
.title(gettext("Network"))
.description(gettext(
"Limit how fast files move, shared by every transfer. 0 means no limit.",
))
.build();
let limit_row = |title: &str| {
adw::SpinRow::builder()
.title(title)
.adjustment(&gtk4::Adjustment::new(0.0, 0.0, 1000.0, 0.5, 5.0, 0.0))
.digits(1)
.build()
};
let upload_limit_row = limit_row(&gettext("Upload limit (MiB/s)"));
let download_limit_row = limit_row(&gettext("Download limit (MiB/s)"));
network_group.add(&upload_limit_row);
network_group.add(&download_limit_row);
let appearance_group = adw::PreferencesGroup::builder()
.title(gettext("Appearance"))
.build();
let theme_row = adw::SwitchRow::builder()
.title(gettext("Proton theme"))
.subtitle(gettext(
"Use Proton's colors instead of the system theme and accent color",
))
.build();
appearance_group.add(&theme_row);
let online_map_row = adw::SwitchRow::builder()
.title(gettext("Street map for Places"))
.subtitle(gettext(
"Show streets and town names from OpenFreeMap. It learns which parts of the world you look at.",
))
.build();
appearance_group.add(&online_map_row);
let tray_row = adw::SwitchRow::builder()
.title(gettext("Show tray icon"))
.subtitle(gettext("Sync status and quick actions in the panel"))
.build();
appearance_group.add(&tray_row);
let language_row = build_language_row();
appearance_group.add(&language_row);
let general = adw::PreferencesPage::builder()
.title(gettext("General"))
.icon_name("preferences-system-symbolic")
.build();
general.add(&startup_group);
general.add(&location_group);
general.add(&network_group);
general.add(&appearance_group);
// ---- Preferences: Storage
let storage_group = adw::PreferencesGroup::builder()
.title(gettext("Cache"))
.description(gettext(
"Pinned and recently opened files, kept on this computer.",
))
.build();
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
storage_box.set_margin_top(12);
storage_box.set_margin_bottom(12);
storage_box.set_margin_start(12);
storage_box.set_margin_end(12);
let cache_bar = gtk4::ProgressBar::new();
let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
cache_label.add_css_class("dim-label");
storage_box.append(&cache_bar);
storage_box.append(&cache_label);
let usage_row = adw::PreferencesRow::builder()
.activatable(false)
.child(&storage_box)
.build();
storage_group.add(&usage_row);
// Cache budget, expressed in GiB. 0 = unlimited; the daemon applies a 0 cap
// as "no eviction". Step in 0.5 GiB; the upper bound is generous.
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
let budget_row = adw::SpinRow::builder()
.title(gettext("Cache size limit (GiB)"))
.subtitle(gettext(
"Older files are removed past this size; 0 means no limit",
))
.adjustment(&budget_adj)
.digits(1)
.build();
storage_group.add(&budget_row);
let purge_row = adw::ActionRow::builder()
.title(gettext("Clear cache"))
.subtitle(gettext("Remove cached copies. Files kept offline stay."))
.build();
let purge_button = gtk4::Button::builder()
.label(gettext("Clear…"))
.valign(gtk4::Align::Center)
.build();
purge_button.add_css_class("flat");
purge_row.add_suffix(&purge_button);
storage_group.add(&purge_row);
// Pins group: filled in by refresh.
let pins_group = adw::PreferencesGroup::builder()
.title(gettext("Available offline"))
.description(gettext(
"Files kept on this computer, even without a connection.",
))
.build();
let storage = adw::PreferencesPage::builder()
.title(gettext("Storage"))
.icon_name("drive-harddisk-symbolic")
.build();
storage.add(&storage_group);
storage.add(&pins_group);
let prefs = adw::PreferencesDialog::builder()
.search_enabled(false)
.build();
prefs.add(&general);
prefs.add(&storage);
// ---- Sidebar footer
// Until the first status arrives there is no state to show, only that the
// app is still asking.
let status_icon = gtk4::Image::builder().visible(false).build();
let status_spinner = spinner();
let status_title = gtk4::Label::builder()
.label(gettext("Connecting…"))
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
status_title.add_css_class("heading");
let status_detail = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.visible(false)
.build();
status_detail.add_css_class("caption");
status_detail.add_css_class("dim-label");
let status_text = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
status_text.set_hexpand(true);
status_text.append(&status_title);
status_text.append(&status_detail);
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
status_content.append(&status_spinner);
status_content.append(&status_icon);
status_content.append(&status_text);
let status_button = gtk4::Button::builder()
.child(&status_content)
.tooltip_text(gettext("Show sync status"))
.build();
status_button.add_css_class("flat");
status_button.add_css_class("sidebar-status");
let quota_bar = gtk4::ProgressBar::new();
let quota_label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
quota_label.add_css_class("caption");
quota_label.add_css_class("dim-label");
let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
quota_box.add_css_class("sidebar-quota");
quota_box.set_margin_start(8);
quota_box.set_margin_end(8);
quota_box.set_visible(false);
quota_box.append(&quota_bar);
quota_box.append(&quota_label);
let avatar = adw::Avatar::new(28, None, true);
let account_name = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
// The app's one menu, on the account row the way Proton's own apps keep it:
// app-wide entries first, the account's last. The whole row opens it, and
// F10 too, as the primary menu.
let app_section = gio::Menu::new();
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
app_section.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
let account_section = gio::Menu::new();
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
let account_menu = gio::Menu::new();
account_menu.append_section(None, &app_section);
account_menu.append_section(None, &account_section);
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.append(&avatar);
row.append(&account_name);
row.append(&gtk4::Image::from_icon_name("view-more-symbolic"));
let account = gtk4::MenuButton::builder()
.child(&row)
.tooltip_text(gettext("Main menu"))
.menu_model(&account_menu)
.direction(gtk4::ArrowType::Up)
.primary(true)
.build();
account.add_css_class("flat");
account.add_css_class("sidebar-account");
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
footer.add_css_class("sidebar-footer");
footer.append(&status_button);
footer.append(&quota_box);
footer.append(&account);
MainWidgets {
prefs,
footer,
status_button,
account_name,
avatar,
status_icon,
status_spinner,
status_title,
status_detail,
quota_box,
quota_bar,
quota_label,
cache_bar,
cache_label,
pins_group,
autostart_row,
budget_row,
upload_limit_row,
download_limit_row,
purge_button,
mountpoint_row,
mountpoint_button,
theme_row,
online_map_row,
tray_row,
language_row,
}
}
/// The Preferences language picker. Row 0 follows the system locale; the rest
/// are [`i18n::LANGUAGES`] in order, under their own names.
fn build_language_row() -> adw::ComboRow {
let names = gtk4::StringList::new(&[]);
names.append(&gettext("System Default"));
for (_, name) in i18n::LANGUAGES {
names.append(name);
}
adw::ComboRow::builder()
.title(gettext("Language"))
.subtitle(gettext("Takes effect the next time Proton Drive starts"))
.model(&names)
.build()
}
/// The picker row for a saved language code; unknown codes show as the system
/// default, which is also how [`i18n::init`] treats them.
pub(crate) fn language_index(code: Option<&str>) -> u32 {
code.and_then(|code| i18n::LANGUAGES.iter().position(|(known, _)| *known == code))
.map_or(0, |at| at as u32 + 1)
}
/// The language code a picker row stands for; `None` is the system default.
pub(crate) fn language_at(index: u32) -> Option<&'static str> {
let at = usize::try_from(index).ok()?.checked_sub(1)?;
i18n::LANGUAGES.get(at).map(|(code, _)| *code)
}
/// The Sync page's live-transfers group, hidden until the refresh loop sees an
/// in-flight transfer from [`Request::GetQueueStatus`].
pub(crate) fn build_transfers_group() -> adw::PreferencesGroup {
adw::PreferencesGroup::builder()
.title(gettext("Transfers"))
.description(gettext("Files moving to and from Proton Drive."))
.visible(false)
.build()
}
/// How many pins the Storage preferences list before collapsing the rest behind a
/// "Show all" row. Enough to recognise the list at a glance; short enough that
/// the groups below it stay reachable.
pub(crate) const PINS_COLLAPSED: usize = 6;
/// How long the cache-budget editor waits after the last change before applying
/// it, so a run of `+` clicks is one request rather than one per click.
pub(crate) const BUDGET_DEBOUNCE: Duration = Duration::from_millis(600);
/// Bytes per GiB, for the cache-budget editor's unit conversion.
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
/// Bytes per MiB, for the bandwidth editors' unit conversion.
pub(crate) const MIB: f64 = 1024.0 * 1024.0;
/// Wire the Preferences controls: the cache-budget editor, the purge button, the
/// start-on-login switch, the mountpoint chooser and the theme toggle. Initial widget state is
/// read once from config / systemd here (the refresh loop owns only the live
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
/// programmatic populate so the change handlers don't fire on it.
pub(crate) fn wire_settings(
ui: &Rc<Ui>,
purge_button: &gtk4::Button,
mountpoint_button: &gtk4::Button,
) {
let config = ui.dirs.load_config();
// Populate from persisted config + the systemd unit state, suppressed.
ui.status.settings_suppress.set(true);
ui.status
.budget_row
.set_value(config.resolved_cache_budget() as f64 / GIB);
ui.status
.upload_limit_row
.set_value(config.upload_limit.unwrap_or(0) as f64 / MIB);
ui.status
.download_limit_row
.set_value(config.download_limit.unwrap_or(0) as f64 / MIB);
ui.status
.mountpoint_row
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
// systemctl can take a while, so ask off the main thread and fill the
// switch in when it answers, without firing its change handler.
let ui_auto = ui.clone();
glib::spawn_future_local(async move {
let enabled = gio::spawn_blocking(service::is_enabled)
.await
.unwrap_or(false);
let suppress = ui_auto.status.settings_suppress.replace(true);
ui_auto.status.autostart_row.set_active(enabled);
ui_auto.status.settings_suppress.set(suppress);
});
ui.status
.theme_row
.set_active(config.proton_theme.unwrap_or(false));
ui.status.online_map_row.set_active(config.online_map);
ui.status.tray_row.set_active(!config.tray_hidden);
ui.status
.language_row
.set_selected(language_index(config.language.as_deref()));
ui.status.settings_suppress.set(false);
// Cache budget: a user edit applies the new soft cap on the daemon (which
// also persists it to config). 0 GiB = unlimited.
let ui_budget = ui.clone();
ui.status.budget_row.connect_value_notify(move |row| {
if ui_budget.status.settings_suppress.get() {
return;
}
// Replace any pending apply, so only the value the user stops on is sent.
if let Some(src) = ui_budget.status.budget_source.borrow_mut().take() {
src.remove();
}
let bytes = (row.value() * GIB).round() as u64;
let ui_fire = ui_budget.clone();
let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || {
ui_fire.status.budget_source.borrow_mut().take();
settings_request(
&ui_fire,
Request::SetCacheBudget { bytes },
gettext("Cache budget updated"),
gettext("Couldn't set cache budget"),
);
});
*ui_budget.status.budget_source.borrow_mut() = Some(src);
});
// Bandwidth: either editor sends both caps, since the daemon sets them as a
// pair. Debounced like the budget.
for row in [&ui.status.upload_limit_row, &ui.status.download_limit_row] {
let ui_limit = ui.clone();
row.connect_value_notify(move |_| {
if ui_limit.status.settings_suppress.get() {
return;
}
if let Some(src) = ui_limit.status.limit_source.borrow_mut().take() {
src.remove();
}
let ui_fire = ui_limit.clone();
let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || {
ui_fire.status.limit_source.borrow_mut().take();
let upload = (ui_fire.status.upload_limit_row.value() * MIB).round() as u64;
let download = (ui_fire.status.download_limit_row.value() * MIB).round() as u64;
settings_request(
&ui_fire,
Request::SetBandwidthLimits { upload, download },
gettext("Bandwidth limits updated"),
gettext("Couldn't set bandwidth limits"),
);
});
*ui_limit.status.limit_source.borrow_mut() = Some(src);
});
}
// Purge: confirm, then drop all unpinned cached content via the daemon.
let ui_purge = ui.clone();
purge_button.connect_clicked(move |_| {
let ui = ui_purge.clone();
let dialog = adw::AlertDialog::builder()
.heading(gettext("Clear Cache?"))
.body(gettext("Cached copies are removed from this computer and download again when you open them. Files kept available offline stay."))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("purge", &gettext("Clear Cache"));
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
dialog.connect_response(None, move |_, resp| {
if resp == "purge" {
settings_request(
&ui,
Request::PurgeCache,
gettext("Cache cleared"),
gettext("Couldn't clear the cache"),
);
}
});
dialog.present(Some(&ui_purge.status.prefs));
});
// Start on login: enable/disable the systemd unit without stopping a live
// mount (the user can disconnect separately).
let ui_auto = ui.clone();
ui.status.autostart_row.connect_active_notify(move |row| {
if ui_auto.status.settings_suppress.get() {
return;
}
let enable = row.is_active();
gio::spawn_blocking(move || {
if enable {
service::enable();
} else {
service::disable();
}
});
});
let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
// Theme: saved with the rest of the config, applied right away.
let ui_theme = ui.clone();
ui.status.theme_row.connect_active_notify(move |row| {
if ui_theme.status.settings_suppress.get() {
return;
}
let on = row.is_active();
set_proton_theme(on);
// Folders swap their icon with the theme; have every row bound again.
for list in file_lists(&ui_theme) {
let rows = list.model.n_items();
list.model.items_changed(0, rows, rows);
}
let mut config = ui_theme.dirs.load_config();
config.proton_theme = Some(on);
if let Err(e) = ui_theme.dirs.save_config(&config) {
toast_error(
&ui_theme,
&gettext("Couldn't save the theme"),
&e.to_string(),
);
}
});
// Street map: saved, and the Places map switches over right away.
let ui_map = ui.clone();
ui.status.online_map_row.connect_active_notify(move |row| {
if ui_map.status.settings_suppress.get() {
return;
}
let on = row.is_active();
ui_map
.gallery
.places_map
.set_online(on, ui_map.dirs.cache_dir());
let mut config = ui_map.dirs.load_config();
config.online_map = on;
if let Err(e) = ui_map.dirs.save_config(&config) {
toast_error(
&ui_map,
&gettext("Couldn't save the map setting"),
&e.to_string(),
);
}
});
// Tray icon: the tray reads the same flag at start, so hiding it here also
// keeps the login autostart from bringing it back.
let ui_tray = ui.clone();
ui.status.tray_row.connect_active_notify(move |row| {
if ui_tray.status.settings_suppress.get() {
return;
}
let show = row.is_active();
let mut config = ui_tray.dirs.load_config();
config.tray_hidden = !show;
if let Err(e) = ui_tray.dirs.save_config(&config) {
toast_error(
&ui_tray,
&gettext("Couldn't save the tray setting"),
&e.to_string(),
);
return;
}
if show {
spawn_tray();
} else {
pdfs_core::tray::quit(&ui_tray.dirs);
}
});
// Language: read once at start by the app, the tray and the prompt, so a
// change applies on the next launch.
let ui_language = ui.clone();
ui.status.language_row.connect_selected_notify(move |row| {
if ui_language.status.settings_suppress.get() {
return;
}
let mut config = ui_language.dirs.load_config();
config.language = language_at(row.selected()).map(str::to_string);
if let Err(e) = ui_language.dirs.save_config(&config) {
toast_error(
&ui_language,
&gettext("Couldn't save the language"),
&e.to_string(),
);
return;
}
toast(
&ui_language,
&gettext("Restart Proton Drive to use the new language"),
);
});
}
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
/// confirming with `done` or reporting the daemon's error under `failed`. Unlike
/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the
/// cache read-out.
pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed: String) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => toast(&ui, &done),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
/// the mount service so the daemon picks it up.
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title(gettext("Choose Proton Drive Folder"))
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(path) = folder.path() else { return };
let path_str = path.display().to_string();
// Persist the choice to config so the next mount uses it.
let mut config = ui.dirs.load_config();
config.mountpoint = Some(path_str.clone());
if let Err(e) = ui.dirs.save_config(&config) {
toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
return;
}
ui.status.mountpoint_row.set_subtitle(&path_str);
// The Locations page shows the same path as a row title. Any other page
// reads it again on the next visit.
if ui.stack.visible_child_name().as_deref() == Some("locations") {
load_locations(&ui);
}
// The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder()
.heading(gettext("Restart Proton Drive?"))
// Translators: {path} is the folder the drive is mounted at.
.body(gettext_f(
"My files now appears in “{path}” after Proton Drive restarts.",
&[("path", &path_str)],
))
.build();
confirm.add_response("later", &gettext("Later"));
confirm.add_response("restart", &gettext("Restart Now"));
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
confirm.set_default_response(Some("restart"));
confirm.set_close_response("later");
let ui_restart = ui.clone();
confirm.connect_response(None, move |_, resp| {
if resp == "restart" {
restart_service_then(&ui_restart, |_| {});
}
});
confirm.present(ui_window(&ui).as_ref());
});
}
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
/// reload the page.
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
let ui_browser = ui.clone();
ui.browser
.retry
.clone()
.connect_clicked(move |_| restart_service_then(&ui_browser, load_browser));
let ui_gallery = ui.clone();
ui.gallery
.retry
.clone()
.connect_clicked(move |_| restart_service_then(&ui_gallery, |ui| load_gallery(ui, false)));
let ui_trash = ui.clone();
ui.trash
.retry
.clone()
.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
/// check is instant (no keyring), and the round-trips run on workers so the
/// main loop never blocks on a slow or wedged daemon.
pub(crate) fn refresh(ui: &Rc<Ui>) {
// Login identity decides which page is shown. Read the cached session — set
// at startup and on login/logout — never the keyring.
{
let session = ui.session.borrow();
match session.as_ref() {
Some(s) => {
// Only pull the user onto a destination when they're sitting on the
// login page; otherwise leave whichever page they navigated to.
if ui.stack.visible_child_name().as_deref() == Some("login") {
ui.stack.set_visible_child_name("browser");
}
ui.nav.set_collapsed(false);
if ui.status.account_name.label() != s.username {
ui.status.account_name.set_label(&s.username);
ui.status.account_name.set_tooltip_text(Some(&s.username));
ui.status.avatar.set_text(Some(&s.username));
}
}
None => {
ui.stack.set_visible_child_name("login");
// Collapsed + showing content = the login page owns the window and
// no destination is reachable without a session.
ui.nav.set_collapsed(true);
ui.nav.set_show_content(true);
return;
}
}
}
offer_unlock(ui);
refresh_status(ui);
refresh_transfers(ui);
}
/// How long a quota reading stays fresh. Account storage barely moves, so a
/// status change reads it again only this often.
const QUOTA_TTL: Duration = Duration::from_secs(60);
/// Fetch the account quota (if the last reading is stale) and paint the
/// sidebar footer. A failed fetch leaves the last good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() {
return;
}
if let Some(at) = ui.status.quota_checked_at.get()
&& at.elapsed() < QUOTA_TTL
{
return;
}
ui.status.quota_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::AccountQuota);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.quota_inflight.set(false);
if let Ok(Ok(Response::AccountQuota {
max_space,
used_space,
})) = result
{
ui.status.quota_checked_at.set(Some(Instant::now()));
paint_account_quota(&ui, max_space, used_space);
ui.status.quota_box.set_visible(true);
}
});
}
fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
let (fraction, text) = quota_display(max_space, used_space);
ui.status.quota_bar.set_fraction(fraction);
ui.status.quota_label.set_text(&text);
}
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
let used = used_space.max(0) as u64;
if max_space <= 0 {
return (
0.0,
// Translators: {size} is an amount of storage, such as "1.2 GiB".
gettext_f("{size} used", &[("size", &human_bytes(used))]),
);
}
let total = max_space as u64;
let fraction = (used as f64 / total as f64).clamp(0.0, 1.0);
let pct = (fraction * 100.0).round() as u64;
(
fraction,
// Translators: account storage. {used} and {total} are sizes such as
// "1.2 GiB"; {percent} is a whole number, followed by the percent sign.
gettext_f(
"{used} of {total} used ({percent}%)",
&[
("used", &human_bytes(used)),
("total", &human_bytes(total)),
("percent", &pct.to_string()),
],
),
)
}
/// Record the mount state seen by the last status poll: gate every control that
/// needs a live daemon, and notify the desktop when the state actually flips.
///
/// The gating is the point — without it, New Folder / Upload / the details pane's
/// actions stay clickable while the mount is down and each click buys a round-trip
/// that can only fail. A greyed control says so up front.
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
*ui.mounted.borrow_mut() = mounted;
sync_mounted_actions(ui, mounted);
ui.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted);
ui.details.details.trash_button.set_sensitive(mounted);
ui.details.details.open_button.set_sensitive(mounted);
ui.details.details.versions_button.set_sensitive(mounted);
// Only notify on a real edge, and never for the first reading: at startup the
// service is usually still coming up, and "disconnected" would be a lie.
if ui.status.notified_mounted.get() == Some(mounted) {
return;
}
let first = ui.status.notified_mounted.replace(Some(mounted)).is_none();
if first {
return;
}
if mounted {
notify(
"mount-state",
&gettext("Proton Drive connected"),
&gettext("Your files are available."),
);
} else {
notify(
"mount-state",
&gettext("Proton Drive disconnected"),
&gettext("The Proton Drive service stopped. Files aren't available until it restarts."),
);
}
}
/// Ask the daemon for its in-flight transfers on a worker thread and repaint the
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
/// two requests don't gate each other; the event feed pushes later snapshots. The group hides itself
/// when nothing is moving, so an idle account shows no Activity section.
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
if ui.status.transfers_inflight.get() {
return;
}
ui.status.transfers_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.transfers_inflight.set(false);
match result {
Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs),
// Daemon unreachable or odd reply: clear the section rather than
// leave stale progress bars frozen on screen.
_ => repaint_transfers(&ui, &[], &[]),
}
});
}
/// Render the Activity group from a work snapshot: the daemon's jobs (scans,
/// batch counts, the local index) above the files moving under them. Rebuilds the
/// rows only when the set changes (count differs); on the common steady tick it
/// updates each bar's fraction and the label in place, so progress animates
/// without flicker. Hides the whole group when the daemon is idle.
pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[JobItem]) {
let lines: Vec<ActivityLine> = jobs
.iter()
.map(job_line)
.chain(items.iter().map(transfer_line))
.collect();
// The wire carries in-flight transfers only, with no completion event: the
// count falling to zero is what "the sync finished" looks like from here.
// Jobs are deliberately not counted — a bulk upload retires its scan job and
// starts its upload job mid-flight, which is not a thing finishing.
// The Import page reads its progress off the same snapshot rather than
// polling the daemon a second time for numbers already on the wire.
takeout_progress(ui, jobs);
let previous = ui.status.active_transfers.replace(items.len());
if !items.is_empty() {
let mut batch = ui.status.transfer_batch.borrow_mut();
let batch = batch.get_or_insert_with(|| TransferBatch {
started: Instant::now(),
names: HashSet::new(),
});
batch
.names
.extend(items.iter().map(|item| item.name.clone()));
}
if items.is_empty() && previous > 0 {
// Nobody needs a notification for what they are watching happen.
let watching = ui_window(ui).is_some_and(|window| window.is_active());
if let Some(batch) = ui.status.transfer_batch.take()
&& !watching
&& (batch.names.len() >= SYNC_NOTIFY_MIN_FILES
|| batch.started.elapsed() >= SYNC_NOTIFY_MIN_DURATION)
{
notify(
"sync-complete",
&gettext("Sync complete"),
&ngettext_f(
"{n} file finished transferring.",
"{n} files finished transferring.",
batch.names.len() as u64,
&[],
),
);
}
// A just-finished batch may have added files (bulk upload) the current
// listing doesn't show yet; refresh whichever listing is on screen.
reload_listing(ui);
}
if lines.is_empty() {
if !ui.status.transfer_rows.borrow().is_empty() {
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
ui.status.transfers_group.remove(&tr.row);
}
}
ui.status.transfers_group.set_visible(false);
return;
}
ui.status.transfers_group.set_visible(true);
// Rebuild rows only when the count changes; otherwise reuse them in place.
if ui.status.transfer_rows.borrow().len() != lines.len() {
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
ui.status.transfers_group.remove(&tr.row);
}
for _ in &lines {
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
row_box.set_margin_top(8);
row_box.set_margin_bottom(8);
// Ellipsized so a long file name cannot widen the page.
let label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
label.add_css_class("dim-label");
let bar = gtk4::ProgressBar::new();
row_box.append(&label);
row_box.append(&bar);
let row = adw::PreferencesRow::builder()
.activatable(false)
.child(&row_box)
.build();
ui.status.transfers_group.add(&row);
ui.status
.transfer_rows
.borrow_mut()
.push(TransferRow { row, label, bar });
}
}
for (line, tr) in lines.iter().zip(ui.status.transfer_rows.borrow().iter()) {
tr.label.set_text(&line.text);
match line.fraction {
Some(f) => tr.bar.set_fraction(f),
// No total to divide by: pulse so the bar still reads as "working".
None => tr.bar.pulse(),
}
}
}
/// One Activity row for a daemon job: its title, plus whatever it can say about
/// where it is — a count when it has one, else what it is currently chewing on.
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
let done = j.done.to_string();
let total = j.total.to_string();
let args: [(&str, &str); 4] = [
("title", &j.title),
("detail", &j.detail),
("done", &done),
("total", &total),
];
let text = match (j.total > 0, j.detail.is_empty()) {
// Translators: a background job's progress. {title} is the job, such as
// "Scanning Photos"; {done} and {total} are counts.
(true, true) => gettext_f("{title} — {done} of {total}", &args),
// Translators: a background job's progress. {title} is the job, {detail}
// what it is working on; {done} and {total} are counts.
(true, false) => gettext_f("{title} — {detail} ({done} of {total})", &args),
// Translators: a background job still running. {title} is the job.
(false, true) => gettext_f("{title}…", &args),
// Translators: a background job still running. {title} is the job,
// {detail} what it is working on.
(false, false) => gettext_f("{title} — {detail}…", &args),
};
ActivityLine {
text,
fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)),
}
}
/// One Activity row for a file in flight: which way it's going, how far, how fast.
pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
let done = human_bytes(t.bytes_completed);
let total = human_bytes(t.bytes_total);
let speed = human_bytes(t.speed_bytes_sec);
let args: [(&str, &str); 5] = [
("arrow", arrow),
("name", &t.name),
("done", &done),
("total", &total),
("speed", &speed),
];
if t.bytes_total == 0 {
ActivityLine {
// Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
// file name, {done} the size moved so far and {speed} a size such as
// "1.2 MiB"; "/s" means per second.
text: gettext_f("{arrow} {name} — {done} ({speed}/s)", &args),
fraction: None,
}
} else {
ActivityLine {
// Translators: a file being transferred. {arrow} is ↑ or ↓, {name} the
// file name, {done} and {total} sizes and {speed} a size such as
// "1.2 MiB"; "/s" means per second.
text: gettext_f("{arrow} {name} — {done} of {total} ({speed}/s)", &args),
fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)),
}
}
}
/// Fetch mount status + cache stats from the daemon on a worker thread and repaint
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
/// a stalled daemon can't pile up threads.
///
/// The event feed pushes every later change through [`paint_status`]; this asks
/// only when the feed (re)connects or loses the daemon.
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
if ui.status.status_inflight.get() {
return;
}
ui.status.status_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::Status);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.status_inflight.set(false);
match result {
Ok(Ok(status @ Response::Status { .. })) => paint_status(&ui, status),
// The daemon is up but stuck. Say so, and keep the mount state:
// announcing "disconnected" for a slow reply would be wrong, and
// the feed asks again when it reconnects.
Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding),
_ => paint_disconnected(&ui),
}
});
}
/// Paint a [`Response::Status`], fetched or pushed: the mount state, the sync
/// status, the cache bar and the pin list.
pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
let Response::Status {
mountpoint,
used,
budget,
pins,
online,
pending_uploads,
pending_changes,
failing_ops,
failing_error,
paused,
paused_until,
session_expired,
..
} = status
else {
return;
};
set_mounted(ui, true);
// The queue is the more useful thing to say when it has anything
// 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 session_expired {
SyncState::SessionExpired { queued }
} else if paused {
SyncState::Paused {
until: paused_until,
queued,
}
} else if failing_ops > 0 {
SyncState::Attention {
count: failing_ops,
error: failing_error,
}
} else if !online {
SyncState::Offline { queued }
} else if let Some(queued) = queued {
SyncState::Syncing { queued }
} else {
SyncState::UpToDate { mountpoint }
};
paint_sync_status(ui, state);
ui.locations.failing.set(failing_ops);
paint_sync_banner(ui);
let fraction = if budget == 0 {
0.0
} else {
(used as f64 / budget as f64).min(1.0)
};
ui.status.cache_bar.set_fraction(fraction);
// A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
// used" reads as a broken read-out, and there is no fraction to
// draw against no limit.
ui.status.cache_label.set_text(&if budget == 0 {
// Translators: {used} is a size such as "1.2 GiB".
gettext_f(
"{used} cached — no limit set",
&[("used", &human_bytes(used))],
)
} else {
// Translators: {used} and {limit} are sizes such as "1.2 GiB".
gettext_f(
"{used} of {limit} used",
&[
("used", &human_bytes(used)),
("limit", &human_bytes(budget)),
],
)
});
repaint_pins(ui, &pins, true);
}
/// Daemon unreachable (still starting, or down): report not-mounted and grey
/// out the unpin buttons in place, but leave the last-known pin rows and cache
/// read-out so the page doesn't flicker on a blip.
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
set_mounted(ui, false);
paint_sync_status(ui, SyncState::Disconnected);
for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(false);
}
}
}
/// 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 {
/// Unix second the pause ends by itself; `None` until resumed.
until: Option<i64>,
queued: Option<String>,
},
/// Operations keep failing; the user may have to act.
Attention {
count: u64,
error: Option<String>,
},
/// No connection to Proton: cached files only, changes wait.
Offline {
queued: Option<String>,
},
/// Local changes are on their way up.
Syncing {
queued: String,
},
UpToDate {
mountpoint: String,
},
/// No mount daemon answered: still starting, or the service is down.
Disconnected,
/// The daemon is running but didn't answer in time.
NotResponding,
}
/// Paint the sidebar status strip and the Sync page's status card: icon,
/// one-line state, and a detail line.
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"),
gettext("Sync paused"),
Some(match (until, queued) {
// Translators: {queued} says what is waiting, such as "3 changes
// waiting"; {time} is when syncing resumes, such as "at 14:30".
(Some(until), Some(queued)) => gettext_f(
"{queued} · resumes {time}",
&[("queued", &queued), ("time", &clock_time(until))],
),
// Translators: {time} is when syncing resumes, such as "at 14:30"
// or "Tue 09:00".
(Some(until), None) => gettext_f("Resumes {time}", &[("time", &clock_time(until))]),
(None, Some(queued)) => queued,
(None, None) => gettext("Until you resume"),
}),
),
SyncState::Attention { count, error } => (
"dialog-warning-symbolic",
Some("error"),
ngettext_f(
"{n} change needs attention",
"{n} changes need attention",
count,
&[],
),
error,
),
SyncState::Offline { queued } => (
"network-offline-symbolic",
Some("warning"),
pgettext("state", "Offline"),
Some(queued.unwrap_or_else(|| gettext("Cached files only"))),
),
SyncState::Syncing { queued } => (
"pdfs-sync-symbolic",
None,
pgettext("state", "Syncing"),
Some(queued),
),
SyncState::UpToDate { mountpoint } => (
"pdfs-synced-symbolic",
Some("success"),
gettext("Up to date"),
Some(mountpoint),
),
SyncState::Disconnected => (
"network-offline-symbolic",
Some("warning"),
gettext("Not connected"),
Some(gettext("Proton Drive isn't running")),
),
SyncState::NotResponding => (
"dialog-warning-symbolic",
Some("warning"),
gettext("Not responding"),
Some(gettext("Proton Drive is taking too long to answer")),
),
};
let image = &ui.status.status_icon;
image.set_icon_name(Some(icon));
image.set_visible(true);
ui.status.status_spinner.set_visible(false);
for c in ["success", "warning", "error"] {
image.remove_css_class(c);
}
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
.status_detail
.set_label(detail.as_deref().unwrap_or_default());
ui.status.status_detail.set_tooltip_text(detail.as_deref());
paint_sync_card(
ui,
icon,
class,
&title,
detail.as_deref(),
paused,
connected,
);
}
/// "at 14:30" today, "Tue 09:00" within the week, a date beyond that.
pub(crate) fn clock_time(unix: i64) -> String {
let (Ok(at), Ok(now)) = (
glib::DateTime::from_unix_local(unix),
glib::DateTime::now_local(),
) else {
// Translators: stands in for a time that could not be read, as in
// "Resumes later".
return gettext("later");
};
let format = if at.ymd() == now.ymd() {
// Translators: strftime format for a time later today, such as "at 14:30".
gettext("at %H:%M")
} else if at.difference(&now).as_seconds() < 6 * 86_400 {
// Translators: strftime format for a time within the next week, such as
// "Tue 09:00".
gettext("%a %H:%M")
} else {
// Translators: strftime format for a date further ahead, such as "23 Sep".
gettext("%e %b")
};
at.format(&format)
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| gettext("later"))
}
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
/// mount daemon is running (`mounted`). Diffs against the last batch by path: when
/// the set is unchanged (the common case on the 2s tick) it only flips the unpin
/// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list
/// and drop scroll/selection every tick.
pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted: bool) {
let desired: Vec<String> = pins.iter().map(|p| p.path.clone()).collect();
if ui.status.pins_state.borrow().as_ref() == Some(&desired) {
for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(mounted);
}
}
return;
}
for pr in ui.status.pin_rows.borrow_mut().drain(..) {
ui.status.pins_group.remove(&pr.row);
}
*ui.status.pins_state.borrow_mut() = Some(desired);
if pins.is_empty() {
let row = adw::ActionRow::builder()
.title(gettext("No files kept offline"))
.subtitle(gettext(
"Right-click a file and choose “Make available offline”.",
))
.build();
ui.status.pins_group.add(&row);
ui.status.pin_rows.borrow_mut().push(PinRow {
row: row.upcast(),
unpin: None,
});
return;
}
// Only the first page of pins is rendered; the rest sit behind the row
// below, so a device with hundreds of pins doesn't bury the groups after it.
let expanded = ui.status.pins_expanded.get();
let shown = if expanded {
pins.len()
} else {
pins.len().min(PINS_COLLAPSED)
};
for pin in &pins[..shown] {
let name = Path::new(&pin.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&pin.path)
.to_string();
let row = adw::ActionRow::builder()
.title(&name)
.subtitle(&pin.path)
.build();
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
row.add_prefix(&icon);
let unpin = gtk4::Button::builder()
.icon_name("pdfs-online-only-symbolic")
.valign(gtk4::Align::Center)
.tooltip_text(gettext("Make online only"))
.sensitive(mounted)
.build();
unpin.add_css_class("flat");
let ui_btn = ui.clone();
let path = pin.path.clone();
unpin.connect_clicked(move |_| {
let rx = spawn_request(
ui_btn.dirs.control_socket(),
Request::Unpin { path: path.clone() },
);
let ui = ui_btn.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Error { message, kind })) => toast_failure(
&ui,
&gettext("Couldn't make it online only"),
&message,
kind,
),
Ok(Ok(_)) => refresh(&ui),
_ => toast_error(
&ui,
&gettext("Couldn't make it online only"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
});
row.add_suffix(&unpin);
ui.status.pins_group.add(&row);
ui.status.pin_rows.borrow_mut().push(PinRow {
row: row.upcast(),
unpin: Some(unpin),
});
}
if pins.len() > PINS_COLLAPSED {
let title = if expanded {
gettext("Show fewer")
} else {
ngettext_f(
"Show all {n} file",
"Show all {n} files",
pins.len() as u64,
&[],
)
};
let icon = if expanded {
"go-up-symbolic"
} else {
"go-down-symbolic"
};
let ui_more = ui.clone();
let row = button_row(&title, icon, move || {
ui_more
.status
.pins_expanded
.set(!ui_more.status.pins_expanded.get());
// The diff guard compares pin *paths*; the same paths render
// differently now, so the baseline has to be dropped for the next
// repaint to actually rebuild.
*ui_more.status.pins_state.borrow_mut() = None;
refresh(&ui_more);
});
ui.status.pins_group.add(&row);
ui.status
.pin_rows
.borrow_mut()
.push(PinRow { row, unpin: None });
}
}
#[cfg(test)]
mod tests {
use super::quota_display;
#[test]
fn quota_display_reports_used_total_and_percentage() {
let gib = 1024_i64.pow(3);
let (fraction, text) = quota_display(4 * gib, gib);
assert!((fraction - 0.25).abs() < f64::EPSILON);
assert_eq!(text, "1.0 GiB of 4.0 GiB used (25%)");
}
#[test]
fn quota_display_clamps_bad_api_values() {
assert_eq!(quota_display(0, -1), (0.0, "0 bytes used".to_string()));
assert_eq!(quota_display(100, 150).0, 1.0);
}
}