wired new locations into gui, fuse acceptance with new permission based mounts

This commit is contained in:
nvrl committed 2026-07-31 19:37:12 +02:00
1 parent d78ddeab8e
commit d988236bfa
19 files changed
+1227 -249

No files matched your search

+1
View File
@@ -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.
+9 -1
View File
@@ -905,7 +905,15 @@ fn cmd_shared_with_me(uid: Option<String>) -> 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)),
+6
View File
@@ -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
+4
View File
@@ -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())
}
+26 -7
View File
@@ -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
+26 -7
View File
@@ -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>) {
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),
_ => {}
}
+1
View File
@@ -1442,6 +1442,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
cached: false,
uid: h.uid.clone(),
path: h.path.clone(),
role: String::new(),
})
.collect();
entries.sort_by(|a, b| {
+95 -179
View File
@@ -9,6 +9,8 @@ pub(crate) struct DevicesState {
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
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<Vec<gtk4::Widget>>,
/// "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<Ui>,
retry: &gtk4::Button,
add_folder: &gtk4::Button,
restore: &gtk4::Button,
) {
/// Install the Devices page's retry button and the "Restore Folders" action.
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button, restore: &gtk4::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<Ui>) {
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<Ui>) {
});
}
/// 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<Ui>) {
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<Ui>) {
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<Ui>, 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<Ui>, folders: &[SyncFolderInfo]) {
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
ui.devices.sync_group.remove(&row);
}
if folders.is_empty() {
let row = adw::ActionRow::builder()
.title("No synced folders")
.subtitle("Add a folder to back it up to this device.")
.title("This computer")
.subtitle(this_computer_subtitle(folders))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
row.add_prefix(&gtk4::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()];
return;
}
/// 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() {
return "No folders backed up yet — add one under Locations.".to_string();
}
let mut rows: Vec<gtk4::Widget> = 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(),
let count = match folders.len() {
1 => "1 folder backed up".to_string(),
n => format!("{n} folders backed up"),
};
// 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(&gtk4::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);
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"),
}
// 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());
}
*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<Ui>) {
// 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"
);
}
}
+536
View File
@@ -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<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// 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<Ui>, retry: &gtk4::Button, add_folder: &gtk4::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<Ui>,
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<Ui>) {
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<Ui>) {
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<Ui>) {
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<Ui>, 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(&gtk4::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<gtk4::Widget> = 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(&gtk4::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<Ui>, 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<Ui>, 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<Ui>, 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<String> = 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");
}
}
+1
View File
@@ -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;
+95 -2
View File
@@ -298,8 +298,15 @@ fn shared_entry_row(ui: &Rc<Ui>, 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(&gtk4::Image::from_icon_name(if entry.is_dir {
"folder-symbolic"
@@ -325,6 +332,43 @@ fn shared_entry_row(ui: &Rc<Ui>, 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<String> = 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<gtk4::Label> {
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());
}
}
@@ -236,9 +236,9 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, 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() {
// 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)
@@ -248,7 +248,6 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
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(),
}
}
+20 -8
View File
@@ -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<Ui>) {
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<Ui>) {
// 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),
_ => {}
}
+123 -36
View File
@@ -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<String>, role: String, message: Option<String>) -> 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<String>) -> 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<Ui>,
/// 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<Vec<gtk4::Widget>>,
@@ -41,7 +145,13 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, entry: &DirEntry) {
/// Re-fetch the node's share and rebuild the people + public-link sections.
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
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<ShareDialog>, 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<ShareDialog>, 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<ShareDialog>, 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 {
+25
View File
@@ -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
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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 <empty-a> <empty-b>` 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.
+29
View File
@@ -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<n>` 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:
+217 -3
View File
@@ -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}")
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: