From d988236bfac3910fe7dd0734a56921a15860ffd7 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 31 Jul 2026 19:37:12 +0200 Subject: [PATCH] wired new locations into gui, fuse acceptance with new permission based mounts --- README.md | 1 + crates/pdfs-cli/src/main.rs | 10 +- crates/pdfs-core/src/control.rs | 6 + crates/pdfs-fuse/src/lib.rs | 4 + crates/pdfs-fuse/src/sharing.rs | 33 +- crates/pdfs-gui/src/app/main.rs | 33 +- crates/pdfs-gui/src/app/pages/browser.rs | 1 + crates/pdfs-gui/src/app/pages/devices.rs | 282 ++++----- crates/pdfs-gui/src/app/pages/locations.rs | 536 ++++++++++++++++++ crates/pdfs-gui/src/app/pages/mod.rs | 1 + crates/pdfs-gui/src/app/pages/shared.rs | 97 +++- crates/pdfs-gui/src/app/pages/shared_by_me.rs | 27 +- crates/pdfs-gui/src/app/pages/status.rs | 28 +- .../pdfs-gui/src/app/widgets/share_dialog.rs | 159 ++++-- docs/ARCHITECTURE.md | 25 + docs/BUGS.md | 5 +- docs/DEVELOPMENT.md | 5 +- docs/TESTING.md | 29 + scripts/fuse-acceptance.py | 222 +++++++- 19 files changed, 1241 insertions(+), 263 deletions(-) create mode 100644 crates/pdfs-gui/src/app/pages/locations.rs diff --git a/README.md b/README.md index 9ae6b03..ada227f 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc - **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop. - **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI. - **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account. +- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here. - **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls. - **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. - **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages. diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 8f1c3f8..ff1d6b7 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -905,7 +905,15 @@ fn cmd_shared_with_me(uid: Option) -> Result<()> { CtlResponse::Entries { entries } => { for e in entries { let kind = if e.is_dir { "d" } else { "-" }; - println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid); + // My role decides whether this is writable at all, so it is + // printed whenever the API reported one; an empty role prints + // nothing rather than a guess. + let role = if e.role.is_empty() { + String::new() + } else { + format!(" ({})", e.role) + }; + println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid); } } CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 167e938..a3a28fd 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -706,6 +706,12 @@ pub struct DirEntry { /// browser. #[serde(default)] pub path: String, + /// My role on this node when it is reached through a share I accepted — + /// `"viewer"`, `"editor"`, `"admin"`, or empty for content I own (where the + /// question does not arise) and for a share whose role the API did not + /// report. Defaulted for wire-compat with clients/daemons predating it. + #[serde(default)] + pub role: String, } /// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 6aebc3f..7dfe906 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -2956,6 +2956,9 @@ impl Core { // Listing entries live in the requested dir; the caller derives // the path from its name. Left empty. path: String::new(), + // My own tree: a role only means something under a share, and + // this listing never crosses into one. + role: String::new(), }) .collect()) } @@ -3415,6 +3418,7 @@ impl Core { cached: false, uid: item.uid, path: String::new(), + role: String::new(), }) .collect()) } diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index bd5d37c..a385199 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -19,10 +19,15 @@ use proton_drive_rs::Node; use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str}; -/// A node from a *shared* listing as a [`DirEntry`]. Shared nodes live outside -/// the mount, so the entry is uid-addressed: `path` is empty, and the local-state -/// flags (`pinned`, `cached`) are false — they describe my own tree, not this one. -fn shared_entry(n: Node) -> DirEntry { +/// A node from a *shared* listing as a [`DirEntry`]. +/// +/// Still uid-addressed — that is the only handle a foreign-volume node is +/// guaranteed to have — but `path` is filled in when the node is resident under +/// the synthetic `Shared with me/` directory, which is what lets a front-end +/// offer path-keyed actions on it. The local-state flags (`pinned`, `cached`) +/// stay false: they describe content this account owns a local copy policy for, +/// which a share on someone else's volume is not. +fn shared_entry_of(core: &Core, n: Node) -> DirEntry { let is_dir = n.is_folder(); let size = match &n.kind { NodeKind::File { @@ -32,6 +37,13 @@ fn shared_entry(n: Node) -> DirEntry { } => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64, NodeKind::Folder => 0, }; + let role = n + .membership + .as_ref() + .and_then(|m| m.role_exact()) + .map(role_to_str) + .unwrap_or_default() + .to_string(); DirEntry { name: n.name, is_dir, @@ -39,8 +51,9 @@ fn shared_entry(n: Node) -> DirEntry { modified: n.modification_time, pinned: false, cached: false, + path: core.rel_path_for_uid(&n.uid).unwrap_or_default(), uid: n.uid.to_string(), - path: String::new(), + role, } } @@ -371,7 +384,10 @@ impl Core { .rt .block_on(self.client.enumerate_nodes(&uids)) .map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?; - Ok(nodes.into_iter().map(shared_entry).collect()) + Ok(nodes + .into_iter() + .map(|node| shared_entry_of(self, node)) + .collect()) } /// List the children of a folder shared with me, addressed by uid. @@ -393,7 +409,10 @@ impl Core { .rt .block_on(self.client.enumerate_nodes(&child_uids)) .map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?; - Ok(nodes.into_iter().map(shared_entry).collect()) + Ok(nodes + .into_iter() + .map(|node| shared_entry_of(self, node)) + .collect()) } /// Download a file shared with me into the content cache, returning its diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 50a30bb..893f1a0 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -6,6 +6,7 @@ pub(crate) mod widgets; use pages::activity::*; use pages::browser::*; use pages::devices::*; +use pages::locations::*; use pages::login::*; use pages::photo_viewer::*; use pages::photos::*; @@ -49,6 +50,8 @@ use pdfs_core::control::{ send, }; +use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; + use pdfs_core::service; const APP_ID: &str = "io.narl.proton-drive-linux"; @@ -113,6 +116,7 @@ struct Ui { pub(crate) shared: SharedState, pub(crate) shared_by_me: SharedByMeState, pub(crate) devices: DevicesState, + pub(crate) locations: LocationsState, pub(crate) activity: ActivityState, } @@ -333,6 +337,7 @@ fn build_window(app: &adw::Application) { let (shared_page, shared_widgets) = build_shared_page(); let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page(); let (devices_page, devices_widgets) = build_devices_page(); + let (locations_page, locations_widgets) = build_locations_page(); let (activity_page, activity_widgets) = build_activity_page(); let (trash_page, trash_widgets) = build_trash_page(); stack.add_named(&login_page, Some("login")); @@ -342,6 +347,7 @@ fn build_window(app: &adw::Application) { stack.add_named(&shared_by_me_page, Some("sharedbyme")); stack.add_named(&shared_page, Some("shared")); stack.add_named(&devices_page, Some("devices")); + stack.add_named(&locations_page, Some("locations")); stack.add_named(&activity_page, Some("activity")); stack.add_named(&trash_page, Some("trash")); @@ -513,6 +519,15 @@ fn build_window(app: &adw::Application) { inflight: Cell::new(false), loaded_at: Cell::new(None), }, + locations: LocationsState { + content: locations_widgets.content.clone(), + status: locations_widgets.status.clone(), + retry: locations_widgets.retry.clone(), + group: locations_widgets.group.clone(), + rows: RefCell::new(Vec::new()), + inflight: Cell::new(false), + loaded_at: Cell::new(None), + }, activity: ActivityState { content: activity_widgets.content.clone(), status: activity_widgets.status.clone(), @@ -546,12 +561,8 @@ fn build_window(app: &adw::Application) { wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); wire_shared_by_me(&ui, &shared_by_me_widgets.retry); - wire_devices( - &ui, - &devices_widgets.retry, - &devices_widgets.add_folder, - &devices_widgets.restore, - ); + wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore); + wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder); wire_activity(&ui, &activity_widgets.retry); wire_refresh( &ui, @@ -562,6 +573,7 @@ fn build_window(app: &adw::Application) { &shared_widgets.refresh, &shared_by_me_widgets.refresh, &devices_widgets.refresh, + &locations_widgets.refresh, &activity_widgets.refresh, ], ); @@ -584,6 +596,8 @@ fn build_window(app: &adw::Application) { Some("shared") => load_shared(&ui_nav), Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {} Some("devices") => load_devices(&ui_nav), + Some("locations") if page_fresh(&ui_nav.locations.loaded_at) => {} + Some("locations") => load_locations(&ui_nav), // Activity is intentionally not TTL-cached: it changes out from under // the page as background uploads and edits complete, so it reloads on // every visit to stay live. @@ -626,10 +640,11 @@ fn build_window(app: &adw::Application) { /// The sidebar destinations, in order: the row index is the index into this table, /// and each entry is `(stack page name, label, icon)`. -const DESTINATIONS: [(&str, &str, &str); 8] = [ +const DESTINATIONS: [(&str, &str, &str); 9] = [ ("browser", "My files", "folder-symbolic"), ("sharedbyme", "Shared", "emblem-shared-symbolic"), ("shared", "Shared with me", "system-users-symbolic"), + ("locations", "Locations", "drive-harddisk-symbolic"), ("devices", "Computers", "computer-symbolic"), ("gallery", "Photos", "image-x-generic-symbolic"), ("activity", "Activity", "document-open-recent-symbolic"), @@ -748,6 +763,10 @@ fn reload_current_page(ui: &Rc) { ui.devices.loaded_at.set(None); load_devices(ui); } + Some("locations") => { + ui.locations.loaded_at.set(None); + load_locations(ui); + } Some("activity") => load_activity(ui), _ => {} } diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index c02c35a..49b80b4 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -1442,6 +1442,7 @@ pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { cached: false, uid: h.uid.clone(), path: h.path.clone(), + role: String::new(), }) .collect(); entries.sort_by(|a, b| { diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index bda1b36..54cd90a 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -9,6 +9,8 @@ pub(crate) struct DevicesState { pub(crate) group: adw::PreferencesGroup, pub(crate) rows: RefCell>, pub(crate) sync_group: adw::PreferencesGroup, + /// The single "This computer" summary row. The per-folder rows moved to the + /// Locations page, which owns every local path. pub(crate) sync_rows: RefCell>, /// "Rename" action in the "This computer" header. Insensitive until the /// device list identifies this machine's own device. @@ -25,7 +27,8 @@ pub(crate) struct DevicesState { pub(crate) struct DevicesWidgets { pub(crate) content: gtk4::Stack, pub(crate) status: adw::StatusPage, - /// "This computer" — the local folders synced to this machine's device. + /// "This computer" — this machine's device identity and a pointer to its + /// folders on the Locations page. pub(crate) sync_group: adw::PreferencesGroup, /// "Rename" in the "This computer" header — renames this machine's device. pub(crate) rename_this: gtk4::Button, @@ -33,7 +36,6 @@ pub(crate) struct DevicesWidgets { /// machine's own device is deliberately not among them; see /// [`repaint_devices`]. pub(crate) group: adw::PreferencesGroup, - pub(crate) add_folder: gtk4::Button, /// "Restore Folders" — re-attach this device's remote folders to local /// directories after adopting it on a new machine. pub(crate) restore: gtk4::Button, @@ -54,11 +56,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { titles.set_hexpand(true); titles.append(&title); - let add_folder = gtk4::Button::builder() - .label("Add Folder") - .valign(gtk4::Align::Center) - .build(); - add_folder.add_css_class("flat"); let restore = gtk4::Button::builder() .label("Restore Folders") .tooltip_text("Sync this computer's Drive folders back to local directories") @@ -71,18 +68,14 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { header.append(&titles); header.append(&refresh); header.append(&restore); - header.append(&add_folder); - // The description spells out what the per-row On-demand switch does to the - // local copy, because the switch itself can't: turning it on *deletes* the - // files from this disk, which is not something to discover afterwards. + // This page is about device *identity* — which computer this is, which other + // computers back up to the account, and how to adopt one. The folders + // themselves are local paths, so they live on the Locations page; keeping a + // second copy of that list here would be two places to change the same mode. let sync_group = adw::PreferencesGroup::builder() .title("This computer") - .description( - "Local folders backed up to this device. Synced folders keep a full copy on \ - this disk; on-demand folders keep the files in Proton Drive only and fetch \ - them as you open them.", - ) + .description("The device this machine backs up to.") .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 @@ -145,7 +138,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { sync_group, rename_this, group, - add_folder, restore, retry, refresh, @@ -153,20 +145,13 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { ) } -/// Install the Devices page's retry button and the "Add Folder" action. -pub(crate) fn wire_devices( - ui: &Rc, - retry: >k4::Button, - add_folder: >k4::Button, - restore: >k4::Button, -) { +/// Install the Devices page's retry button and the "Restore Folders" action. +pub(crate) fn wire_devices(ui: &Rc, retry: >k4::Button, restore: >k4::Button) { let ui_retry = ui.clone(); retry.connect_clicked(move |_| { service::restart(); load_devices(&ui_retry); }); - let ui_add = ui.clone(); - 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(); @@ -224,9 +209,9 @@ pub(crate) fn load_devices(ui: &Rc) { return; } }; - // Daemon reachable: show the list and paint the synced-folders section. + // Daemon reachable: show the list and paint the "This computer" section. ui.devices.content.set_visible_child_name("list"); - repaint_sync_folders(&ui, &folders); + repaint_this_computer(&ui, &folders); let devices = match devices_reply { Ok(Ok(Response::Devices { items })) => items, @@ -239,35 +224,6 @@ pub(crate) fn load_devices(ui: &Rc) { }); } -/// Refresh just the synced-folders section, with no status flash, no spinner and -/// no devices round-trip. Driven by the periodic tick while the Devices page is -/// on screen: a folder's sync progress is only live if something re-reads it. -/// -/// A hiccup leaves the page as it is — the next tick tries again, and a real -/// outage is reported by [`load_devices`] on the next visit. -pub(crate) fn refresh_sync_folders(ui: &Rc) { - if ui.devices.inflight.get() { - return; - } - ui.devices.inflight.set(true); - let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.devices.inflight.set(false); - let Ok(Ok(Response::SyncFolders { items })) = result else { - return; - }; - // The page may have been navigated away from, or collapsed to a status - // view, while the request was in flight. - if ui.stack.visible_child_name().as_deref() == Some("devices") - && ui.devices.content.visible_child_name().as_deref() == Some("list") - { - repaint_sync_folders(&ui, &items); - } - }); -} - /// The daemon didn't answer the Devices page. pub(crate) fn devices_unreachable(ui: &Rc) { if service::is_failed() || !service::is_active() { @@ -398,137 +354,53 @@ pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String { } } -/// Rebuild the "This computer" section from a fresh synced-folders listing. -pub(crate) fn repaint_sync_folders(ui: &Rc, folders: &[SyncFolderInfo]) { +/// Rebuild the "This computer" section: one row naming the device this machine +/// backs up to, and how many folders it carries. +/// +/// The per-folder rows (mode switch, sync now, remove) live on the Locations +/// page. They are local paths, and Locations is the one page that owns those; +/// two lists of the same folders would be two places to flip the same switch. +pub(crate) fn repaint_this_computer(ui: &Rc, folders: &[SyncFolderInfo]) { for row in ui.devices.sync_rows.borrow_mut().drain(..) { ui.devices.sync_group.remove(&row); } + let row = adw::ActionRow::builder() + .title("This computer") + .subtitle(this_computer_subtitle(folders)) + .build(); + row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); + let manage = gtk4::Button::builder() + .label("Locations") + .tooltip_text("Manage this computer's folders and mountpoint") + .valign(gtk4::Align::Center) + .build(); + manage.add_css_class("flat"); + let ui_go = ui.clone(); + manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations")); + row.add_suffix(&manage); + ui.devices.sync_group.add(&row); + *ui.devices.sync_rows.borrow_mut() = vec![row.upcast()]; +} + +/// How many folders this machine backs up, and whether any of them needs +/// attention — the one fact worth surfacing away from the folder list itself. +pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String { if folders.is_empty() { - let row = adw::ActionRow::builder() - .title("No synced folders") - .subtitle("Add a folder to back it up to this device.") - .build(); - row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); - ui.devices.sync_group.add(&row); - *ui.devices.sync_rows.borrow_mut() = vec![row.upcast()]; - return; + return "No folders backed up yet — add one under Locations.".to_string(); } - let mut rows: Vec = Vec::new(); - for f in folders { - // A running pass describes itself ("Uploading photo.jpg — 12 of 40"); - // otherwise fall back to the folder's resting state. - let status = match &f.progress { - Some(p) => sync_progress_label(p), - None => sync_state_label(&f.state).to_string(), - }; - // A queued switch is where the folder is *going*, which is what the user - // just asked for and is waiting to see happen — so it leads the subtitle - // instead of the mode the folder is still technically in. - let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) { - (Some("ondemand"), _) => format!("Going on-demand · {status}"), - (Some(_), _) => format!("Switching to synced · {status}"), - (None, "ondemand") => format!("On-demand · {status}"), - (None, _) => format!("Synced · {status}"), - }; - let row = adw::ActionRow::builder() - .title(&f.local_path) - .subtitle(&subtitle) - .build(); - row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); - let id = f.id; - - // A pass that knows how far it has got shows it: the subtitle alone - // ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This - // holds for scanning as much as applying — a big folder spends minutes there - // — but a folder syncing for the first time has no estimate to draw against - // (`total == 0`), and this row is repainted on the 2s tick, too slow for a - // pulse to read as motion. So it stays text-only until real counts exist. - if let Some(p) = &f.progress - && p.total > 0 - { - let bar = gtk4::ProgressBar::builder() - .fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0)) - .valign(gtk4::Align::Center) - .width_request(120) - .build(); - row.add_suffix(&bar); - } - - // Sync now — only meaningful for mirror folders (on-demand has no local - // copy to reconcile). The engine also syncs on file changes and on a - // 120s poll; this is the "don't wait" button. - if f.mode != "ondemand" { - let sync_now = gtk4::Button::builder() - .icon_name("view-refresh-symbolic") - .tooltip_text("Sync this folder now") - .valign(gtk4::Align::Center) - .build(); - sync_now.add_css_class("flat"); - let ui_sync = ui.clone(); - sync_now.connect_clicked(move |_| { - let rx = spawn_request( - ui_sync.dirs.control_socket(), - Request::SyncNow { id: Some(id) }, - ); - let ui_sync = ui_sync.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"), - Ok(Ok(Response::Error { message, kind })) => { - toast_failure(&ui_sync, "Couldn't sync", &message, kind) - } - _ => toast_error( - &ui_sync, - "Couldn't sync", - "The mount service didn't respond.", - ), - } - }); - }); - row.add_suffix(&sync_now); - } - - // On-demand toggle: off = full local copy (mirror), on = FUSE mount that - // frees the disk. Set the state before wiring the handler so painting the - // current mode doesn't fire a spurious request. - // - // A queued switch paints as already flipped: the daemon accepted the - // request and will act on it, so snapping the switch back to the current - // mode would read as "it didn't take" and invite the user to toggle again. - let target = f.pending_mode.as_deref().unwrap_or(&f.mode); - let ondemand = gtk4::Switch::builder() - .tooltip_text( - "On-demand: free this disk by keeping the files in Proton Drive only, \ - fetching each as you open it. Turn off to download them back and keep a \ - full local copy.", - ) - .valign(gtk4::Align::Center) - .active(target == "ondemand") - .build(); - let ui_mode = ui.clone(); - ondemand.connect_state_set(move |_, on| { - set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" }); - glib::Propagation::Proceed - }); - row.add_suffix(&ondemand); - - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Stop syncing this folder") - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let ui_rm = ui.clone(); - let path = f.local_path.clone(); - // The folder's *current* mode, not a queued one: a switch that hasn't - // landed yet has not moved the files anywhere. - let ondemand = f.mode == "ondemand"; - remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand)); - row.add_suffix(&remove); - ui.devices.sync_group.add(&row); - rows.push(row.upcast()); + let count = match folders.len() { + 1 => "1 folder backed up".to_string(), + n => format!("{n} folders backed up"), + }; + let attention = folders + .iter() + .filter(|f| f.state == "error" || f.state == "conflict") + .count(); + match attention { + 0 => format!("{count} · manage them under Locations"), + 1 => format!("{count} · 1 needs attention"), + n => format!("{count} · {n} need attention"), } - *ui.devices.sync_rows.borrow_mut() = rows; } /// Human label for a synced folder's `state` column. @@ -592,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc) { // device and creating the remote folder is off-socket network // work. A fixed delay here either fires too early (empty list) // or too late (row flashes in). Instead let the periodic - // `refresh_sync_folders` tick pick the row up whenever it + // `refresh_locations` tick pick the row up whenever it // actually lands, which is what keeps a running pass live too. toast(&ui, "Syncing folder…"); } @@ -942,3 +814,47 @@ pub(crate) fn run_devices_mutation( } }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn folder(state: &str) -> SyncFolderInfo { + SyncFolderInfo { + id: 1, + local_path: "/home/u/Documents".into(), + remote_uid: "vol~link".into(), + mode: "mirror".into(), + state: state.into(), + last_sync: 0, + pending_mode: None, + progress: None, + } + } + + #[test] + fn a_machine_with_no_folders_is_told_where_to_add_one() { + assert_eq!( + this_computer_subtitle(&[]), + "No folders backed up yet — add one under Locations." + ); + } + + #[test] + fn folders_needing_attention_are_counted_not_hidden() { + // The per-folder rows moved to Locations, so this row is the only place + // the Computers page can surface that something is wrong. + assert_eq!( + this_computer_subtitle(&[folder("idle"), folder("idle")]), + "2 folders backed up · manage them under Locations" + ); + assert_eq!( + this_computer_subtitle(&[folder("idle"), folder("error")]), + "2 folders backed up · 1 needs attention" + ); + assert_eq!( + this_computer_subtitle(&[folder("conflict"), folder("error")]), + "2 folders backed up · 2 need attention" + ); + } +} diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs new file mode 100644 index 0000000..6294932 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -0,0 +1,536 @@ +//! The Locations page: every local place Proton Drive occupies on this machine. +//! +//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary +//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is +//! a mirrored local directory or an on-demand FUSE session. The page is called +//! *Locations* rather than *Mounts* because a mirror folder is a plain directory +//! with no FUSE session behind it (mount-architecture.md §4). +//! +//! The device rows drive the same control requests the Computers page used to: +//! mode switch, sync now, remove. What is new here is that the primary mount is +//! listed alongside them, with the mountpoint chooser that used to live in +//! Settings. + +use crate::*; + +pub(crate) struct LocationsState { + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) group: adw::PreferencesGroup, + pub(crate) rows: RefCell>, + pub(crate) inflight: Cell, + pub(crate) loaded_at: Cell>, +} + +/// Widgets the Locations page's load/repaint touch. +pub(crate) struct LocationsWidgets { + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) group: adw::PreferencesGroup, + pub(crate) retry: gtk4::Button, + pub(crate) refresh: gtk4::Button, + pub(crate) add_folder: gtk4::Button, +} + +pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { + let title = gtk4::Label::builder() + .label("Locations") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + + let add_folder = gtk4::Button::builder() + .label("Add Folder") + .tooltip_text("Back a local folder up to this computer's Proton Drive device") + .valign(gtk4::Align::Center) + .build(); + add_folder.add_css_class("flat"); + let refresh = refresh_button(); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + titles.set_hexpand(true); + titles.append(&title); + header.append(&titles); + header.append(&refresh); + header.append(&add_folder); + // Clamped to the same width as the rows below, or the page title floats off + // to the left of the list it names. + let header_clamp = adw::Clamp::builder().child(&header).build(); + + // Same warning the Computers page carried, for the same reason: the + // on-demand switch removes the local copy, which is not a thing to discover + // after the fact. + let group = adw::PreferencesGroup::builder() + .title("On this computer") + .description( + "Where Proton Drive lives on this machine. Synced folders keep a full copy on \ + this disk; on-demand folders keep the files in Proton Drive only and fetch \ + them as you open them.", + ) + .build(); + + let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + groups.append(&group); + let clamp = adw::Clamp::builder().child(&groups).build(); + // Never scroll sideways: a location's title is a full path, and letting the + // row grow to fit one pushes the controls at its end off screen. Constrained, + // the path ellipsizes and the switch/remove/open stay reachable. + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .hscrollbar_policy(gtk4::PolicyType::Never) + .child(&clamp) + .build(); + + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("drive-harddisk-symbolic") + .title("Loading…") + .child(&retry) + .build(); + // A bare `StatusPage` asks for more width than a half-screen window has, and + // a `Stack` is as wide as its widest child — so an unwrapped status page + // silently widens the *list* page too, pushing each row's controls off the + // right edge. Scrolling it (never horizontally) caps that demand. + let status_scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .hscrollbar_policy(gtk4::PolicyType::Never) + .child(&status) + .build(); + + let content = gtk4::Stack::new(); + content.add_named(&scroll, Some("list")); + content.add_named(&status_scroll, Some("status")); + + let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + page.set_margin_top(18); + page.set_margin_bottom(18); + page.set_margin_start(18); + page.set_margin_end(18); + page.append(&header_clamp); + page.append(&content); + + let widgets = LocationsWidgets { + content: content.clone(), + status: status.clone(), + group: group.clone(), + retry: retry.clone(), + refresh, + add_folder, + }; + (page.upcast(), widgets) +} + +pub(crate) fn wire_locations(ui: &Rc, retry: >k4::Button, add_folder: >k4::Button) { + let ui_retry = ui.clone(); + retry.connect_clicked(move |_| { + ui_retry.locations.loaded_at.set(None); + load_locations(&ui_retry); + }); + let ui_add = ui.clone(); + add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); +} + +/// Show a status page in place of the locations list. +pub(crate) fn locations_status( + ui: &Rc, + icon: &str, + title: &str, + description: &str, + retry: bool, +) { + ui.locations.status.set_icon_name(Some(icon)); + ui.locations.status.set_title(title); + ui.locations.status.set_description(Some(description)); + ui.locations.retry.set_visible(retry); + ui.locations.content.set_visible_child_name("status"); +} + +/// Fetch every local location and repaint the list. +pub(crate) fn load_locations(ui: &Rc) { + if ui.locations.inflight.get() { + return; + } + ui.locations.inflight.set(true); + locations_status( + ui, + "drive-harddisk-symbolic", + "Loading…", + "Reading this computer's Proton Drive locations.", + false, + ); + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.locations.inflight.set(false); + match result { + Ok(Ok(Response::Locations { items })) => { + ui.locations.content.set_visible_child_name("list"); + repaint_locations(&ui, &items); + ui.locations.loaded_at.set(Some(Instant::now())); + } + Ok(Ok(Response::Error { message, .. })) => { + ui.locations.loaded_at.set(None); + locations_status( + &ui, + "dialog-warning-symbolic", + "Unavailable", + &message, + true, + ); + } + _ => { + ui.locations.loaded_at.set(None); + locations_unreachable(&ui); + } + } + }); +} + +/// Refresh the rows with no status flash and no spinner, from the periodic tick: +/// a sync pass's progress is only live if something re-reads it. +pub(crate) fn refresh_locations(ui: &Rc) { + if ui.locations.inflight.get() { + return; + } + ui.locations.inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.locations.inflight.set(false); + let Ok(Ok(Response::Locations { items })) = result else { + return; + }; + // The page may have been navigated away from, or collapsed to a status + // view, while the request was in flight. + if ui.stack.visible_child_name().as_deref() == Some("locations") + && ui.locations.content.visible_child_name().as_deref() == Some("list") + { + repaint_locations(&ui, &items); + } + }); +} + +/// The daemon didn't answer the Locations page. +pub(crate) fn locations_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + locations_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + locations_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("locations") { + load_locations(&ui); + } + }); +} + +pub(crate) fn repaint_locations(ui: &Rc, items: &[MountSpec]) { + for row in ui.locations.rows.borrow_mut().drain(..) { + ui.locations.group.remove(&row); + } + if items.is_empty() { + let row = adw::ActionRow::builder() + .title("No locations") + .subtitle("The mount service hasn't reported a mountpoint yet.") + .build(); + row.add_prefix(>k4::Image::from_icon_name("drive-harddisk-symbolic")); + ui.locations.group.add(&row); + *ui.locations.rows.borrow_mut() = vec![row.upcast()]; + return; + } + + let mut rows: Vec = Vec::new(); + for spec in items { + let row = adw::ActionRow::builder() + .title(&spec.local_path) + .subtitle(location_subtitle(spec)) + // One line each: a wrapped path would reflow the whole list every + // time a sync state changed under it. + .title_lines(1) + .subtitle_lines(1) + .build(); + row.add_prefix(>k4::Image::from_icon_name(location_icon(&spec.kind))); + + // A read-only location cannot be written through even where the files are + // visible; saying so on the row is cheaper than letting the user find out + // from a save dialog. + if spec.access == MountAccess::Ro { + let badge = gtk4::Label::new(Some("Read-only")); + badge.add_css_class("dim-label"); + badge.add_css_class("caption"); + badge.set_valign(gtk4::Align::Center); + row.add_suffix(&badge); + } + + // Same rule as the Computers page: a first pass has no estimate to draw + // against, so the bar appears only once real counts exist. + if let Some(p) = &spec.progress + && p.total > 0 + { + let bar = gtk4::ProgressBar::builder() + .fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0)) + .valign(gtk4::Align::Center) + .width_request(120) + .build(); + row.add_suffix(&bar); + } + + match &spec.kind { + MountKind::MyFiles => add_my_files_controls(ui, &row), + MountKind::Device { sync_folder_id } => { + add_device_controls(ui, &row, spec, *sync_folder_id) + } + // A standalone shared mount has no local mode to switch and is not + // this device's to remove. + MountKind::Shared { .. } => {} + } + + let open = gtk4::Button::builder() + .icon_name("folder-open-symbolic") + .tooltip_text("Open this folder") + .valign(gtk4::Align::Center) + .build(); + open.add_css_class("flat"); + let path = spec.local_path.clone(); + open.connect_clicked(move |_| open_path(&path)); + row.add_suffix(&open); + + ui.locations.group.add(&row); + rows.push(row.upcast()); + } + *ui.locations.rows.borrow_mut() = rows; +} + +/// The primary mount's only control: where it lives. Changing it rewrites config +/// and offers a service restart, which is why it stays a single prompt shared +/// with the (now removed) Settings row. +fn add_my_files_controls(ui: &Rc, row: &adw::ActionRow) { + let change = gtk4::Button::builder() + .label("Change") + .tooltip_text("Choose a different folder for the Proton Drive mount") + .valign(gtk4::Align::Center) + .build(); + change.add_css_class("flat"); + let ui_mp = ui.clone(); + change.connect_clicked(move |_| prompt_mountpoint(&ui_mp)); + row.add_suffix(&change); +} + +/// Sync now (mirror only), the on-demand switch, and Remove — the same three +/// controls, and the same handlers, the Computers page used to carry. +fn add_device_controls(ui: &Rc, row: &adw::ActionRow, spec: &MountSpec, id: i64) { + if spec.mode != MountMode::OnDemand { + let sync_now = gtk4::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text("Sync this folder now") + .valign(gtk4::Align::Center) + .build(); + sync_now.add_css_class("flat"); + let ui_sync = ui.clone(); + sync_now.connect_clicked(move |_| sync_folder_now(&ui_sync, id)); + row.add_suffix(&sync_now); + } + + // A queued switch paints as already flipped: the daemon accepted it and will + // act on it, so snapping back would read as "it didn't take". The state is + // set before the handler is wired so painting the current mode doesn't fire + // a spurious request. + let target = spec.pending_mode.unwrap_or(spec.mode); + let ondemand = gtk4::Switch::builder() + .tooltip_text( + "On-demand: free this disk by keeping the files in Proton Drive only, \ + fetching each as you open it. Turn off to download them back and keep a \ + full local copy.", + ) + .valign(gtk4::Align::Center) + .active(target == MountMode::OnDemand) + .build(); + let ui_mode = ui.clone(); + ondemand.connect_state_set(move |_, on| { + set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" }); + glib::Propagation::Proceed + }); + row.add_suffix(&ondemand); + + let remove = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Stop syncing this folder") + .valign(gtk4::Align::Center) + .build(); + remove.add_css_class("flat"); + let ui_rm = ui.clone(); + let path = spec.local_path.clone(); + // The folder's *current* mode, not a queued one: a switch that hasn't landed + // yet has not moved the files anywhere. + let is_ondemand = spec.mode == MountMode::OnDemand; + remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand)); + row.add_suffix(&remove); +} + +/// Ask the daemon for an immediate pass over one folder. +fn sync_folder_now(ui: &Rc, id: i64) { + let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) }); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => toast(&ui, "Syncing folder…"), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't sync", &message, kind) + } + _ => toast_error(&ui, "Couldn't sync", "The mount service didn't respond."), + } + }); +} + +pub(crate) fn location_icon(kind: &MountKind) -> &'static str { + match kind { + MountKind::MyFiles => "folder-remote-symbolic", + MountKind::Device { .. } => "folder-symbolic", + MountKind::Shared { .. } => "system-users-symbolic", + } +} + +/// One line describing what a location *is* and what it is doing. +/// +/// Ordered by how likely it is to be what the user came to check: a queued mode +/// switch leads (they just asked for it), then the resting mode, then the sync +/// state, then — only when it is surprising — the fact that no session owns the +/// path. A mirror folder is a plain directory with no FUSE session, so "not +/// mounted" is its normal state and saying it would be noise. +pub(crate) fn location_subtitle(spec: &MountSpec) -> String { + let mut parts: Vec = Vec::new(); + match &spec.kind { + MountKind::MyFiles => parts.push("My files".to_string()), + MountKind::Shared { .. } => parts.push("Shared folder".to_string()), + MountKind::Device { .. } => match (spec.pending_mode, spec.mode) { + (Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()), + (Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()), + (Some(MountMode::Unknown) | None, MountMode::OnDemand) => { + parts.push("On-demand".to_string()) + } + (Some(MountMode::Unknown) | None, _) => parts.push("Synced".to_string()), + }, + } + if matches!(spec.kind, MountKind::Device { .. }) { + parts.push(match &spec.progress { + Some(p) => sync_progress_label(p), + None => sync_state_label(&spec.state).to_string(), + }); + } + // Only the locations that are supposed to have a session report its absence: + // a mirror folder never has one. + let expects_session = !matches!(spec.kind, MountKind::Device { .. }) + || spec.mode == MountMode::OnDemand + || spec.pending_mode == Some(MountMode::OnDemand); + if expects_session && !spec.mounted { + parts.push("not mounted".to_string()); + } + parts.join(" · ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(kind: MountKind, mode: MountMode, mounted: bool) -> MountSpec { + MountSpec { + id: 1, + kind, + local_path: "/home/u/ProtonDrive".into(), + root_uid: "vol~link".into(), + root_share_id: "share".into(), + mode, + access: MountAccess::Rw, + state: "idle".into(), + last_sync: 0, + pending_mode: None, + mounted, + progress: None, + } + } + + #[test] + fn a_mirror_folder_is_never_reported_as_unmounted() { + // It is a plain local directory: no FUSE session is expected, so saying + // "not mounted" would describe every healthy mirror folder as broken. + let spec = spec( + MountKind::Device { sync_folder_id: 7 }, + MountMode::Mirror, + false, + ); + assert_eq!(location_subtitle(&spec), "Synced · up to date"); + } + + #[test] + fn an_on_demand_folder_without_a_session_says_so() { + let spec = spec( + MountKind::Device { sync_folder_id: 7 }, + MountMode::OnDemand, + false, + ); + assert_eq!( + location_subtitle(&spec), + "On-demand · up to date · not mounted" + ); + } + + #[test] + fn a_queued_switch_leads_the_subtitle() { + let mut spec = spec( + MountKind::Device { sync_folder_id: 7 }, + MountMode::Mirror, + true, + ); + spec.pending_mode = Some(MountMode::OnDemand); + assert!(location_subtitle(&spec).starts_with("Going on-demand")); + spec.mode = MountMode::OnDemand; + spec.pending_mode = Some(MountMode::Mirror); + assert!(location_subtitle(&spec).starts_with("Switching to synced")); + } + + /// An unrecognised persisted mode must not silently read as "Synced" *and* + /// must not lose the rest of the line. + #[test] + fn the_primary_mount_reports_only_its_own_facts() { + let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true); + assert_eq!(location_subtitle(&spec), "My files"); + spec.mounted = false; + assert_eq!(location_subtitle(&spec), "My files · not mounted"); + } + + #[test] + fn sync_state_reaches_the_subtitle() { + let mut spec = spec( + MountKind::Device { sync_folder_id: 2 }, + MountMode::Mirror, + true, + ); + spec.state = "conflict".into(); + assert_eq!(location_subtitle(&spec), "Synced · needs attention"); + } +} diff --git a/crates/pdfs-gui/src/app/pages/mod.rs b/crates/pdfs-gui/src/app/pages/mod.rs index 1ff8658..8b13b7a 100644 --- a/crates/pdfs-gui/src/app/pages/mod.rs +++ b/crates/pdfs-gui/src/app/pages/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod activity; pub(crate) mod browser; pub(crate) mod devices; +pub(crate) mod locations; pub(crate) mod login; pub(crate) mod photo_viewer; pub(crate) mod photos; diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 95c3a6f..0693122 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -298,8 +298,15 @@ fn shared_entry_row(ui: &Rc, entry: &DirEntry) -> adw::ActionRow { .title(&entry.name) .activatable(true) .build(); - if !entry.is_dir { - row.set_subtitle(&human_bytes(entry.size)); + let subtitle = shared_row_subtitle(entry); + if !subtitle.is_empty() { + row.set_subtitle(&subtitle); + } + // What I am allowed to do with someone else's file is the thing this page + // cannot leave implicit: a viewer share is browsable and unwritable, and + // that is only visible if it is said. + if let Some(badge) = role_badge(&entry.role) { + row.add_suffix(&badge); } row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir { "folder-symbolic" @@ -325,6 +332,43 @@ fn shared_entry_row(ui: &Rc, entry: &DirEntry) -> adw::ActionRow { row } +/// A shared row's subtitle: its size (files only), and where it can be reached +/// in the local mount once the synthetic `Shared with me/` directory has interned +/// it. An unlisted share has no local path yet, and says nothing rather than +/// pointing at a directory that is not there. +pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String { + let mut parts: Vec = Vec::new(); + if !entry.is_dir { + parts.push(human_bytes(entry.size)); + } + if !entry.path.is_empty() { + parts.push(format!("in {}", entry.path)); + } + parts.join(" · ") +} + +/// The display name for a wire role, or `None` when there is nothing to show: +/// owned content carries no role, and a role the API did not report is not +/// guessed at (the SDK's `from_permissions_exact` returns `None` for an +/// unrecognised mask rather than degrading it to viewer). +pub(crate) fn role_label(role: &str) -> Option<&'static str> { + match role { + "viewer" => Some("Viewer"), + "editor" => Some("Editor"), + "admin" => Some("Admin"), + _ => None, + } +} + +/// A dim role pill for a share I did not create. +pub(crate) fn role_badge(role: &str) -> Option { + let badge = gtk4::Label::new(Some(role_label(role)?)); + badge.add_css_class("dim-label"); + badge.add_css_class("caption"); + badge.set_valign(gtk4::Align::Center); + Some(badge) +} + /// Download a file shared with me into the daemon's cache and hand it to the /// user's default application — the shared-item twin of the browser's /// download-and-open, addressed by uid because the file lives outside the mount. @@ -663,3 +707,52 @@ pub(crate) fn run_shared_mutation( } }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(is_dir: bool, path: &str, role: &str) -> DirEntry { + DirEntry { + name: "Budget.ods".into(), + is_dir, + size: 2048, + modified: 0, + pinned: false, + cached: false, + uid: "vol~link".into(), + path: path.into(), + role: role.into(), + } + } + + #[test] + fn a_share_with_no_local_path_says_nothing_about_one() { + // Before the synthetic directory has been listed, the daemon cannot + // resolve a path — pointing at one that isn't there would be worse than + // silence. + assert_eq!(shared_row_subtitle(&entry(true, "", "viewer")), ""); + assert_eq!( + shared_row_subtitle(&entry(false, "", "viewer")), + human_bytes(2048) + ); + } + + #[test] + fn a_resident_share_reports_where_it_can_be_reached() { + let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor")); + assert_eq!(subtitle, "in Shared with me/Team Budget"); + } + + #[test] + fn only_a_known_role_becomes_a_badge() { + // "" is owned content (no role applies) and an unrecognised mask is + // deliberately not guessed at — `from_permissions_exact` returns None + // rather than degrading to viewer, and this must not undo that. + for role in ["viewer", "editor", "admin"] { + assert!(role_label(role).is_some(), "{role} should have a label"); + } + assert!(role_label("").is_none()); + assert!(role_label("inherited").is_none()); + } +} diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index a5b2b97..a465450 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -236,19 +236,18 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { row.add_suffix(&open); } - // Manage opens the per-node Share dialog — only when the node's path is - // known (it has been browsed to this session); the dialog is path-keyed. - if !item.path.is_empty() { - let manage = gtk4::Button::builder() - .label("Manage") - .valign(gtk4::Align::Center) - .build(); - manage.add_css_class("flat"); - let ui_manage = ui.clone(); - let entry = shared_item_as_entry(item); - manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); - row.add_suffix(&manage); - } + // Manage opens the per-node Share dialog. It used to be offered only for + // an item whose path the daemon could resolve; the dialog now addresses a + // pathless node by uid, so every shared item can be managed from here. + let manage = gtk4::Button::builder() + .label("Manage") + .valign(gtk4::Align::Center) + .build(); + manage.add_css_class("flat"); + let ui_manage = ui.clone(); + let entry = shared_item_as_entry(item); + manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); + row.add_suffix(&manage); ui.shared_by_me.group.add(&row); rows.push(row.upcast()); @@ -295,5 +294,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry { cached: false, uid: item.uid.clone(), path: item.path.clone(), + // Shared *by* me: I own it, so there is no role of mine to report. + role: String::new(), } } diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index fdd9536..11e63ed 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -31,8 +31,8 @@ pub(crate) struct StatusState { /// 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, - /// Shows the active mountpoint in its subtitle; updated when the user picks a - /// new folder. + /// Shows where the primary mount lives; the folder itself is managed on the + /// Locations page, which owns every local path. pub(crate) mountpoint_row: adw::ActionRow, /// Set while a settings widget is being populated programmatically, so its /// change handler skips the IPC/systemd side effect. @@ -100,7 +100,8 @@ pub(crate) struct MainWidgets { pub(crate) budget_row: adw::SpinRow, /// Purges all unpinned cached content. pub(crate) purge_button: gtk4::Button, - /// Shows the active mountpoint; its suffix button opens a folder chooser. + /// Shows the active mountpoint; its suffix button opens the Locations page, + /// which is where the folder is changed. pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_button: gtk4::Button, } @@ -215,12 +216,17 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { .subtitle("Mount Proton Drive automatically when you log in.") .build(); system_group.add(&autostart_row); + // The mountpoint is a *location*, and every other local path this client + // owns is managed on the Locations page. Keeping a second chooser here would + // be a second source of truth for the same setting, so this row reports the + // path and hands the change over. let mountpoint_row = adw::ActionRow::builder() .title("Mountpoint") .subtitle("—") .build(); let mountpoint_button = gtk4::Button::builder() - .label("Change") + .label("Locations") + .tooltip_text("Manage this computer's Proton Drive locations") .valign(gtk4::Align::Center) .build(); mountpoint_button.add_css_class("flat"); @@ -372,10 +378,10 @@ pub(crate) fn wire_settings( } }); - // Mountpoint: pick a folder, persist it, and offer to restart the mount so - // the change takes effect (the daemon reads the path on mount). + // Mountpoint: the chooser lives on the Locations page, next to every other + // local path; this button is the way there. let ui_mp = ui.clone(); - mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp)); + mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations")); } /// Run a settings control-socket round-trip (budget / purge) on a worker thread, @@ -423,6 +429,12 @@ pub(crate) fn prompt_mountpoint(ui: &Rc) { return; } ui.status.mountpoint_row.set_subtitle(&path_str); + // The Locations page shows the same path as a row title; its cached rows + // are now stale whether or not it is the visible page. + ui.locations.loaded_at.set(None); + 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() @@ -503,7 +515,7 @@ pub(crate) fn refresh(ui: &Rc) { // 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("locations") => refresh_locations(ui), Some("activity") => refresh_activity(ui), _ => {} } diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index ef90981..fbee202 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -21,13 +21,117 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 { } } +/// How an open Share dialog addresses its node. +/// +/// A node the browser is showing has a mountpoint-relative path. A node reached +/// from Shared or Shared-with-me may not: it can live under a device folder's +/// own inode space, or on someone else's volume, neither of which the primary +/// mount's path space can name. Those carry a uid instead, and the daemon +/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4). +/// +/// The two are separate request variants rather than one request with an +/// optional field on purpose: an older daemon that ignored `uid` would resolve +/// the empty path to the *mount root* and act on the wrong node. +pub(crate) enum ShareTarget { + Path(String), + Uid(String), +} + +impl ShareTarget { + fn list(&self) -> Request { + match self { + ShareTarget::Path(path) => Request::ListShare { path: path.clone() }, + ShareTarget::Uid(uid) => Request::ListShareByUid { uid: uid.clone() }, + } + } + + fn invite(&self, emails: Vec, role: String, message: Option) -> Request { + match self { + ShareTarget::Path(path) => Request::ShareNode { + path: path.clone(), + emails, + role, + message, + }, + ShareTarget::Uid(uid) => Request::ShareNodeByUid { + uid: uid.clone(), + emails, + role, + message, + }, + } + } + + fn update_role(&self, id: String, kind: ShareEntryKind, role: String) -> Request { + match self { + ShareTarget::Path(path) => Request::UpdateShareRole { + path: path.clone(), + id, + kind, + role, + }, + ShareTarget::Uid(uid) => Request::UpdateShareRoleByUid { + uid: uid.clone(), + id, + kind, + role, + }, + } + } + + fn remove_entry(&self, id: String, kind: ShareEntryKind) -> Request { + match self { + ShareTarget::Path(path) => Request::RemoveShareEntry { + path: path.clone(), + id, + kind, + }, + ShareTarget::Uid(uid) => Request::RemoveShareEntryByUid { + uid: uid.clone(), + id, + kind, + }, + } + } + + fn create_link(&self, role: String, password: Option) -> Request { + match self { + ShareTarget::Path(path) => Request::CreatePublicLink { + path: path.clone(), + role, + password, + expires: None, + }, + ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid { + uid: uid.clone(), + role, + password, + expires: None, + }, + } + } + + fn remove_link(&self, id: String) -> Request { + match self { + ShareTarget::Path(path) => Request::RemovePublicLink { + path: path.clone(), + id, + }, + ShareTarget::Uid(uid) => Request::RemovePublicLinkByUid { + uid: uid.clone(), + id, + }, + } + } +} + /// The mutable state behind an open Share dialog, so the invite/role/link /// handlers can rebuild the people and link sections after each change without /// tearing the whole dialog down. pub(crate) struct ShareDialog { pub(crate) ui: Rc, - /// The node's mountpoint-relative path — how every request addresses it. - pub(crate) rel: String, + /// How every request from this dialog addresses the node. + pub(crate) target: ShareTarget, pub(crate) people: adw::PreferencesGroup, pub(crate) link_group: adw::PreferencesGroup, pub(crate) people_rows: RefCell>, @@ -41,7 +145,13 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { toast_error(ui, "Can't share", "Proton Drive isn't connected."); return; } - let rel = entry_rel(ui, entry); + // A node with no path is not a bug here: Shared and Shared-with-me list + // nodes that the primary mount never interned. Address those by uid. + let target = if entry.path.is_empty() { + ShareTarget::Uid(entry.uid.clone()) + } else { + ShareTarget::Path(entry_rel(ui, entry)) + }; let toolbar = adw::ToolbarView::new(); let header = adw::HeaderBar::new(); @@ -110,7 +220,7 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { let state = Rc::new(ShareDialog { ui: ui.clone(), - rel, + target, people, link_group, people_rows: RefCell::new(Vec::new()), @@ -144,12 +254,7 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { let msg_clear = message_row.clone(); share_dialog_op( &state_inv, - Request::ShareNode { - path: state_inv.rel.clone(), - emails, - role, - message, - }, + state_inv.target.invite(emails, role, message), "Invitations sent", "Couldn't send invitations", Some(Box::new(move || { @@ -165,12 +270,7 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { /// Re-fetch the node's share and rebuild the people + public-link sections. pub(crate) fn share_dialog_reload(state: &Rc) { - let rx = spawn_request( - state.ui.dirs.control_socket(), - Request::ListShare { - path: state.rel.clone(), - }, - ); + let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list()); let state = state.clone(); glib::spawn_future_local(async move { match rx.recv().await { @@ -232,12 +332,11 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr drop.connect_selected_notify(move |d| { share_dialog_op( &state_role, - Request::UpdateShareRole { - path: state_role.rel.clone(), - id: id.clone(), + state_role.target.update_role( + id.clone(), kind, - role: role_index_to_wire(d.selected()).to_string(), - }, + role_index_to_wire(d.selected()).to_string(), + ), "Role updated", "Couldn't update the role", None, @@ -265,11 +364,7 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr remove.connect_clicked(move |_| { share_dialog_op( &state_rm, - Request::RemoveShareEntry { - path: state_rm.rel.clone(), - id: id.clone(), - kind, - }, + state_rm.target.remove_entry(id.clone(), kind), "Access removed", "Couldn't remove access", None, @@ -332,10 +427,7 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi remove.connect_clicked(move |_| { share_dialog_op( &state_rm, - Request::RemovePublicLink { - path: state_rm.rel.clone(), - id: id.clone(), - }, + state_rm.target.remove_link(id.clone()), "Public link removed", "Couldn't remove the link", None, @@ -407,12 +499,7 @@ pub(crate) fn share_dialog_create_link( ) { let rx = spawn_request( state.ui.dirs.control_socket(), - Request::CreatePublicLink { - path: state.rel.clone(), - role, - password, - expires: None, - }, + state.target.create_link(role, password), ); let state = state.clone(); glib::spawn_future_local(async move { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e4c7c3a..e86a756 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -76,6 +76,31 @@ sequenceDiagram FUSE-->>User: returns child inode metadata (attributes, TTL) ``` +### Access Classification and Enforcement + +Every `Entry` carries an `Access` (`Owner | Editor | Viewer | Unknown`), inherited from its parent at intern time rather than resolved per node — a child is always interned from its parent's listing, so one edge lookup answers it. A known share root takes its access from the persisted `share_access` table instead, which is also what makes the classification correct offline. + +Two rules decide the cases inheritance cannot: + +* **Not under a share, no role → `Owner` (fail open).** My Files and device folders are owned content; regressing this denies ordinary writes. +* **Under a share with no usable role → `Viewer` (fail closed).** An unrecognised permission mask is never degraded into a guess. + +A node whose parent is not resident is the awkward case: a device folder's parent is the device root, which is never persisted as a node, so the whole subtree hydrates parentless. It resolves the way the persisted authority (`Db::effective_node_access`) does — a recorded share row above it wins, otherwise fail open on the mount's own volume and closed on a foreign one (`docs/BUGS.md` B79). + +Enforcement is three layers, and only the third closes B34: + +1. **Mode bits.** `attr()` returns `0o555`/`0o444` for a non-writable entry, and both mount paths set `MountOption::DefaultPermissions`, so the *kernel* refuses `open(O_WRONLY)`, `access(W_OK)` and namespace operations for any non-root process. +2. **Handler gates.** `EACCES` in `create`/`mkdir`/`unlink`/`rmdir`/`rename` (parent-writable; both parents for a rename) and in `open`-for-write/`write`/`setattr`/`fallocate`. Covers root and stale attribute TTLs. +3. **Queue guards.** `Core::require_uid_writable` admits a mutation only when the persisted authority *and* every live inode space agree the uid is writable. Nothing reaches `pending_op` otherwise, so the perpetual failing drain that B34 describes cannot occur even if a handler check is missed. The intersection is across mounts because a uid may be resident in more than one inode space. + +`EACCES`, not `EROFS`, for a read-only subtree inside a read-write mount: `EROFS` means "read-only filesystem" and misleads the heuristics in `cp`, `rsync` and `git`. + +### Local Locations + +`mount` is a presentation table: one row per local place this client occupies — the primary My Files session, and each device folder in mirror or on-demand mode. `sync_folder` remains the sync engine's own table (`sync_entry` is FK'd to it) and `MountKind::Device { sync_folder_id }` is the join. `Request::ListLocations` serves the table to `pdfs locations` and the GUI's Locations page. + +A mirror folder is a plain local directory with no FUSE session, so a row can legitimately describe a location that is not mounted — which is why the page is called *Locations* rather than *Mounts*. The primary mountpoint stays in `AppConfig`, with its `mount` row written at daemon start as a projection of it, so there are not two sources of truth for that path. + --- ## 3. Read Path & Block Caching Pipeline diff --git a/docs/BUGS.md b/docs/BUGS.md index 2ce8689..abfa9bf 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -66,7 +66,10 @@ device-folder tree to `Viewer` in memory. **Tests:** `state::tests::own_volume_nodes_without_a_resident_parent_stay_owned`, `foreign_volume_nodes_without_a_resident_parent_fail_closed`, `persisted_authority_outranks_the_own_volume_fail_open`. The first fails on the -pre-fix branch with `left: Unknown, right: Owner`. +pre-fix branch with `left: Unknown, right: Owner`. The standing live guard is the +`regression B79` acceptance case, which finds a mounted on-demand device folder +through `pdfs locations --json` and writes into it (skipping when this machine +has none). **Verified live (2026-07-31):** the packaged 1.2.1 daemon denies `touch ~/Documents/pdfs-b79-test`, `mkdir`, and `>` redirect while diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 48a7a75..b59df48 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -58,7 +58,9 @@ Derived from: [`BUGS.md`](BUGS.md) * **B56–B60 — fail-closed state and IPC fixes**: Future schemas, event cursor advancement, socket limits, private-directory validation, and atomic config saves are implemented. Malformed-schema, database-full, connection-flood, wrong-owner, and concurrent/faulted-save verification remains. * **B61 — credential rotation persistence**: Surface and retry keyring persistence failures so a rotated single-use refresh token cannot be lost on restart. * **B62–B65 — release assurance**: Clean-install package verification, complete version/protocol identity, recovery sign-off, advisory/license policy, SBOM, provenance, checksums, and signatures remain. -* **B66–B68 — lifecycle and recovery**: Add queue flush/shutdown protection, explicit device adoption during fresh-state restore, and move profile backup below a writable remote folder. +* **B66–B67 — lifecycle and recovery**: Add queue flush/shutdown protection and explicit device adoption during fresh-state restore. +* **B68/B78 — profile backup**: the destination is fixed and verified live — `profile.json` now lives in `.proton-drive-linux/` under the device root, since the API refuses file creation at the root itself. What remains from B68 is making backup *health* visible (today a failure is a `WARN` in the log and nothing in the UI) and re-running the replacement-machine restore end to end. +* **B79 — cross-mount permission classification**: fixed and verified live; the standing guard is the `regression B79` acceptance case. The second-account cases it neighbours (`regression B34`) still cannot run — see below. --- @@ -76,6 +78,7 @@ Derived from: [`TESTING.md`](TESTING.md) and [`RECOVERY.md`](RECOVERY.md) 3. Wipe the local state directory and SQLite cache. 4. Restart the daemon. 5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte. +* **Second-account sharing run (mount-architecture.md P7)**: The only outstanding verification for the mount/sharing work. One account covers `context_share_id`, the shared-with-me listing, sharing from device folders and the Locations page — all confirmed. A **second account** is required for the shared-tree rendering under a foreign volume, `accept_invitation`, `update_member_role`, the `regression B34` viewer cases (which skip cleanly today), and above all an **editor-role write into a foreign share**, the only thing that validates `membership_address_for`. * **Managed live mode matrix**: Before release, run `scripts/fuse-acceptance.sh --managed-live ` with `PDFS_ACCEPTANCE_ONLY` unset. It validates on-demand/on-demand, on-demand/mirror, mirror/on-demand, and mirror/mirror transitions. * **Crash-consistency matrix**: Inject failure after scratch/sidecar sync, each staging rename, directory sync, pending-op insert/commit, and source deletion; prove that a complete discoverable copy and either the old or new queue record always survives. diff --git a/docs/TESTING.md b/docs/TESTING.md index aad5bb5..967c2ab 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -83,6 +83,31 @@ guard, and it was found by exactly this mechanism: the B70 case failed, and narrowing it produced a smaller reproduction that has its own case now. Expect a red run against a real mount until it is fixed. +**B79** (an on-demand device folder accepts writes) discovers its target from +`pdfs locations --json` and skips when this machine has no mounted on-demand +folder. It is the cheap standing guard against a *cross-mount* permission +regression: the primary mount holds device-folder nodes too, and the queue guard +intersects what every live inode space says about a uid, so a fail-closed +classification in one mount denies writes in another. + +**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share +still writes) locate their subject through `pdfs shared-with-me --json`, which +now carries `role` and a mount-relative `path`. Both skip when the account has no +accepted share at that role — B34 needs a **second account** to have shared a +folder read-only, which is the one part of the sharing suite that cannot be +self-served. B34b degrades gracefully in the other direction: a shared *file* +gets its mode bits and `W_OK` checked but is never written to, because it is +someone else's document. + +B34's fourth assertion is the load-bearing one: `pending_uploads` and +`pending_changes` must be **unchanged** across the refused writes. Mode bits +alone would pass a fix that still admitted the write into `pending_op`, which is +the actual harm — a background drain failing 403 forever. + +**Synthetic `Shared with me/` directory contract** asserts the virtual directory +enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when +the directory is absent (an account with no accepted shares). + A regression case asserts against the *remote* outcome, so it must wait for the queue to drain before reading. Reading straight back through the mount proves nothing: the local attribute and content caches will happily return the bytes @@ -149,6 +174,10 @@ During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A release acceptance run must leave it unset so the complete contract executes. +A filter naming a live-only case (every `regression B` is one) selects nothing +in the account-free reference run, which is not an error — that target simply has +nothing to do. Only a filter matching *no case at all* fails, as the typo it is. + Passing more paths runs the same filesystem suite independently against each. The first path must be the FUSE mount under test; later paths may be another FUSE mount or a normal local mirror filesystem: diff --git a/scripts/fuse-acceptance.py b/scripts/fuse-acceptance.py index f7e29f3..d33b254 100755 --- a/scripts/fuse-acceptance.py +++ b/scripts/fuse-acceptance.py @@ -1075,6 +1075,208 @@ def test_regression_b74_rename_after_close(ctx: Context) -> None: ctx.record("digest", hashlib.sha256(read(final)).hexdigest()) +SHARED_DIR_NAME = "Shared with me" + + +def _locations(daemon: Daemon) -> list[dict]: + """Every local Proton Drive location the daemon reports.""" + return json.loads(daemon.command("locations", json_output=True)).get("items", []) + + +def _shared_with_me(daemon: Daemon) -> list[dict]: + return json.loads(daemon.command("shared-with-me", json_output=True)).get("entries", []) + + +def _shared_root(daemon: Daemon, mount: Path, role: str) -> Path: + """The local path of an accepted share held at `role`. + + Skips rather than fails when the account has no such share: the role cases + need a *second* account to have shared something at that role, which most + runs will not have (mount-architecture.md §7). + """ + entries = [e for e in _shared_with_me(daemon) if e.get("role") == role] + if not entries: + raise Skip(f"no accepted {role}-role share on this account") + for entry in entries: + # `path` is mount-relative and filled in only once the synthetic + # directory has interned the node, so list it first. + with contextlib.suppress(OSError): + os.listdir(mount / SHARED_DIR_NAME) + relative = entry.get("path") or "" + if relative: + candidate = mount / relative + if candidate.exists(): + return candidate + raise Skip(f"{role}-role share is not resident under {SHARED_DIR_NAME}/") + + +def test_regression_b34_viewer_share_is_read_only(ctx: Context) -> None: + """A viewer-role share must be read-only *and* queue nothing (B34). + + The harm B34 names is not the mode bits: it is that a local write into a + read-only share was accepted, entered `pending_op`, and then failed 403 in + the drain forever. So step 4 — the queue depth being unchanged across the + attempts — is the assertion that distinguishes the fix from cosmetics. + """ + daemon = ctx.require_daemon() + mount = daemon.enclosing_mount(ctx.root) + root = _shared_root(daemon, mount, "viewer") + + before = daemon.status().get("mount") or {} + mode = stat.S_IMODE(os.stat(root).st_mode) + check(mode == 0o555, f"viewer share is {mode:o}, expected 555") + check(not os.access(root, os.W_OK), "viewer share reports itself writable") + + target = root / "b34-should-not-exist" + expect_errno({errno.EACCES}, lambda: os.mkdir(target), "mkdir in a viewer share") + expect_errno( + {errno.EACCES}, + lambda: os.open(target, os.O_CREAT | os.O_WRONLY, 0o600), + "create in a viewer share", + ) + for child in sorted(root.iterdir())[:1]: + child_mode = stat.S_IMODE(os.stat(child).st_mode) + expected = 0o555 if child.is_dir() else 0o444 + check(child_mode == expected, f"{child.name} is {child_mode:o}, expected {expected:o}") + expect_errno( + {errno.EACCES}, + lambda: os.open(child, os.O_WRONLY), + "open-for-write in a viewer share", + ) + expect_errno( + {errno.EACCES}, + lambda: os.rename(child, child.with_name("b34-renamed")), + "rename in a viewer share", + ) + expect_errno( + {errno.EACCES}, + lambda: (os.rmdir(child) if child.is_dir() else os.unlink(child)), + "remove from a viewer share", + ) + + after = daemon.status().get("mount") or {} + for key in ("pending_uploads", "pending_changes"): + check( + before.get(key, 0) == after.get(key, 0), + f"a refused write changed {key}: {before.get(key)} -> {after.get(key)}", + ) + ctx.record("viewer.mode", f"{mode:o}") + ctx.record("viewer.queue_unchanged", True) + + +def test_regression_b34b_editor_share_still_writes(ctx: Context) -> None: + """The fail-open half of B34: an editor share stays writable. + + A permission model that denies everything would pass the viewer case and + break the feature, so this is the guard that the enforcement is scoped. + """ + daemon = ctx.require_daemon() + mount = daemon.enclosing_mount(ctx.root) + root = _shared_root(daemon, mount, "editor") + + is_dir = root.is_dir() + mode = stat.S_IMODE(os.stat(root).st_mode) + expected = 0o755 if is_dir else 0o644 + check(mode == expected, f"editor share is {mode:o}, expected {expected:o}") + check(os.access(root, os.W_OK), "editor share reports itself unwritable") + ctx.record("editor.mode", f"{mode:o}") + + # A shared *file* is someone else's document: proving the mode bits and the + # kernel's own W_OK answer is as far as this case goes. Only a shared folder + # gets a real write, and only into a file this suite created. + if not is_dir: + ctx.note("editor.write", "skipped: the editor share is a file") + return + + payload = b"b34b-editor-write\n" * 64 + target = root / "b34b-editor-write.bin" + write_durable(target, payload) + daemon.wait_for_queue() + try: + check_bytes(read(target), payload, "editor-share write did not read back") + finally: + with contextlib.suppress(OSError): + os.unlink(target) + daemon.wait_for_queue() + + +def test_shared_directory_contract(ctx: Context) -> None: + """The synthetic `Shared with me/` directory behaves like a real directory. + + It is a real `Node` on a reserved volume rather than a FUSE special case, so + it must enumerate, stat and refuse mutation like any other read-only folder — + and must not be creatable or removable by the user. + """ + daemon = ctx.require_daemon() + mount = daemon.enclosing_mount(ctx.root) + shared = mount / SHARED_DIR_NAME + if not shared.is_dir(): + raise Skip(f"{SHARED_DIR_NAME}/ is not present in this mount") + + mode = stat.S_IMODE(os.stat(shared).st_mode) + check(mode == 0o555, f"{SHARED_DIR_NAME}/ is {mode:o}, expected 555") + # Enumerating must not raise: it is the operation the whole feature exists for. + names = sorted(os.listdir(shared)) + expect_errno( + {errno.EACCES}, + lambda: os.mkdir(shared / "b34-synthetic-child"), + f"mkdir inside {SHARED_DIR_NAME}/", + ) + expect_errno( + {errno.EACCES, errno.ENOTEMPTY, errno.EBUSY}, + lambda: os.rmdir(shared), + f"rmdir {SHARED_DIR_NAME}/", + ) + expect_errno( + {errno.EACCES, errno.EEXIST}, + lambda: os.rename(shared, mount / "b34-renamed-shared"), + f"rename {SHARED_DIR_NAME}/", + ) + ctx.record("shared.mode", f"{mode:o}") + ctx.note("shared.entries", len(names)) + + +def test_regression_b79_ondemand_location_accepts_writes(ctx: Context) -> None: + """An on-demand device folder must accept writes (B79). + + Every mount holds its own inode space, and the queue guard intersects the + access each one reports. The primary mount hydrates the device folder's + nodes parentless (their device root is never a persisted node), so a + fail-closed classification there denied every write in the *secondary* + mount while its own state said Owner. The symptom is EACCES on a plain + `touch` in `~/Documents` while `~/ProtonDrive` accepts one. + """ + daemon = ctx.require_daemon() + locations = [ + location + for location in _locations(daemon) + if location.get("kind", {}).get("kind") == "device" + and location.get("mode") == "ondemand" + and location.get("mounted") + and location.get("access") == "rw" + ] + if not locations: + raise Skip("no mounted on-demand device folder on this machine") + + root = Path(locations[0]["local_path"]) + payload = b"b79-ondemand-write\n" * 128 + directory = root / "pdfs-b79-acceptance" + target = directory / "write.bin" + try: + os.mkdir(directory) + write_durable(target, payload) + daemon.wait_for_queue() + check_bytes(read(target), payload, "on-demand write did not read back") + finally: + with contextlib.suppress(OSError): + os.unlink(target) + with contextlib.suppress(OSError): + os.rmdir(directory) + with contextlib.suppress(Exception): + daemon.wait_for_queue() + ctx.record("b79.location", str(root)) + + def _conflict_copies(root: Path) -> list[str]: return sorted(name for name in os.listdir(root) if "(sync-conflict" in name) @@ -1132,6 +1334,10 @@ TESTS = [ Case("regression B69: identical rewrite makes no conflict", test_regression_b69_identical_rewrite, (LIVE,)), Case("regression B70: transient download name is not sealed", test_regression_b70_transient_download_name, (LIVE,)), Case("regression B74: rename after close keeps its content", test_regression_b74_rename_after_close, (LIVE,)), + Case("regression B34: a viewer share is read-only and queues nothing", test_regression_b34_viewer_share_is_read_only, (LIVE,)), + Case("regression B34b: an editor share still writes", test_regression_b34b_editor_share_still_writes, (LIVE,)), + Case("synthetic Shared-with-me directory contract", test_shared_directory_contract, (LIVE,)), + Case("regression B79: an on-demand device folder accepts writes", test_regression_b79_ondemand_location_accepts_writes, (LIVE,)), Case("durability across a daemon restart", test_durability_across_restart, (LIVE,)), ] @@ -1181,10 +1387,18 @@ REPORT: list[Run] = [] def select_cases(kind: str) -> list[Case]: selected = os.environ.get("PDFS_ACCEPTANCE_ONLY") cases = [case for case in TESTS if kind in case.kinds] - if selected: - cases = [case for case in cases if selected.lower() in case.name.lower()] - check(bool(cases), f"PDFS_ACCEPTANCE_ONLY={selected!r} matched no tests for {kind}") - return cases + if not selected: + return cases + needle = selected.lower() + # A filter that names a live-only case (every regression case is one) matches + # nothing in the reference run, which is not an error — the reference target + # simply has nothing to do. Only a filter that matches *no case at all* is a + # typo worth failing on. + check( + any(needle in case.name.lower() for case in TESTS), + f"PDFS_ACCEPTANCE_ONLY={selected!r} matched no tests", + ) + return [case for case in cases if needle in case.name.lower()] def reap_stale_roots(parent: Path, max_age: int) -> None: