added settings for this device + quota in settings

This commit is contained in:
nvrl committed 2026-07-20 23:08:23 +02:00
1 parent bf625bf8d9
commit a3d9d05777
7 files changed
+205

No files matched your search

+32
View File
@@ -310,6 +310,8 @@ enum Command {
#[arg(short, long, default_value_t = 50)] #[arg(short, long, default_value_t = 50)]
limit: usize, limit: usize,
}, },
/// Show account storage usage (used of total, across all Proton products).
Quota,
/// Inspect and maintain the local metadata database and content cache. /// Inspect and maintain the local metadata database and content cache.
Cache { Cache {
#[command(subcommand)] #[command(subcommand)]
@@ -547,6 +549,7 @@ fn main() -> Result<()> {
Command::Invitations { action } => cmd_invitations(action), Command::Invitations { action } => cmd_invitations(action),
Command::Bookmarks { action } => cmd_bookmarks(action), Command::Bookmarks { action } => cmd_bookmarks(action),
Command::Activity { limit } => cmd_activity(limit), Command::Activity { limit } => cmd_activity(limit),
Command::Quota => cmd_quota(),
Command::Cache { action } => cmd_cache(action), Command::Cache { action } => cmd_cache(action),
Command::Diagnose => cmd_diagnose(), Command::Diagnose => cmd_diagnose(),
} }
@@ -925,6 +928,35 @@ fn cmd_shared_get(uid: String, dest: Option<PathBuf>) -> Result<()> {
Ok(()) 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<()> { fn cmd_activity(limit: usize) -> Result<()> {
let response = control_request(CtlRequest::ListActivity { limit })?; let response = control_request(CtlRequest::ListActivity { limit })?;
if emit_json(&response)? { if emit_json(&response)? {
+11
View File
@@ -307,6 +307,11 @@ pub enum Request {
/// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`]. /// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`].
DeleteBookmark { token: String }, DeleteBookmark { token: String },
// ---- account ----------------------------------------------------------
/// Total account storage usage (used/total, all Proton products, not just
/// Drive). Replies with [`Response::AccountQuota`].
AccountQuota,
// ---- activity --------------------------------------------------------- // ---- activity ---------------------------------------------------------
/// Fetch the daemon's recent activity log, newest first, capped at `limit` /// Fetch the daemon's recent activity log, newest first, capped at `limit`
/// entries. Replies with [`Response::Activity`]. The log is persisted, so it /// entries. Replies with [`Response::Activity`]. The log is persisted, so it
@@ -970,6 +975,11 @@ pub enum Response {
Invitations { items: Vec<InvitationInfo> }, Invitations { items: Vec<InvitationInfo> },
/// Saved public links (reply to [`Request::ListBookmarks`]). /// Saved public links (reply to [`Request::ListBookmarks`]).
Bookmarks { items: Vec<BookmarkInfo> }, Bookmarks { items: Vec<BookmarkInfo> },
/// 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`]). /// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
SharedByMe { items: Vec<SharedItem> }, SharedByMe { items: Vec<SharedItem> },
/// The daemon's recent activity, newest first (reply to /// The daemon's recent activity, newest first (reply to
@@ -1283,6 +1293,7 @@ mod tests {
Request::DeleteBookmark { Request::DeleteBookmark {
token: "tok".into(), token: "tok".into(),
}, },
Request::AccountQuota,
]; ];
for req in reqs { for req in reqs {
let line = serde_json::to_string(&req).unwrap(); let line = serde_json::to_string(&req).unwrap();
+7
View File
@@ -680,6 +680,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(entries) => CtlResponse::Entries { entries }, Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e), 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(CtlRequest::ListSharedFolder { uid }) => match core.list_shared_folder(&uid) {
Ok(entries) => CtlResponse::Entries { entries }, Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
+13
View File
@@ -392,6 +392,19 @@ impl Core {
.map_err(|e| CoreError::from_api(&e, "delete bookmark")) .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 ----------------------------------------------------- // ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others, each with a summary of its share /// List the nodes I have shared with others, each with a summary of its share
+7
View File
@@ -400,6 +400,11 @@ fn build_window(app: &adw::Application) {
transfers_inflight: Cell::new(false), transfers_inflight: Cell::new(false),
cache_bar: main_widgets.cache_bar.clone(), cache_bar: main_widgets.cache_bar.clone(),
cache_label: main_widgets.cache_label.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(), autostart_row: main_widgets.autostart_row.clone(),
budget_row: main_widgets.budget_row.clone(), budget_row: main_widgets.budget_row.clone(),
mountpoint_row: main_widgets.mountpoint_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(),
@@ -501,6 +506,8 @@ fn build_window(app: &adw::Application) {
rows: RefCell::new(Vec::new()), rows: RefCell::new(Vec::new()),
sync_group: devices_widgets.sync_group.clone(), sync_group: devices_widgets.sync_group.clone(),
sync_rows: RefCell::new(Vec::new()), sync_rows: RefCell::new(Vec::new()),
rename_this: devices_widgets.rename_this.clone(),
this_device: RefCell::new(None),
inflight: Cell::new(false), inflight: Cell::new(false),
loaded_at: Cell::new(None), loaded_at: Cell::new(None),
}, },
+43
View File
@@ -10,6 +10,13 @@ pub(crate) struct DevicesState {
pub(crate) rows: RefCell<Vec<gtk4::Widget>>, pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) sync_group: adw::PreferencesGroup, pub(crate) sync_group: adw::PreferencesGroup,
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>, pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
/// "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<Option<(String, String)>>,
pub(crate) inflight: Cell<bool>, pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>, pub(crate) loaded_at: Cell<Option<Instant>>,
} }
@@ -20,6 +27,8 @@ pub(crate) struct DevicesWidgets {
pub(crate) status: adw::StatusPage, pub(crate) status: adw::StatusPage,
/// "This computer" — the local folders synced to this machine's device. /// "This computer" — the local folders synced to this machine's device.
pub(crate) sync_group: adw::PreferencesGroup, 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 /// "Other computers" — the account's *other* registered devices. This
/// machine's own device is deliberately not among them; see /// machine's own device is deliberately not among them; see
/// [`repaint_devices`]. /// [`repaint_devices`].
@@ -75,6 +84,17 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
them as you open them.", them as you open them.",
) )
.build(); .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() let group = adw::PreferencesGroup::builder()
.title("Other computers") .title("Other computers")
.description("Other devices backing up to this account.") .description("Other devices backing up to this account.")
@@ -123,6 +143,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
content, content,
status, status,
sync_group, sync_group,
rename_this,
group, group,
add_folder, add_folder,
restore, restore,
@@ -148,6 +169,12 @@ pub(crate) fn wire_devices(
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
let ui_restore = ui.clone(); let ui_restore = ui.clone();
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore)); 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. /// Show a status page in place of the Devices list.
@@ -282,6 +309,22 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
for row in ui.devices.rows.borrow_mut().drain(..) { for row in ui.devices.rows.borrow_mut().drain(..) {
ui.devices.group.remove(&row); 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(); let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
if others.is_empty() { if others.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
+92
View File
@@ -17,6 +17,14 @@ pub(crate) struct StatusState {
pub(crate) mount_row: adw::ActionRow, pub(crate) mount_row: adw::ActionRow,
pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label, 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<bool>,
pub(crate) quota_checked_at: Cell<Option<Instant>>,
/// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic /// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic
/// sets so reflecting the systemd state doesn't fire the toggle handler. /// sets so reflecting the systemd state doesn't fire the toggle handler.
pub(crate) autostart_row: adw::SwitchRow, pub(crate) autostart_row: adw::SwitchRow,
@@ -78,6 +86,10 @@ pub(crate) struct MainWidgets {
pub(crate) mount_row: adw::ActionRow, pub(crate) mount_row: adw::ActionRow,
/// Live upload/download progress, populated by the refresh loop. /// Live upload/download progress, populated by the refresh loop.
pub(crate) transfers_group: adw::PreferencesGroup, 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_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label, pub(crate) cache_label: gtk4::Label,
pub(crate) pins_group: adw::PreferencesGroup, 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_row.add_suffix(&logout_button);
account_group.add(&account_row); 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(&quota_bar);
quota_box.append(&quota_label);
let quota_row = adw::PreferencesRow::builder()
.activatable(false)
.child(&quota_box)
.build();
quota_group.add(&quota_row);
// Mount group: a read-only status line. The mount is managed automatically // Mount group: a read-only status line. The mount is managed automatically
// by the systemd user service; there is no toggle to fiddle with. // by the systemd user service; there is no toggle to fiddle with.
let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); 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_start(12);
inner.set_margin_end(12); inner.set_margin_end(12);
inner.append(&account_group); inner.append(&account_group);
inner.append(&quota_group);
inner.append(&mount_group); inner.append(&mount_group);
inner.append(&transfers_group); inner.append(&transfers_group);
inner.append(&storage_group); inner.append(&storage_group);
@@ -238,6 +274,9 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
account_row, account_row,
mount_row, mount_row,
transfers_group, transfers_group,
quota_group,
quota_bar,
quota_label,
cache_bar, cache_bar,
cache_label, cache_label,
pins_group, pins_group,
@@ -463,12 +502,65 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
// Both of these pages show work as it happens, so they follow the tick while // 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. // they are on screen. Every other page loads on navigation only.
match ui.stack.visible_child_name().as_deref() { match ui.stack.visible_child_name().as_deref() {
Some("main") => refresh_quota(ui),
Some("devices") => refresh_sync_folders(ui), Some("devices") => refresh_sync_folders(ui),
Some("activity") => refresh_activity(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<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()));
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 /// 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. /// needs a live daemon, and notify the desktop when the state actually flips.
/// ///