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