mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
wired new locations into gui, fuse acceptance with new permission based mounts
This commit is contained in:
1 parent
d78ddeab8e
commit
d988236bfa
19 files changed
+1241
-263
No files matched your search
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||||
|
|||||||
@@ -905,7 +905,15 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
|||||||
CtlResponse::Entries { entries } => {
|
CtlResponse::Entries { entries } => {
|
||||||
for e in entries {
|
for e in entries {
|
||||||
let kind = if e.is_dir { "d" } else { "-" };
|
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)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
|||||||
@@ -706,6 +706,12 @@ pub struct DirEntry {
|
|||||||
/// browser.
|
/// browser.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path: String,
|
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
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
|
|||||||
@@ -2956,6 +2956,9 @@ impl Core {
|
|||||||
// Listing entries live in the requested dir; the caller derives
|
// Listing entries live in the requested dir; the caller derives
|
||||||
// the path from its name. Left empty.
|
// the path from its name. Left empty.
|
||||||
path: String::new(),
|
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())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -3415,6 +3418,7 @@ impl Core {
|
|||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
role: String::new(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
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
|
/// A node from a *shared* listing as a [`DirEntry`].
|
||||||
/// 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.
|
/// Still uid-addressed — that is the only handle a foreign-volume node is
|
||||||
fn shared_entry(n: Node) -> DirEntry {
|
/// 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 is_dir = n.is_folder();
|
||||||
let size = match &n.kind {
|
let size = match &n.kind {
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -32,6 +37,13 @@ fn shared_entry(n: Node) -> DirEntry {
|
|||||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||||
NodeKind::Folder => 0,
|
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 {
|
DirEntry {
|
||||||
name: n.name,
|
name: n.name,
|
||||||
is_dir,
|
is_dir,
|
||||||
@@ -39,8 +51,9 @@ fn shared_entry(n: Node) -> DirEntry {
|
|||||||
modified: n.modification_time,
|
modified: n.modification_time,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
path: String::new(),
|
role,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +384,10 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
.block_on(self.client.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.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.
|
/// List the children of a folder shared with me, addressed by uid.
|
||||||
@@ -393,7 +409,10 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&child_uids))
|
.block_on(self.client.enumerate_nodes(&child_uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.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
|
/// Download a file shared with me into the content cache, returning its
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub(crate) mod widgets;
|
|||||||
use pages::activity::*;
|
use pages::activity::*;
|
||||||
use pages::browser::*;
|
use pages::browser::*;
|
||||||
use pages::devices::*;
|
use pages::devices::*;
|
||||||
|
use pages::locations::*;
|
||||||
use pages::login::*;
|
use pages::login::*;
|
||||||
use pages::photo_viewer::*;
|
use pages::photo_viewer::*;
|
||||||
use pages::photos::*;
|
use pages::photos::*;
|
||||||
@@ -49,6 +50,8 @@ use pdfs_core::control::{
|
|||||||
send,
|
send,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
|
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
const APP_ID: &str = "io.narl.proton-drive-linux";
|
const APP_ID: &str = "io.narl.proton-drive-linux";
|
||||||
@@ -113,6 +116,7 @@ struct Ui {
|
|||||||
pub(crate) shared: SharedState,
|
pub(crate) shared: SharedState,
|
||||||
pub(crate) shared_by_me: SharedByMeState,
|
pub(crate) shared_by_me: SharedByMeState,
|
||||||
pub(crate) devices: DevicesState,
|
pub(crate) devices: DevicesState,
|
||||||
|
pub(crate) locations: LocationsState,
|
||||||
pub(crate) activity: ActivityState,
|
pub(crate) activity: ActivityState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +337,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
let (shared_page, shared_widgets) = build_shared_page();
|
let (shared_page, shared_widgets) = build_shared_page();
|
||||||
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
||||||
let (devices_page, devices_widgets) = build_devices_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 (activity_page, activity_widgets) = build_activity_page();
|
||||||
let (trash_page, trash_widgets) = build_trash_page();
|
let (trash_page, trash_widgets) = build_trash_page();
|
||||||
stack.add_named(&login_page, Some("login"));
|
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_by_me_page, Some("sharedbyme"));
|
||||||
stack.add_named(&shared_page, Some("shared"));
|
stack.add_named(&shared_page, Some("shared"));
|
||||||
stack.add_named(&devices_page, Some("devices"));
|
stack.add_named(&devices_page, Some("devices"));
|
||||||
|
stack.add_named(&locations_page, Some("locations"));
|
||||||
stack.add_named(&activity_page, Some("activity"));
|
stack.add_named(&activity_page, Some("activity"));
|
||||||
stack.add_named(&trash_page, Some("trash"));
|
stack.add_named(&trash_page, Some("trash"));
|
||||||
|
|
||||||
@@ -513,6 +519,15 @@ fn build_window(app: &adw::Application) {
|
|||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
loaded_at: Cell::new(None),
|
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 {
|
activity: ActivityState {
|
||||||
content: activity_widgets.content.clone(),
|
content: activity_widgets.content.clone(),
|
||||||
status: activity_widgets.status.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_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
||||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||||
wire_devices(
|
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||||
&ui,
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||||
&devices_widgets.retry,
|
|
||||||
&devices_widgets.add_folder,
|
|
||||||
&devices_widgets.restore,
|
|
||||||
);
|
|
||||||
wire_activity(&ui, &activity_widgets.retry);
|
wire_activity(&ui, &activity_widgets.retry);
|
||||||
wire_refresh(
|
wire_refresh(
|
||||||
&ui,
|
&ui,
|
||||||
@@ -562,6 +573,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
&shared_widgets.refresh,
|
&shared_widgets.refresh,
|
||||||
&shared_by_me_widgets.refresh,
|
&shared_by_me_widgets.refresh,
|
||||||
&devices_widgets.refresh,
|
&devices_widgets.refresh,
|
||||||
|
&locations_widgets.refresh,
|
||||||
&activity_widgets.refresh,
|
&activity_widgets.refresh,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -584,6 +596,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
Some("shared") => load_shared(&ui_nav),
|
Some("shared") => load_shared(&ui_nav),
|
||||||
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
||||||
Some("devices") => load_devices(&ui_nav),
|
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
|
// Activity is intentionally not TTL-cached: it changes out from under
|
||||||
// the page as background uploads and edits complete, so it reloads on
|
// the page as background uploads and edits complete, so it reloads on
|
||||||
// every visit to stay live.
|
// 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,
|
/// The sidebar destinations, in order: the row index is the index into this table,
|
||||||
/// and each entry is `(stack page name, label, icon)`.
|
/// 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"),
|
("browser", "My files", "folder-symbolic"),
|
||||||
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
||||||
("shared", "Shared with me", "system-users-symbolic"),
|
("shared", "Shared with me", "system-users-symbolic"),
|
||||||
|
("locations", "Locations", "drive-harddisk-symbolic"),
|
||||||
("devices", "Computers", "computer-symbolic"),
|
("devices", "Computers", "computer-symbolic"),
|
||||||
("gallery", "Photos", "image-x-generic-symbolic"),
|
("gallery", "Photos", "image-x-generic-symbolic"),
|
||||||
("activity", "Activity", "document-open-recent-symbolic"),
|
("activity", "Activity", "document-open-recent-symbolic"),
|
||||||
@@ -748,6 +763,10 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
ui.devices.loaded_at.set(None);
|
ui.devices.loaded_at.set(None);
|
||||||
load_devices(ui);
|
load_devices(ui);
|
||||||
}
|
}
|
||||||
|
Some("locations") => {
|
||||||
|
ui.locations.loaded_at.set(None);
|
||||||
|
load_locations(ui);
|
||||||
|
}
|
||||||
Some("activity") => load_activity(ui),
|
Some("activity") => load_activity(ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1442,6 +1442,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
cached: false,
|
cached: false,
|
||||||
uid: h.uid.clone(),
|
uid: h.uid.clone(),
|
||||||
path: h.path.clone(),
|
path: h.path.clone(),
|
||||||
|
role: String::new(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| {
|
entries.sort_by(|a, b| {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub(crate) struct DevicesState {
|
|||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
pub(crate) sync_group: adw::PreferencesGroup,
|
pub(crate) sync_group: adw::PreferencesGroup,
|
||||||
|
/// 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>>,
|
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
/// "Rename" action in the "This computer" header. Insensitive until the
|
/// "Rename" action in the "This computer" header. Insensitive until the
|
||||||
/// device list identifies this machine's own device.
|
/// device list identifies this machine's own device.
|
||||||
@@ -25,7 +27,8 @@ pub(crate) struct DevicesState {
|
|||||||
pub(crate) struct DevicesWidgets {
|
pub(crate) struct DevicesWidgets {
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
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,
|
pub(crate) sync_group: adw::PreferencesGroup,
|
||||||
/// "Rename" in the "This computer" header — renames this machine's device.
|
/// "Rename" in the "This computer" header — renames this machine's device.
|
||||||
pub(crate) rename_this: gtk4::Button,
|
pub(crate) rename_this: gtk4::Button,
|
||||||
@@ -33,7 +36,6 @@ pub(crate) struct DevicesWidgets {
|
|||||||
/// machine's own device is deliberately not among them; see
|
/// machine's own device is deliberately not among them; see
|
||||||
/// [`repaint_devices`].
|
/// [`repaint_devices`].
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
pub(crate) add_folder: gtk4::Button,
|
|
||||||
/// "Restore Folders" — re-attach this device's remote folders to local
|
/// "Restore Folders" — re-attach this device's remote folders to local
|
||||||
/// directories after adopting it on a new machine.
|
/// directories after adopting it on a new machine.
|
||||||
pub(crate) restore: gtk4::Button,
|
pub(crate) restore: gtk4::Button,
|
||||||
@@ -54,11 +56,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
titles.set_hexpand(true);
|
titles.set_hexpand(true);
|
||||||
titles.append(&title);
|
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()
|
let restore = gtk4::Button::builder()
|
||||||
.label("Restore Folders")
|
.label("Restore Folders")
|
||||||
.tooltip_text("Sync this computer's Drive folders back to local directories")
|
.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(&titles);
|
||||||
header.append(&refresh);
|
header.append(&refresh);
|
||||||
header.append(&restore);
|
header.append(&restore);
|
||||||
header.append(&add_folder);
|
|
||||||
|
|
||||||
// The description spells out what the per-row On-demand switch does to the
|
// This page is about device *identity* — which computer this is, which other
|
||||||
// local copy, because the switch itself can't: turning it on *deletes* the
|
// computers back up to the account, and how to adopt one. The folders
|
||||||
// files from this disk, which is not something to discover afterwards.
|
// 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()
|
let sync_group = adw::PreferencesGroup::builder()
|
||||||
.title("This computer")
|
.title("This computer")
|
||||||
.description(
|
.description("The device this machine backs up to.")
|
||||||
"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.",
|
|
||||||
)
|
|
||||||
.build();
|
.build();
|
||||||
// Rename control for *this* machine's device, in the section header. The
|
// 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
|
// 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,
|
sync_group,
|
||||||
rename_this,
|
rename_this,
|
||||||
group,
|
group,
|
||||||
add_folder,
|
|
||||||
restore,
|
restore,
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
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.
|
/// Install the Devices page's retry button and the "Restore Folders" action.
|
||||||
pub(crate) fn wire_devices(
|
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::Button) {
|
||||||
ui: &Rc<Ui>,
|
|
||||||
retry: >k4::Button,
|
|
||||||
add_folder: >k4::Button,
|
|
||||||
restore: >k4::Button,
|
|
||||||
) {
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
retry.connect_clicked(move |_| {
|
||||||
service::restart();
|
service::restart();
|
||||||
load_devices(&ui_retry);
|
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();
|
let ui_restore = ui.clone();
|
||||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
||||||
let ui_ren = ui.clone();
|
let ui_ren = ui.clone();
|
||||||
@@ -224,9 +209,9 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
|||||||
return;
|
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");
|
ui.devices.content.set_visible_child_name("list");
|
||||||
repaint_sync_folders(&ui, &folders);
|
repaint_this_computer(&ui, &folders);
|
||||||
|
|
||||||
let devices = match devices_reply {
|
let devices = match devices_reply {
|
||||||
Ok(Ok(Response::Devices { items })) => items,
|
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.
|
/// The daemon didn't answer the Devices page.
|
||||||
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||||
if service::is_failed() || !service::is_active() {
|
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.
|
/// Rebuild the "This computer" section: one row naming the device this machine
|
||||||
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
/// 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(..) {
|
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
||||||
ui.devices.sync_group.remove(&row);
|
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() {
|
if folders.is_empty() {
|
||||||
let row = adw::ActionRow::builder()
|
return "No folders backed up yet — add one under Locations.".to_string();
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
let count = match folders.len() {
|
||||||
for f in folders {
|
1 => "1 folder backed up".to_string(),
|
||||||
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
|
n => format!("{n} folders backed up"),
|
||||||
// otherwise fall back to the folder's resting state.
|
};
|
||||||
let status = match &f.progress {
|
let attention = folders
|
||||||
Some(p) => sync_progress_label(p),
|
.iter()
|
||||||
None => sync_state_label(&f.state).to_string(),
|
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||||
};
|
.count();
|
||||||
// A queued switch is where the folder is *going*, which is what the user
|
match attention {
|
||||||
// just asked for and is waiting to see happen — so it leads the subtitle
|
0 => format!("{count} · manage them under Locations"),
|
||||||
// instead of the mode the folder is still technically in.
|
1 => format!("{count} · 1 needs attention"),
|
||||||
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
|
n => format!("{count} · {n} need attention"),
|
||||||
(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());
|
|
||||||
}
|
}
|
||||||
*ui.devices.sync_rows.borrow_mut() = rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Human label for a synced folder's `state` column.
|
/// 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
|
// device and creating the remote folder is off-socket network
|
||||||
// work. A fixed delay here either fires too early (empty list)
|
// work. A fixed delay here either fires too early (empty list)
|
||||||
// or too late (row flashes in). Instead let the periodic
|
// 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.
|
// actually lands, which is what keeps a running pass live too.
|
||||||
toast(&ui, "Syncing folder…");
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: >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<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(>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<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(>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<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,6 +1,7 @@
|
|||||||
pub(crate) mod activity;
|
pub(crate) mod activity;
|
||||||
pub(crate) mod browser;
|
pub(crate) mod browser;
|
||||||
pub(crate) mod devices;
|
pub(crate) mod devices;
|
||||||
|
pub(crate) mod locations;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
pub(crate) mod photo_viewer;
|
pub(crate) mod photo_viewer;
|
||||||
pub(crate) mod photos;
|
pub(crate) mod photos;
|
||||||
|
|||||||
@@ -298,8 +298,15 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
.title(&entry.name)
|
.title(&entry.name)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
if !entry.is_dir {
|
let subtitle = shared_row_subtitle(entry);
|
||||||
row.set_subtitle(&human_bytes(entry.size));
|
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 {
|
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||||
"folder-symbolic"
|
"folder-symbolic"
|
||||||
@@ -325,6 +332,43 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
row
|
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
|
/// 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
|
/// 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.
|
/// 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,19 +236,18 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
row.add_suffix(&open);
|
row.add_suffix(&open);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage opens the per-node Share dialog — only when the node's path is
|
// Manage opens the per-node Share dialog. It used to be offered only for
|
||||||
// known (it has been browsed to this session); the dialog is path-keyed.
|
// an item whose path the daemon could resolve; the dialog now addresses a
|
||||||
if !item.path.is_empty() {
|
// pathless node by uid, so every shared item can be managed from here.
|
||||||
let manage = gtk4::Button::builder()
|
let manage = gtk4::Button::builder()
|
||||||
.label("Manage")
|
.label("Manage")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
manage.add_css_class("flat");
|
manage.add_css_class("flat");
|
||||||
let ui_manage = ui.clone();
|
let ui_manage = ui.clone();
|
||||||
let entry = shared_item_as_entry(item);
|
let entry = shared_item_as_entry(item);
|
||||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||||
row.add_suffix(&manage);
|
row.add_suffix(&manage);
|
||||||
}
|
|
||||||
|
|
||||||
ui.shared_by_me.group.add(&row);
|
ui.shared_by_me.group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
@@ -295,5 +294,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid.clone(),
|
uid: item.uid.clone(),
|
||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
|
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||||
|
role: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,8 +31,8 @@ pub(crate) struct StatusState {
|
|||||||
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
||||||
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) budget_row: adw::SpinRow,
|
pub(crate) budget_row: adw::SpinRow,
|
||||||
/// Shows the active mountpoint in its subtitle; updated when the user picks a
|
/// Shows where the primary mount lives; the folder itself is managed on the
|
||||||
/// new folder.
|
/// Locations page, which owns every local path.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
/// Set while a settings widget is being populated programmatically, so its
|
/// Set while a settings widget is being populated programmatically, so its
|
||||||
/// change handler skips the IPC/systemd side effect.
|
/// change handler skips the IPC/systemd side effect.
|
||||||
@@ -100,7 +100,8 @@ pub(crate) struct MainWidgets {
|
|||||||
pub(crate) budget_row: adw::SpinRow,
|
pub(crate) budget_row: adw::SpinRow,
|
||||||
/// Purges all unpinned cached content.
|
/// Purges all unpinned cached content.
|
||||||
pub(crate) purge_button: gtk4::Button,
|
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_row: adw::ActionRow,
|
||||||
pub(crate) mountpoint_button: gtk4::Button,
|
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.")
|
.subtitle("Mount Proton Drive automatically when you log in.")
|
||||||
.build();
|
.build();
|
||||||
system_group.add(&autostart_row);
|
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()
|
let mountpoint_row = adw::ActionRow::builder()
|
||||||
.title("Mountpoint")
|
.title("Mountpoint")
|
||||||
.subtitle("—")
|
.subtitle("—")
|
||||||
.build();
|
.build();
|
||||||
let mountpoint_button = gtk4::Button::builder()
|
let mountpoint_button = gtk4::Button::builder()
|
||||||
.label("Change")
|
.label("Locations")
|
||||||
|
.tooltip_text("Manage this computer's Proton Drive locations")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
mountpoint_button.add_css_class("flat");
|
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
|
// Mountpoint: the chooser lives on the Locations page, next to every other
|
||||||
// the change takes effect (the daemon reads the path on mount).
|
// local path; this button is the way there.
|
||||||
let ui_mp = ui.clone();
|
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,
|
/// 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;
|
return;
|
||||||
}
|
}
|
||||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
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.
|
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||||
let confirm = adw::AlertDialog::builder()
|
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.
|
// they are on screen. Every other page loads on navigation only.
|
||||||
match ui.stack.visible_child_name().as_deref() {
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
Some("main") => refresh_quota(ui),
|
Some("main") => refresh_quota(ui),
|
||||||
Some("devices") => refresh_sync_folders(ui),
|
Some("locations") => refresh_locations(ui),
|
||||||
Some("activity") => refresh_activity(ui),
|
Some("activity") => refresh_activity(ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// handlers can rebuild the people and link sections after each change without
|
||||||
/// tearing the whole dialog down.
|
/// tearing the whole dialog down.
|
||||||
pub(crate) struct ShareDialog {
|
pub(crate) struct ShareDialog {
|
||||||
pub(crate) ui: Rc<Ui>,
|
pub(crate) ui: Rc<Ui>,
|
||||||
/// The node's mountpoint-relative path — how every request addresses it.
|
/// How every request from this dialog addresses the node.
|
||||||
pub(crate) rel: String,
|
pub(crate) target: ShareTarget,
|
||||||
pub(crate) people: adw::PreferencesGroup,
|
pub(crate) people: adw::PreferencesGroup,
|
||||||
pub(crate) link_group: adw::PreferencesGroup,
|
pub(crate) link_group: adw::PreferencesGroup,
|
||||||
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
|
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.");
|
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
||||||
return;
|
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 toolbar = adw::ToolbarView::new();
|
||||||
let header = adw::HeaderBar::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 {
|
let state = Rc::new(ShareDialog {
|
||||||
ui: ui.clone(),
|
ui: ui.clone(),
|
||||||
rel,
|
target,
|
||||||
people,
|
people,
|
||||||
link_group,
|
link_group,
|
||||||
people_rows: RefCell::new(Vec::new()),
|
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();
|
let msg_clear = message_row.clone();
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_inv,
|
&state_inv,
|
||||||
Request::ShareNode {
|
state_inv.target.invite(emails, role, message),
|
||||||
path: state_inv.rel.clone(),
|
|
||||||
emails,
|
|
||||||
role,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
"Invitations sent",
|
"Invitations sent",
|
||||||
"Couldn't send invitations",
|
"Couldn't send invitations",
|
||||||
Some(Box::new(move || {
|
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.
|
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||||
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||||
state.ui.dirs.control_socket(),
|
|
||||||
Request::ListShare {
|
|
||||||
path: state.rel.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
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| {
|
drop.connect_selected_notify(move |d| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_role,
|
&state_role,
|
||||||
Request::UpdateShareRole {
|
state_role.target.update_role(
|
||||||
path: state_role.rel.clone(),
|
id.clone(),
|
||||||
id: id.clone(),
|
|
||||||
kind,
|
kind,
|
||||||
role: role_index_to_wire(d.selected()).to_string(),
|
role_index_to_wire(d.selected()).to_string(),
|
||||||
},
|
),
|
||||||
"Role updated",
|
"Role updated",
|
||||||
"Couldn't update the role",
|
"Couldn't update the role",
|
||||||
None,
|
None,
|
||||||
@@ -265,11 +364,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |_| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_rm,
|
&state_rm,
|
||||||
Request::RemoveShareEntry {
|
state_rm.target.remove_entry(id.clone(), kind),
|
||||||
path: state_rm.rel.clone(),
|
|
||||||
id: id.clone(),
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
"Access removed",
|
"Access removed",
|
||||||
"Couldn't remove access",
|
"Couldn't remove access",
|
||||||
None,
|
None,
|
||||||
@@ -332,10 +427,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |_| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_rm,
|
&state_rm,
|
||||||
Request::RemovePublicLink {
|
state_rm.target.remove_link(id.clone()),
|
||||||
path: state_rm.rel.clone(),
|
|
||||||
id: id.clone(),
|
|
||||||
},
|
|
||||||
"Public link removed",
|
"Public link removed",
|
||||||
"Couldn't remove the link",
|
"Couldn't remove the link",
|
||||||
None,
|
None,
|
||||||
@@ -407,12 +499,7 @@ pub(crate) fn share_dialog_create_link(
|
|||||||
) {
|
) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
state.ui.dirs.control_socket(),
|
state.ui.dirs.control_socket(),
|
||||||
Request::CreatePublicLink {
|
state.target.create_link(role, password),
|
||||||
path: state.rel.clone(),
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
expires: None,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
|
|||||||
@@ -76,6 +76,31 @@ sequenceDiagram
|
|||||||
FUSE-->>User: returns child inode metadata (attributes, TTL)
|
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
|
## 3. Read Path & Block Caching Pipeline
|
||||||
|
|||||||
+4
-1
@@ -66,7 +66,10 @@ device-folder tree to `Viewer` in memory.
|
|||||||
**Tests:** `state::tests::own_volume_nodes_without_a_resident_parent_stay_owned`,
|
**Tests:** `state::tests::own_volume_nodes_without_a_resident_parent_stay_owned`,
|
||||||
`foreign_volume_nodes_without_a_resident_parent_fail_closed`,
|
`foreign_volume_nodes_without_a_resident_parent_fail_closed`,
|
||||||
`persisted_authority_outranks_the_own_volume_fail_open`. The first fails on the
|
`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
|
**Verified live (2026-07-31):** the packaged 1.2.1 daemon denies
|
||||||
`touch ~/Documents/pdfs-b79-test`, `mkdir`, and `>` redirect while
|
`touch ~/Documents/pdfs-b79-test`, `mkdir`, and `>` redirect while
|
||||||
|
|||||||
+4
-1
@@ -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.
|
* **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.
|
* **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.
|
* **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.
|
3. Wipe the local state directory and SQLite cache.
|
||||||
4. Restart the daemon.
|
4. Restart the daemon.
|
||||||
5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte.
|
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.
|
* **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.
|
* **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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
narrowing it produced a smaller reproduction that has its own case now. Expect a
|
||||||
red run against a real mount until it is fixed.
|
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
|
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
|
queue to drain before reading. Reading straight back through the mount proves
|
||||||
nothing: the local attribute and content caches will happily return the bytes
|
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
|
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||||
release acceptance run must leave it unset so the complete contract executes.
|
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.
|
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
|
The first path must be the FUSE mount under test; later paths may be another
|
||||||
FUSE mount or a normal local mirror filesystem:
|
FUSE mount or a normal local mirror filesystem:
|
||||||
|
|||||||
+218
-4
@@ -1075,6 +1075,208 @@ def test_regression_b74_rename_after_close(ctx: Context) -> None:
|
|||||||
ctx.record("digest", hashlib.sha256(read(final)).hexdigest())
|
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]:
|
def _conflict_copies(root: Path) -> list[str]:
|
||||||
return sorted(name for name in os.listdir(root) if "(sync-conflict" in name)
|
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 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 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 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,)),
|
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]:
|
def select_cases(kind: str) -> list[Case]:
|
||||||
selected = os.environ.get("PDFS_ACCEPTANCE_ONLY")
|
selected = os.environ.get("PDFS_ACCEPTANCE_ONLY")
|
||||||
cases = [case for case in TESTS if kind in case.kinds]
|
cases = [case for case in TESTS if kind in case.kinds]
|
||||||
if selected:
|
if not selected:
|
||||||
cases = [case for case in cases if selected.lower() in case.name.lower()]
|
return cases
|
||||||
check(bool(cases), f"PDFS_ACCEPTANCE_ONLY={selected!r} matched no tests for {kind}")
|
needle = selected.lower()
|
||||||
return cases
|
# 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:
|
def reap_stale_roots(parent: Path, max_age: int) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user