From a3d9d05777fce4b59f3a13eb60ff27056931955b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 20 Jul 2026 23:08:23 +0200 Subject: [PATCH] added settings for this device + quota in settings --- crates/pdfs-cli/src/main.rs | 32 +++++++++ crates/pdfs-core/src/control.rs | 11 +++ crates/pdfs-fuse/src/control.rs | 7 ++ crates/pdfs-fuse/src/sharing.rs | 13 ++++ crates/pdfs-gui/src/app/main.rs | 7 ++ crates/pdfs-gui/src/app/pages/devices.rs | 43 +++++++++++ crates/pdfs-gui/src/app/pages/status.rs | 92 ++++++++++++++++++++++++ 7 files changed, 205 insertions(+) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 6d01f0f..3ba2c41 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -310,6 +310,8 @@ enum Command { #[arg(short, long, default_value_t = 50)] limit: usize, }, + /// Show account storage usage (used of total, across all Proton products). + Quota, /// Inspect and maintain the local metadata database and content cache. Cache { #[command(subcommand)] @@ -547,6 +549,7 @@ fn main() -> Result<()> { Command::Invitations { action } => cmd_invitations(action), Command::Bookmarks { action } => cmd_bookmarks(action), Command::Activity { limit } => cmd_activity(limit), + Command::Quota => cmd_quota(), Command::Cache { action } => cmd_cache(action), Command::Diagnose => cmd_diagnose(), } @@ -925,6 +928,35 @@ fn cmd_shared_get(uid: String, dest: Option) -> Result<()> { Ok(()) } +fn cmd_quota() -> Result<()> { + let response = control_request(CtlRequest::AccountQuota)?; + if emit_json(&response)? { + return Ok(()); + } + match response { + CtlResponse::AccountQuota { + max_space, + used_space, + } => { + let used = used_space.max(0) as u64; + if max_space > 0 { + let total = max_space as u64; + let pct = (used as f64 / total as f64 * 100.0).round() as u64; + println!( + "{} of {} used ({pct}%)", + human_bytes(used), + human_bytes(total) + ); + } else { + println!("{} used", human_bytes(used)); + } + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + fn cmd_activity(limit: usize) -> Result<()> { let response = control_request(CtlRequest::ListActivity { limit })?; if emit_json(&response)? { diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index a820a6b..ed750f0 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -307,6 +307,11 @@ pub enum Request { /// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`]. DeleteBookmark { token: String }, + // ---- account ---------------------------------------------------------- + /// Total account storage usage (used/total, all Proton products, not just + /// Drive). Replies with [`Response::AccountQuota`]. + AccountQuota, + // ---- activity --------------------------------------------------------- /// Fetch the daemon's recent activity log, newest first, capped at `limit` /// entries. Replies with [`Response::Activity`]. The log is persisted, so it @@ -970,6 +975,11 @@ pub enum Response { Invitations { items: Vec }, /// Saved public links (reply to [`Request::ListBookmarks`]). Bookmarks { items: Vec }, + /// Account storage usage in bytes (reply to [`Request::AccountQuota`]). + /// `max_space` is the total; `used_space` what is consumed. `max_space == 0` + /// means the total is unknown, which a front-end shows as a plain used figure + /// rather than a full bar. + AccountQuota { max_space: i64, used_space: i64 }, /// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]). SharedByMe { items: Vec }, /// The daemon's recent activity, newest first (reply to @@ -1283,6 +1293,7 @@ mod tests { Request::DeleteBookmark { token: "tok".into(), }, + Request::AccountQuota, ]; for req in reqs { let line = serde_json::to_string(&req).unwrap(); diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 558e233..e923db5 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -680,6 +680,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(entries) => CtlResponse::Entries { entries }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::AccountQuota) => match core.account_quota() { + Ok((max_space, used_space)) => CtlResponse::AccountQuota { + max_space, + used_space, + }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::ListSharedFolder { uid }) => match core.list_shared_folder(&uid) { Ok(entries) => CtlResponse::Entries { entries }, Err(e) => CtlResponse::error(e), diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index e45b709..8d9f36f 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -392,6 +392,19 @@ impl Core { .map_err(|e| CoreError::from_api(&e, "delete bookmark")) } + // ---- account ---------------------------------------------------------- + + /// Total account storage usage `(max_space, used_space)` in bytes, across all + /// Proton products (not Drive-only). A remote round-trip; nothing here is + /// cached. + pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> { + let q = self + .rt + .block_on(self.client.quota()) + .map_err(|e| CoreError::from_api(&e, "account quota"))?; + Ok((q.max_space, q.used_space)) + } + // ---- shared by me ----------------------------------------------------- /// List the nodes I have shared with others, each with a summary of its share diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 01144ec..e6dff9d 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -400,6 +400,11 @@ fn build_window(app: &adw::Application) { transfers_inflight: Cell::new(false), cache_bar: main_widgets.cache_bar.clone(), cache_label: main_widgets.cache_label.clone(), + quota_group: main_widgets.quota_group.clone(), + quota_bar: main_widgets.quota_bar.clone(), + quota_label: main_widgets.quota_label.clone(), + quota_inflight: Cell::new(false), + quota_checked_at: Cell::new(None), autostart_row: main_widgets.autostart_row.clone(), budget_row: main_widgets.budget_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(), @@ -501,6 +506,8 @@ fn build_window(app: &adw::Application) { rows: RefCell::new(Vec::new()), sync_group: devices_widgets.sync_group.clone(), sync_rows: RefCell::new(Vec::new()), + rename_this: devices_widgets.rename_this.clone(), + this_device: RefCell::new(None), inflight: Cell::new(false), loaded_at: Cell::new(None), }, diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 9f2d476..bda1b36 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -10,6 +10,13 @@ pub(crate) struct DevicesState { pub(crate) rows: RefCell>, pub(crate) sync_group: adw::PreferencesGroup, pub(crate) sync_rows: RefCell>, + /// "Rename" action in the "This computer" header. Insensitive until the + /// device list identifies this machine's own device. + pub(crate) rename_this: gtk4::Button, + /// This machine's own device `(uid, name)`, from the last device list. The + /// current device is filtered out of the "Other computers" rows, so this is + /// where its rename target lives. `None` until identified. + pub(crate) this_device: RefCell>, pub(crate) inflight: Cell, pub(crate) loaded_at: Cell>, } @@ -20,6 +27,8 @@ pub(crate) struct DevicesWidgets { pub(crate) status: adw::StatusPage, /// "This computer" — the local folders synced to this machine's device. pub(crate) sync_group: adw::PreferencesGroup, + /// "Rename" in the "This computer" header — renames this machine's device. + pub(crate) rename_this: gtk4::Button, /// "Other computers" — the account's *other* registered devices. This /// machine's own device is deliberately not among them; see /// [`repaint_devices`]. @@ -75,6 +84,17 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { them as you open them.", ) .build(); + // Rename control for *this* machine's device, in the section header. The + // current device is filtered out of "Other computers", so this is the only + // place it can be renamed; insensitive until the device list identifies it. + let rename_this = gtk4::Button::builder() + .icon_name("document-edit-symbolic") + .tooltip_text("Rename this computer") + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); + rename_this.add_css_class("flat"); + sync_group.set_header_suffix(Some(&rename_this)); let group = adw::PreferencesGroup::builder() .title("Other computers") .description("Other devices backing up to this account.") @@ -123,6 +143,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { content, status, sync_group, + rename_this, group, add_folder, restore, @@ -148,6 +169,12 @@ pub(crate) fn wire_devices( add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); let ui_restore = ui.clone(); restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore)); + let ui_ren = ui.clone(); + ui.devices.rename_this.connect_clicked(move |_| { + if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() { + prompt_rename_device(&ui_ren, &uid, &name); + } + }); } /// Show a status page in place of the Devices list. @@ -282,6 +309,22 @@ pub(crate) fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { for row in ui.devices.rows.borrow_mut().drain(..) { ui.devices.group.remove(&row); } + // Identify this machine's own device so the "This computer" header's Rename + // action has a target. It never appears in the rows below, so this is the + // only place its name/uid is captured. + match devices.iter().find(|d| d.this_device) { + Some(me) => { + *ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone())); + ui.devices.rename_this.set_sensitive(true); + ui.devices + .rename_this + .set_tooltip_text(Some(&format!("Rename this computer ({})", me.name))); + } + None => { + *ui.devices.this_device.borrow_mut() = None; + ui.devices.rename_this.set_sensitive(false); + } + } let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect(); if others.is_empty() { let row = adw::ActionRow::builder() diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 029f758..fdd9536 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -17,6 +17,14 @@ pub(crate) struct StatusState { pub(crate) mount_row: adw::ActionRow, pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_label: gtk4::Label, + /// Account-quota group + its bar/label. Hidden until the first reading. + pub(crate) quota_group: adw::PreferencesGroup, + 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, + pub(crate) quota_checked_at: Cell>, /// "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, @@ -78,6 +86,10 @@ pub(crate) struct MainWidgets { pub(crate) mount_row: adw::ActionRow, /// Live upload/download progress, populated by the refresh loop. pub(crate) transfers_group: adw::PreferencesGroup, + /// Account-quota group; hidden until `refresh_quota` gets a reading. + pub(crate) quota_group: adw::PreferencesGroup, + 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, @@ -111,6 +123,29 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { account_row.add_suffix(&logout_button); account_group.add(&account_row); + // Account storage: the Proton account quota (used of total), distinct from + // the local content cache below. A progress bar + "X of Y used" line, painted + // by `refresh_quota`. Hidden until the first successful read so a cold start + // (or an account the API can't report) shows nothing rather than an empty bar. + let quota_group = adw::PreferencesGroup::builder() + .title("Account storage") + .description("Your Proton storage across all products.") + .visible(false) + .build(); + let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + quota_box.set_margin_top(6); + quota_box.set_margin_bottom(6); + let quota_bar = gtk4::ProgressBar::new(); + let quota_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + quota_label.add_css_class("dim-label"); + quota_box.append("a_bar); + quota_box.append("a_label); + let quota_row = adw::PreferencesRow::builder() + .activatable(false) + .child("a_box) + .build(); + quota_group.add("a_row); + // Mount group: a read-only status line. The mount is managed automatically // by the systemd user service; there is no toggle to fiddle with. let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); @@ -219,6 +254,7 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&account_group); + inner.append("a_group); inner.append(&mount_group); inner.append(&transfers_group); inner.append(&storage_group); @@ -238,6 +274,9 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { account_row, mount_row, transfers_group, + quota_group, + quota_bar, + quota_label, cache_bar, cache_label, pins_group, @@ -463,12 +502,65 @@ pub(crate) fn refresh(ui: &Rc) { // Both of these pages show work as it happens, so they follow the tick while // they are on screen. Every other page loads on navigation only. match ui.stack.visible_child_name().as_deref() { + Some("main") => refresh_quota(ui), Some("devices") => refresh_sync_folders(ui), Some("activity") => refresh_activity(ui), _ => {} } } +/// How long a quota reading stays fresh. Account storage barely moves, so the +/// Settings tick refetches it only this often rather than every 2s. +const QUOTA_TTL: Duration = Duration::from_secs(60); + +/// Fetch the account quota (if the last reading is stale) and paint the Account +/// storage group. Runs only while Settings is on screen. The group stays hidden +/// until a reading lands, so an account the API can't report — or a still-starting +/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last +/// good reading in place. +pub(crate) fn refresh_quota(ui: &Rc) { + 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())); + let used = used_space.max(0) as u64; + if max_space > 0 { + let total = max_space as u64; + let fraction = (used as f64 / total as f64).min(1.0); + ui.status.quota_bar.set_fraction(fraction); + let pct = (fraction * 100.0).round() as u64; + ui.status.quota_label.set_text(&format!( + "{} of {} used ({pct}%)", + human_bytes(used), + human_bytes(total) + )); + } else { + ui.status.quota_bar.set_fraction(0.0); + ui.status + .quota_label + .set_text(&format!("{} used", human_bytes(used))); + } + ui.status.quota_group.set_visible(true); + } + }); +} + /// 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. ///