From bf625bf8d93561a1b410a7e17f12ef4e451d41ef Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 20 Jul 2026 22:30:16 +0200 Subject: [PATCH] added functionality to open shared files with you + fixed bug where mid transfer the SIGTERM signal can corrupt the mount --- crates/pdfs-cli/src/main.rs | 250 +++++++++++--- crates/pdfs-core/src/config.rs | 9 + crates/pdfs-core/src/control.rs | 77 +++++ crates/pdfs-core/src/db/maintenance.rs | 11 +- crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-core/src/profile.rs | 171 ++++++++++ crates/pdfs-fuse/src/control.rs | 74 ++++- crates/pdfs-fuse/src/devices.rs | 136 +++++++- crates/pdfs-fuse/src/lib.rs | 138 ++++++-- crates/pdfs-fuse/src/profile.rs | 394 +++++++++++++++++++++++ crates/pdfs-fuse/src/sharing.rs | 109 +++++-- crates/pdfs-fuse/src/sync.rs | 13 +- crates/pdfs-gui/src/app/main.rs | 15 +- crates/pdfs-gui/src/app/pages/devices.rs | 179 +++++++++- crates/pdfs-gui/src/app/pages/shared.rs | 180 ++++++++++- 15 files changed, 1621 insertions(+), 136 deletions(-) create mode 100644 crates/pdfs-core/src/profile.rs create mode 100644 crates/pdfs-fuse/src/profile.rs diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 2b83693..6d01f0f 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -11,8 +11,8 @@ use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind, - SyncPhase, pending_summary, + ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, + RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary, }; use pdfs_core::db::Db; @@ -276,8 +276,19 @@ enum Command { }, /// List the items I have shared with others (members, invites or a link). Shared, - /// List nodes shared with me that I have accepted. - SharedWithMe, + /// List nodes shared with me that I have accepted, or the children of one. + SharedWithMe { + /// Folder uid to list into, in `volume~link` form (from a previous run). + /// Omit for the top level. + uid: Option, + }, + /// Download a file shared with me by its uid (from `pdfs shared-with-me`). + SharedGet { + /// Node uid in `volume~link` form. + uid: String, + /// Where to write it. Omit to leave it in the cache and print the path. + dest: Option, + }, /// Leave a shared node by its uid (from `pdfs shared-with-me`). Leave { /// Node uid in `volume~link` form. @@ -358,6 +369,15 @@ enum DeviceCmd { Rename { uid: String, name: String }, /// Delete (deregister) a device by uid. Rm { uid: String }, + /// Adopt an existing device as this machine's, so a hostname change or a + /// reinstall re-attaches to it instead of registering a duplicate. + Adopt { + /// Device uid (from `devices list`). Omit with `--clear`. + uid: Option, + /// Drop the adoption and go back to matching by hostname. + #[arg(long, conflicts_with = "uid")] + clear: bool, + }, } #[derive(Subcommand)] @@ -390,6 +410,13 @@ enum SyncCmd { #[arg(value_parser = ["mirror", "ondemand"])] mode: String, }, + /// Re-attach this machine's device folders to local directories, then sync + /// them down. Use after adopting a device on a new machine. + Restore { + /// Accept every proposed local path without asking. + #[arg(long)] + yes: bool, + }, } #[derive(Subcommand)] @@ -514,7 +541,8 @@ fn main() -> Result<()> { Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind), Command::PublicLink { action } => cmd_public_link(action), Command::Shared => cmd_shared(), - Command::SharedWithMe => cmd_shared_with_me(), + Command::SharedWithMe { uid } => cmd_shared_with_me(uid), + Command::SharedGet { uid, dest } => cmd_shared_get(uid, dest), Command::Leave { uid } => cmd_leave(uid), Command::Invitations { action } => cmd_invitations(action), Command::Bookmarks { action } => cmd_bookmarks(action), @@ -539,8 +567,13 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> { .last_sync .map(|t| t.to_string()) .unwrap_or_else(|| "never".to_string()); + let tag = match (d.adopted, d.this_device) { + (true, _) => " *adopted*", + (false, true) => " *this machine*", + _ => "", + }; println!( - "{} {} (synced: {sync}) [{}]", + "{} {} (synced: {sync}) [{}]{tag}", d.device_type, d.name, d.uid ); } @@ -553,6 +586,13 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> { ok_or_bail(control_request(CtlRequest::RenameDevice { uid, name })?)? } DeviceCmd::Rm { uid } => ok_or_bail(control_request(CtlRequest::DeleteDevice { uid })?)?, + DeviceCmd::Adopt { uid, clear } => { + if uid.is_none() && !clear { + bail!("give a device uid (see `pdfs devices list`), or --clear"); + } + let uid = if clear { None } else { uid }; + ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)? + } } Ok(()) } @@ -574,45 +614,49 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { return Ok(()); } match response { - CtlResponse::SyncFolders { items } if items.is_empty() => { - println!("No synced folders.") - } - CtlResponse::SyncFolders { items } => { - for f in items { - let sync = if f.last_sync == 0 { - "never".to_string() - } else { - f.last_sync.to_string() - }; - // A queued switch is reported as the mode the folder is moving - // to, so `sync list` never reads as if the request was dropped. - let mode = match &f.pending_mode { - Some(pending) => format!("{} → {pending}", f.mode), - None => f.mode.clone(), - }; - println!( - "[{}] {} ({mode}, {}, synced: {sync})", - f.id, f.local_path, f.state - ); - // A pass in flight says what it is doing, indented under it. - if let Some(p) = &f.progress { - match p.phase { - SyncPhase::Scanning if p.total == 0 => println!(" scanning…"), - SyncPhase::Scanning => { - println!(" scanning: {} of {}", p.done, p.total.max(p.done)) + CtlResponse::SyncFolders { items } if items.is_empty() => { + println!("No synced folders.") + } + CtlResponse::SyncFolders { items } => { + for f in items { + let sync = if f.last_sync == 0 { + "never".to_string() + } else { + f.last_sync.to_string() + }; + // A queued switch is reported as the mode the folder is moving + // to, so `sync list` never reads as if the request was dropped. + let mode = match &f.pending_mode { + Some(pending) => format!("{} → {pending}", f.mode), + None => f.mode.clone(), + }; + println!( + "[{}] {} ({mode}, {}, synced: {sync})", + f.id, f.local_path, f.state + ); + // A pass in flight says what it is doing, indented under it. + if let Some(p) = &f.progress { + match p.phase { + SyncPhase::Scanning if p.total == 0 => println!(" scanning…"), + SyncPhase::Scanning => { + println!( + " scanning: {} of {}", + p.done, + p.total.max(p.done) + ) + } + SyncPhase::Applying => println!( + " {} of {} {}", + p.done + 1, + p.total.max(p.done + 1), + p.current + ), } - SyncPhase::Applying => println!( - " {} of {} {}", - p.done + 1, - p.total.max(p.done + 1), - p.current - ), } } } - } - CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), - other => bail!("unexpected response: {other:?}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), } } SyncCmd::Rm { id, delete_remote } => { @@ -625,10 +669,88 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { SyncCmd::Mode { id, mode } => { ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)? } + SyncCmd::Restore { yes } => return cmd_sync_restore(yes), } Ok(()) } +/// `pdfs sync restore`: list what this machine's device holds, confirm where +/// each folder should land, then hand the mapping to the daemon. +/// +/// The daemon only ever *proposes* local paths — a path recorded by another +/// machine may name a home directory that does not exist here — so the default +/// flow is propose-and-confirm. `--yes` accepts the proposals for scripting. +fn cmd_sync_restore(yes: bool) -> Result<()> { + let response = control_request(CtlRequest::ListRestorableFolders)?; + if emit_json(&response)? { + return Ok(()); + } + let items = match response { + CtlResponse::RestorableFolders { items } => items, + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + }; + let candidates: Vec<_> = items.into_iter().filter(|f| !f.already_synced).collect(); + if candidates.is_empty() { + println!("Nothing to restore: this device has no folders that aren't already synced here."); + return Ok(()); + } + + let mut chosen: Vec = Vec::new(); + for f in candidates { + if yes { + println!("{} -> {} [{}]", f.name, f.local_path, f.mode); + chosen.push(CtlRestoreItem { + remote_uid: f.remote_uid, + local_path: f.local_path, + mode: f.mode, + }); + continue; + } + // Empty answer takes the proposal, `n` skips, anything else is read as + // the path to use instead — the three things a user wants here. + let answer = prompt_line(&format!( + "{} [{}] -> {} (Enter to accept, 'n' to skip, or a path)", + f.name, f.mode, f.local_path + ))?; + match answer.as_str() { + "n" | "N" => continue, + "" => chosen.push(CtlRestoreItem { + remote_uid: f.remote_uid, + local_path: f.local_path, + mode: f.mode, + }), + path => chosen.push(CtlRestoreItem { + remote_uid: f.remote_uid, + local_path: expand_tilde(path), + mode: f.mode, + }), + } + } + if chosen.is_empty() { + println!("Nothing selected."); + return Ok(()); + } + ok_or_bail(control_request(CtlRequest::RestoreSyncFolders { + items: chosen, + })?) +} + +/// Expand a leading `~` in a hand-typed path. The daemon requires an absolute +/// path, and a shell is not always the one doing the typing here. +fn expand_tilde(path: &str) -> String { + match path.strip_prefix("~/") { + Some(rest) => match std::env::var_os("HOME") { + Some(home) => PathBuf::from(home) + .join(rest) + .to_string_lossy() + .into_owned(), + None => path.to_string(), + }, + None => path.to_string(), + } +} + fn cmd_share( path: PathBuf, emails: Vec, @@ -755,8 +877,20 @@ fn cmd_shared() -> Result<()> { Ok(()) } -fn cmd_shared_with_me() -> Result<()> { - match control_request(CtlRequest::ListSharedWithMe)? { +/// List what is shared with me, or — given a folder uid — that folder's +/// children. Shared items have no path in the mount, so a uid is the only handle +/// there is: every row prints one, and it is what the next level down (or +/// `shared-get`) is addressed with. +fn cmd_shared_with_me(uid: Option) -> Result<()> { + let request = match uid { + Some(uid) => CtlRequest::ListSharedFolder { uid }, + None => CtlRequest::ListSharedWithMe, + }; + let response = control_request(request)?; + if emit_json(&response)? { + return Ok(()); + } + match response { CtlResponse::Entries { entries } if entries.is_empty() => { println!("Nothing shared with you.") } @@ -772,6 +906,25 @@ fn cmd_shared_with_me() -> Result<()> { Ok(()) } +/// Download a file shared with me. The daemon puts the plaintext in its content +/// cache and replies with that path; with a `dest` we copy it out, so the caller +/// gets a file it owns rather than one the cache may later evict. +fn cmd_shared_get(uid: String, dest: Option) -> Result<()> { + let path = match control_request(CtlRequest::OpenSharedFile { uid })? { + CtlResponse::FilePath { path } => PathBuf::from(path), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + }; + match dest { + Some(dest) => { + std::fs::copy(&path, &dest).with_context(|| format!("writing {}", dest.display()))?; + println!("{}", dest.display()); + } + None => println!("{}", path.display()), + } + Ok(()) +} + fn cmd_activity(limit: usize) -> Result<()> { let response = control_request(CtlRequest::ListActivity { limit })?; if emit_json(&response)? { @@ -1514,7 +1667,10 @@ fn cmd_cache_inspect(deep: bool) -> Result<()> { integrity_problems, integrity_checked, } => { - println!("Database schema v{schema_version}, {}", human_bytes(db_bytes)); + println!( + "Database schema v{schema_version}, {}", + human_bytes(db_bytes) + ); if db_reclaimable_bytes > 0 { println!( " {} reclaimable — run `pdfs cache vacuum`", @@ -1649,7 +1805,11 @@ fn cmd_diagnose() -> Result<()> { ); } Ok(other) => { - all_ok &= check(" daemon responding", false, format!("unexpected: {other:?}")); + all_ok &= check( + " daemon responding", + false, + format!("unexpected: {other:?}"), + ); } Err(e) => { // Not a failure of the installation: a stopped daemon is a normal diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index c712970..6b5a0a2 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -41,6 +41,13 @@ pub struct AppConfig { /// defaults. Defaulted for configs predating the field. #[serde(default)] pub ignore_patterns: Option>, + /// Device uid this machine has been *adopted* into, overriding the hostname + /// heuristic in `ensure_device`. Lives here rather than in `cache.db` so it + /// survives a fresh install — on a new machine the DB is empty by + /// definition, which is exactly when the pin matters. `None` means "resolve + /// by hostname". Defaulted for configs predating the field. + #[serde(default)] + pub device_uid: Option, } impl Default for AppConfig { @@ -51,6 +58,7 @@ impl Default for AppConfig { cache_budget: None, mountpoint: None, ignore_patterns: None, + device_uid: None, } } } @@ -294,6 +302,7 @@ mod tests { cache_budget: Some(1234), mountpoint: Some("/tmp/x".to_string()), ignore_patterns: Some(vec!["build/".to_string()]), + device_uid: Some("dev-uid".to_string()), }; let json = serde_json::to_string(&config).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap(); diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 7c019b5..a820a6b 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -188,6 +188,11 @@ pub enum Request { RenameDevice { uid: String, name: String }, /// Delete (deregister) a device by its uid. Replies with [`Response::Ok`]. DeleteDevice { uid: String }, + /// Adopt an existing device as this machine's, pinning its uid in + /// `config.json` so a hostname change or reinstall re-attaches to it instead + /// of registering a duplicate (features.md 5.1). `uid: None` clears the pin. + /// Replies with [`Response::Ok`]. + AdoptDevice { uid: Option }, // ---- device folder sync (devices.md) ---------------------------------- /// Add a local folder to this machine's device, uploading its tree and @@ -204,6 +209,13 @@ pub enum Request { /// Force a reconcile pass: one folder by id, or all when `id` is `None`. /// Replies with [`Response::Ok`]. SyncNow { id: Option }, + /// List the folders under this machine's device that can be synced here, + /// each with a proposed local path (features.md 5.2). Replies with + /// [`Response::RestorableFolders`]. + ListRestorableFolders, + /// Attach the given remote device folders to local paths and sync them down. + /// Replies with [`Response::Ok`]. + RestoreSyncFolders { items: Vec }, // ---- sharing a node --------------------------------------------------- /// Invite `emails` (Proton and/or external addresses, auto-detected) to the @@ -259,6 +271,16 @@ pub enum Request { /// [`Response::Entries`] (each entry carries its `uid`; `path` is empty since /// the item lives outside the mount tree). ListSharedWithMe, + /// List the children of a folder shared with me, addressed by `uid`. Replies + /// with [`Response::Entries`]; like [`Request::ListSharedWithMe`] the entries + /// carry a `uid` and an empty `path`, since the subtree lives outside the + /// mount. Lets a front-end browse into a shared folder. + ListSharedFolder { uid: String }, + /// Download a file shared with me into the content cache, addressed by `uid`. + /// Replies with [`Response::FilePath`] so the front-end can open it with the + /// default app. The by-uid twin of [`Request::OpenFile`], which can only + /// address nodes inside the mount. + OpenSharedFile { uid: String }, /// Leave a shared node by its `uid`, giving up my access. Replies with /// [`Response::Ok`]. LeaveShared { uid: String }, @@ -340,6 +362,11 @@ pub struct DeviceInfo { /// front-end must not offer that as casually as removing another computer. #[serde(default)] pub this_device: bool, + /// Whether this device is the one *explicitly adopted* in `config.json` + /// ([`Request::AdoptDevice`]), as opposed to one matched by hostname. Only + /// an adopted device survives a hostname change or a reinstall. + #[serde(default)] + pub adopted: bool, } /// One synced local folder on this machine's device (in [`Response::SyncFolders`]). @@ -369,6 +396,36 @@ pub struct SyncFolderInfo { pub progress: Option, } +/// A folder under this machine's device offered for restore (in +/// [`Response::RestorableFolders`]). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RestorableFolder { + /// Uid of the remote folder — the handle for [`RestoreItem`]. + pub remote_uid: String, + /// Its remote name. + pub name: String, + /// Where the daemon *proposes* to put it: the path the profile recorded when + /// that path makes sense on this machine, else `~/`. A front-end shows + /// this as an editable default, never applies it silently. + pub local_path: String, + /// Proposed mode, from the profile; `mirror` when unknown. + pub mode: String, + /// Already synced on this machine, so restoring it again would be a no-op. + /// Listed anyway so the picker shows the whole device. + #[serde(default)] + pub already_synced: bool, +} + +/// One folder to restore, in [`Request::RestoreSyncFolders`]. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RestoreItem { + pub remote_uid: String, + /// Absolute local path to sync it to; created if missing. + pub local_path: String, + /// `mirror` or `ondemand`. + pub mode: String, +} + /// Which stage a running sync pass is in, in a [`SyncProgress`]. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncPhase { @@ -898,6 +955,8 @@ pub enum Response { Devices { items: Vec }, /// This device's synced folders (reply to [`Request::ListSyncFolders`]). SyncFolders { items: Vec }, + /// Folders offered for restore (reply to [`Request::ListRestorableFolders`]). + RestorableFolders { items: Vec }, /// A node's share: members + pending invitations, and its public link if any /// (reply to [`Request::ListShare`]). Share { @@ -1161,6 +1220,18 @@ mod tests { mode: "ondemand".into(), }, Request::SyncNow { id: Some(3) }, + Request::AdoptDevice { + uid: Some("dev-1".into()), + }, + Request::AdoptDevice { uid: None }, + Request::ListRestorableFolders, + Request::RestoreSyncFolders { + items: vec![RestoreItem { + remote_uid: "vol~link".into(), + local_path: "/home/me/Docs".into(), + mode: "mirror".into(), + }], + }, Request::ShareNode { path: "a/b".into(), emails: vec!["x@proton.me".into(), "y@example.com".into()], @@ -1192,6 +1263,12 @@ mod tests { Request::ListSharedByMe, Request::ListActivity { limit: 100 }, Request::ListSharedWithMe, + Request::ListSharedFolder { + uid: "vol~link".into(), + }, + Request::OpenSharedFile { + uid: "vol~link".into(), + }, Request::LeaveShared { uid: "vol~link".into(), }, diff --git a/crates/pdfs-core/src/db/maintenance.rs b/crates/pdfs-core/src/db/maintenance.rs index f62dc97..4bd7499 100644 --- a/crates/pdfs-core/src/db/maintenance.rs +++ b/crates/pdfs-core/src/db/maintenance.rs @@ -137,9 +137,10 @@ impl Db { if exists.is_none() { continue; } - let count: i64 = conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| { - r.get(0) - })?; + let count: i64 = + conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| { + r.get(0) + })?; tables.push(((*name).to_string(), count)); } @@ -203,7 +204,6 @@ impl Db { let frames: i64 = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(2))?; Ok(frames.max(0)) } - } #[cfg(test)] @@ -314,7 +314,8 @@ mod tests { let db = Db::open(&path).unwrap(); for i in 0..2000 { - db.set_state_str(&format!("k{i}"), &"v".repeat(256)).unwrap(); + db.set_state_str(&format!("k{i}"), &"v".repeat(256)) + .unwrap(); } let full = db.stats().unwrap().total_bytes(); for i in 0..2000 { diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 851378b..5ee34a5 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod control; pub mod db; pub mod error; pub mod localindex; +pub mod profile; pub mod service; pub mod shell; pub mod syncignore; diff --git a/crates/pdfs-core/src/profile.rs b/crates/pdfs-core/src/profile.rs new file mode 100644 index 0000000..4351bb7 --- /dev/null +++ b/crates/pdfs-core/src/profile.rs @@ -0,0 +1,171 @@ +//! The machine profile that travels with a device (features.md 5.3). +//! +//! Sync folder mappings, pins, and the settings that shape them live in +//! `cache.db` and `config.json` — both on the local disk. When that disk dies, +//! the remote data survives but the *arrangement* of it does not: which folders +//! were synced, where they lived locally, which were on-demand, what was pinned. +//! +//! So the daemon mirrors that arrangement into a `profile.json` in its device's +//! root folder on Drive. It goes up the normal upload path, which means it is +//! end-to-end encrypted with the account keys like any other file — 5.3's +//! "encrypt with the user's Proton keys" is satisfied by *not* special-casing +//! it. It lives under the device rather than the Drive root because it describes +//! that device; adopting the device is what makes it relevant. +//! +//! This module is pure data. The upload/download and the restore flow live in +//! `pdfs-fuse`, next to the client that performs them. + +use serde::{Deserialize, Serialize}; + +/// File name of the profile inside the device root folder. +pub const PROFILE_FILE_NAME: &str = "profile.json"; + +/// Format version of the profile document. +/// +/// Bumped only for changes a *reader* cannot survive. Adding fields does not +/// qualify — unknown fields are ignored on the way in, so an older client reads +/// a newer profile's common subset. A newer `version` than this is refused +/// outright rather than half-applied: a restore that silently drops folders it +/// did not understand is worse than one that tells you to upgrade. +pub const PROFILE_VERSION: u32 = 1; + +/// One synced folder, as it was arranged on the machine that wrote the profile. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ProfileFolder { + /// Uid of the folder's remote root, under the device root. The stable + /// identity — the local path may not exist on the restoring machine, but + /// this does. + pub remote_uid: String, + /// Absolute local path it was synced to. A *suggestion* on restore: another + /// machine may have a different username, home, or mount layout. + pub local_path: String, + /// `mirror` or `ondemand`. + pub mode: String, +} + +/// One pinned node (P5 pins), as `pins` stores it. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ProfilePin { + pub uid: String, + /// Mountpoint-relative path, kept for display: a pin whose node has been + /// deleted should still be nameable in a restore summary. + pub path: String, + pub recursive: bool, +} + +/// The full arrangement of one machine. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub version: u32, + /// The device this profile describes. + pub device_uid: String, + /// Hostname of the machine that wrote it, so a restore prompt can say + /// *"restore settings from laptop-xyz?"* rather than quoting a uid. + pub hostname: String, + /// When it was written, epoch seconds. + pub saved_at: i64, + #[serde(default)] + pub folders: Vec, + #[serde(default)] + pub pins: Vec, + /// Global sync ignore patterns. Safe to restore as-is. + #[serde(default)] + pub ignore_patterns: Option>, + /// Machine-shaped settings. Carried, but never applied without asking: a + /// laptop's 5 GB cache budget and `~/ProtonDrive` mountpoint are not + /// obviously right on the next machine. + #[serde(default)] + pub cache_budget: Option, + #[serde(default)] + pub mountpoint: Option, +} + +impl Profile { + /// Parse a profile document, refusing one written by a newer client. + pub fn parse(bytes: &[u8]) -> Result { + let profile: Profile = + serde_json::from_slice(bytes).map_err(|e| format!("malformed profile.json: {e}"))?; + if profile.version > PROFILE_VERSION { + return Err(format!( + "profile.json was written by a newer client (version {}, this client understands {PROFILE_VERSION}); upgrade to restore it", + profile.version + )); + } + Ok(profile) + } + + /// Serialize for upload. Pretty-printed: it is a small file a user may well + /// open in the web UI to see what their machine had. + pub fn to_bytes(&self) -> Result, String> { + serde_json::to_vec_pretty(self).map_err(|e| format!("serialize profile: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Profile { + Profile { + version: PROFILE_VERSION, + device_uid: "dev-1".to_string(), + hostname: "laptop-xyz".to_string(), + saved_at: 1_700_000_000, + folders: vec![ProfileFolder { + remote_uid: "vol~link".to_string(), + local_path: "/home/nils/Documents".to_string(), + mode: "mirror".to_string(), + }], + pins: vec![ProfilePin { + uid: "vol~pin".to_string(), + path: "Photos/2024".to_string(), + recursive: true, + }], + ignore_patterns: Some(vec!["target/".to_string()]), + cache_budget: Some(5_000_000_000), + mountpoint: Some("/home/nils/ProtonDrive".to_string()), + } + } + + #[test] + fn round_trips() { + let p = sample(); + assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p); + } + + /// A field added by a later client must not break this one's restore: the + /// common subset is still usable, which is the whole reason `version` is + /// reserved for breaking changes only. + #[test] + fn unknown_fields_are_ignored() { + let json = br#"{ + "version": 1, + "device_uid": "dev-1", + "hostname": "laptop-xyz", + "saved_at": 1, + "folders": [], + "something_from_the_future": {"a": 1} + }"#; + let p = Profile::parse(json).unwrap(); + assert_eq!(p.device_uid, "dev-1"); + assert!(p.folders.is_empty()); + } + + /// Missing optional sections read as empty rather than failing: a profile + /// written before pins existed is still a valid folder mapping. + #[test] + fn absent_sections_default() { + let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#; + let p = Profile::parse(json).unwrap(); + assert!(p.pins.is_empty()); + assert!(p.ignore_patterns.is_none()); + } + + #[test] + fn future_version_is_refused() { + let mut p = sample(); + p.version = PROFILE_VERSION + 1; + let err = Profile::parse(&p.to_bytes().unwrap()).unwrap_err(); + assert!(err.contains("newer client"), "{err}"); + } +} diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index cea609f..558e233 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -73,18 +73,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.pin(&rel) { - Ok(name) => CtlResponse::Ok { - message: format!("pinned {name}"), - }, + Ok(name) => { + core.touch_profile(); + CtlResponse::Ok { + message: format!("pinned {name}"), + } + } Err(e) => CtlResponse::error(e), }, Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.unpin(&rel) { - Ok(name) => CtlResponse::Ok { - message: format!("unpinned {name}"), - }, + Ok(name) => { + core.touch_profile(); + CtlResponse::Ok { + message: format!("unpinned {name}"), + } + } Err(e) => CtlResponse::error(e), }, Err(e) => CtlResponse::error(e), @@ -456,9 +462,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U cfg.cache_budget = Some(bytes); dirs.save_config(&cfg) }) { - Ok(Ok(())) => CtlResponse::Ok { - message: format!("cache budget set to {bytes} bytes"), - }, + Ok(Ok(())) => { + core.touch_profile(); + CtlResponse::Ok { + message: format!("cache budget set to {bytes} bytes"), + } + } Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!( "budget applied but config write failed: {e}" ))), @@ -483,6 +492,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::AdoptDevice { uid }) => match core.adopt_device(uid.as_deref()) { + Ok(message) => CtlResponse::Ok { message }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::AddSyncFolder { local_path }) => { // Registering the device and uploading a folder tree far outlasts the // socket read timeout, so ack immediately and work on a background @@ -494,6 +507,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U let started = Instant::now(); match core.add_sync_folder(&path) { Ok(folder) => { + core.touch_profile(); let name = Path::new(&folder.local_path) .file_name() .and_then(|n| n.to_str()) @@ -522,15 +536,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Ok(CtlRequest::RemoveSyncFolder { id, delete_remote }) => { match core.remove_sync_folder(id, delete_remote) { - Ok(()) => CtlResponse::Ok { - message: "removed synced folder".to_string(), - }, + Ok(()) => { + core.touch_profile(); + CtlResponse::Ok { + message: "removed synced folder".to_string(), + } + } Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::SetSyncFolderMode { id, mode }) => { match core.request_sync_folder_mode(id, &mode) { Ok(message) => { + core.touch_profile(); core.log_activity(ActivityKind::Upload, &message, "", true); CtlResponse::Ok { message } } @@ -546,6 +564,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, } } + Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() { + Ok(items) => CtlResponse::RestorableFolders { items }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::RestoreSyncFolders { items }) => { + // Like AddSyncFolder: each restored folder downloads a whole tree, + // which outlasts the socket timeout. Ack, then work. + let core = core.clone(); + std::thread::spawn(move || match core.restore_sync_folders(&items) { + Ok(message) => { + core.touch_profile(); + core.log_activity(ActivityKind::Download, &message, "", true); + } + Err(e) => { + warn!(error = %e, "restore failed"); + core.log_activity(ActivityKind::Download, "restore folders", &e, false); + } + }); + CtlResponse::Ok { + message: "restoring folders".to_string(), + } + } Ok(CtlRequest::ShareNode { path, emails, @@ -640,6 +680,16 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(entries) => CtlResponse::Entries { entries }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::ListSharedFolder { uid }) => match core.list_shared_folder(&uid) { + Ok(entries) => CtlResponse::Entries { entries }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::OpenSharedFile { uid }) => match core.open_shared_file(&uid) { + Ok(p) => CtlResponse::FilePath { + path: p.display().to_string(), + }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::LeaveShared { uid }) => match core.leave_shared(&uid) { Ok(()) => { core.log_activity(ActivityKind::Unshare, "shared item", "left", true); diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 0753ae5..12ce056 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -26,9 +26,34 @@ use tracing::{info, warn}; use super::sync::{self, base_name}; use super::{ BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str, - dir_is_empty, evict_dir_contents, now_secs, parse_uid, sync_folder_info, this_hostname, + dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount, now_secs, parse_uid, + sync_folder_info, this_hostname, umount_session_unblocked, }; +/// The device uid this machine has been adopted into, from `config.json`. +/// +/// Read fresh each time rather than cached on [`Core`]: adoption rewrites the +/// config from the control socket, and the next `ensure_device` must see it +/// without a daemon restart. +pub(crate) fn adopted_device_uid() -> Option { + let uid = pdfs_core::config::AppDirs::new() + .ok()? + .load_config() + .device_uid?; + let uid = uid.trim().to_string(); + (!uid.is_empty()).then_some(uid) +} + +/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`. +pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> { + let dirs = pdfs_core::config::AppDirs::new() + .map_err(|e| CoreError::internal(format!("config dirs: {e}")))?; + let mut cfg = dirs.load_config(); + cfg.device_uid = uid.map(|u| u.to_string()); + dirs.save_config(&cfg) + .map_err(|e| CoreError::internal(format!("write config: {e}"))) +} + impl Core { // ---- devices ---------------------------------------------------------- @@ -43,12 +68,14 @@ impl Core { // No cached device row yet means this machine syncs nothing, so none of // the listed devices is ours. let this_uid = self.db.device_get().ok().flatten().map(|d| d.uid); + let pinned = adopted_device_uid(); Ok(devices .into_iter() .map(|d| { let uid = d.uid.to_string(); DeviceInfo { this_device: this_uid.as_deref() == Some(uid.as_str()), + adopted: pinned.as_deref() == Some(uid.as_str()), uid, name: d.name.unwrap_or_else(|_| "(unnamed device)".to_string()), device_type: device_type_str(d.device_type).to_string(), @@ -79,21 +106,97 @@ impl Core { Ok(()) } + /// Adopt an existing device as *this* machine's, pinning it in `config.json` + /// so hostname changes and reinstalls stop registering duplicates + /// (features.md 5.1). `None` clears the pin and returns to hostname matching. + /// + /// Refuses a uid that is not in the account: the whole point of the pin is + /// that it is authoritative, so accepting a bad one would defer the failure + /// to the next `ensure_device` with no obvious cause. + pub(crate) fn adopt_device(&self, uid: Option<&str>) -> CoreResult { + let Some(uid) = uid else { + set_adopted_device_uid(None)?; + return Ok("adoption cleared; device now resolved by hostname".to_string()); + }; + let remote = self + .rt + .block_on(self.client.enumerate_devices()) + .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; + let dev = remote + .into_iter() + .find(|d| d.uid.to_string() == uid) + .ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?; + + let name = dev + .name + .as_deref() + .ok() + .map(|n| n.to_string()) + .unwrap_or_else(this_hostname); + set_adopted_device_uid(Some(uid))?; + // Write the row through too, so callers that read the cached device + // (and `ListSyncFolders`' "this device" flag) agree immediately rather + // than after the next `ensure_device`. + self.db + .device_set(&StoredDevice { + uid: uid.to_string(), + share_id: dev.share_id.to_string(), + root_uid: dev.root_folder_uid.to_string(), + name: name.clone(), + created: dev.creation_time, + }) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + info!(uid, name, "adopted device"); + Ok(format!("adopted device {name}")) + } + // ---- device folder sync (devices.md, Phase 1) ------------------------- /// Auto-register (or recover) this machine as a Proton Drive Device, caching /// it so restarts reuse the same device. Recovery matches an existing remote /// Linux device by name before creating a new one, so a lost local record /// doesn't orphan the device's root folder. + /// + /// Resolution order: the *adopted* uid pinned in `config.json`, then the + /// cached DB row (validated remotely), then a hostname match, then create. + /// The pin exists because the hostname heuristic silently registers a second + /// device after a rename or reinstall, orphaning the first one's folders + /// (features.md 5.1). pub(crate) fn ensure_device(&self) -> CoreResult { let name = this_hostname(); - // Enumerate the remote devices once: used both to validate any cached - // record and to recover an existing device by name. + // Enumerate the remote devices once: used to validate the pin, validate + // any cached record, and recover an existing device by name. let remote = self .rt .block_on(self.client.enumerate_devices()) .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; + // An adopted uid is an explicit instruction, so it outranks everything + // and it fails loudly: falling back to the heuristic here would create + // the duplicate device the user adopted specifically to avoid. + if let Some(pin) = adopted_device_uid() { + let d = remote + .iter() + .find(|d| d.uid.to_string() == pin) + .ok_or_else(|| { + CoreError::not_found(format!( + "adopted device {pin} not found in this account; \ + run `pdfs devices adopt --clear` to return to hostname matching" + )) + })?; + let dev = StoredDevice { + uid: d.uid.to_string(), + share_id: d.share_id.to_string(), + root_uid: d.root_folder_uid.to_string(), + name: d.name.as_deref().ok().unwrap_or(&name).to_string(), + created: d.creation_time, + }; + self.db + .device_set(&dev) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + return Ok(dev); + } + // A cached device is only trustworthy if it still exists remotely. A // device deleted from another client (or the web UI) leaves a stale row // whose root folder is gone, so creating folders under it fails with @@ -346,8 +449,8 @@ impl Core { // folder the daemon no longer tracks, and nothing would ever unmount it. // Tear it down first — including before trashing the remote tree it // serves, which would otherwise leave it answering for deleted nodes. - if let Some(session) = self.mounts.lock().remove(&id) { - if let Err(e) = session.umount_and_join() { + if let Some((session, conn)) = self.mounts.lock().remove(&id) { + if let Err(e) = umount_session_unblocked(session, conn) { warn!(id, error = %e, "unmount on-demand folder failed"); } else { info!(id, path = %folder.local_path, "unmounted on-demand folder"); @@ -599,7 +702,9 @@ impl Core { return Err(SwitchBlocked::Failed(e.to_string())); } }; - self.mounts.lock().insert(id, session); + self.mounts + .lock() + .insert(id, (session, fuse_connection_id(&local))); let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); self.db.sync_folder_set_state(id, "idle", now_secs()).ok(); info!(id, path = %local.display(), "mounted sync folder on-demand"); @@ -607,8 +712,8 @@ impl Core { } _ => { // ondemand→mirror: tear down the secondary mount first. - if let Some(session) = self.mounts.lock().remove(&id) - && let Err(e) = session.umount_and_join() + if let Some((session, conn)) = self.mounts.lock().remove(&id) + && let Err(e) = umount_session_unblocked(session, conn) { warn!(id, error = %e, "unmount on-demand folder failed"); } @@ -676,6 +781,17 @@ impl Core { continue; } let local = PathBuf::from(&folder.local_path); + // A daemon killed before it could unmount (systemd's stop timeout, + // say) leaves this path attached to a dead FUSE connection: it still + // exists, but every stat on it fails with ENOTCONN — so `is_dir()` + // reads it as *missing* and the folder gets parked in `error` on + // every subsequent start, with no way back but a manual + // `fusermount -u`. Detach the corpse first; the directory + // underneath then restores normally. + if is_stale_mount(&local) { + warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount"); + clear_stale_mount(&local); + } if !local.is_dir() { warn!(id = folder.id, path = %local.display(), "restore on-demand: local path missing"); let _ = self @@ -737,7 +853,9 @@ impl Core { }; match self.spawn_ondemand_mount(&local, root) { Ok(session) => { - self.mounts.lock().insert(folder.id, session); + self.mounts + .lock() + .insert(folder.id, (session, fuse_connection_id(&local))); let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs()); info!(id = folder.id, path = %local.display(), "remounted on-demand folder"); } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 6b054df..959aaed 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -75,6 +75,7 @@ use control::run_control_socket; mod devices; mod drain; mod photos; +mod profile; mod sharing; mod state; mod sync; @@ -506,8 +507,10 @@ struct Core { /// id (devices.md Phase 3). Each is a `ProtonFs` rooted at the folder's remote /// node, mounted over its local path, sharing this Core's client/cache/db but /// with its own inode space (`fork_state`). Held so we can unmount on toggle - /// back to `mirror` and on daemon shutdown. - mounts: Arc>>, + /// back to `mirror` and on daemon shutdown. The `u32` is the FUSE connection + /// id (see [`fuse_connection_id`]), captured at mount time so teardown can + /// abort a mid-transfer connection instead of blocking on it. + mounts: Arc>>, /// Per-sync-folder locks, held for a whole reconcile pass and for a whole /// mode switch. A `mirror→ondemand` flip evicts the local tree and mounts /// FUSE over it, so it must never overlap a pass that is walking and @@ -3317,22 +3320,25 @@ impl ProtonFs { let st = self.core.state.lock(); st.children.get(&parent).and_then(|kids| { kids.iter().copied().find_map(|ino| { - st.entries.get(&ino).filter(|e| e.node.name == name).map(|e| { - // A `lookup` reply carries attrs with the same TTL a - // `getattr` would, so `ls -l` — which is one `lookup` - // per entry and no `getattr` at all — takes its sizes - // from here. Resolving only in `getattr` left the whole - // listing provisional (bugs.md B14). - let provisional = matches!( - &e.node.kind, - NodeKind::File { - claimed_size: None, - .. - } - ) - .then(|| (e.parent, e.uid.clone())); - (ino, self.attr(ino, &e.node), provisional) - }) + st.entries + .get(&ino) + .filter(|e| e.node.name == name) + .map(|e| { + // A `lookup` reply carries attrs with the same TTL a + // `getattr` would, so `ls -l` — which is one `lookup` + // per entry and no `getattr` at all — takes its sizes + // from here. Resolving only in `getattr` left the whole + // listing provisional (bugs.md B14). + let provisional = matches!( + &e.node.kind, + NodeKind::File { + claimed_size: None, + .. + } + ) + .then(|| (e.parent, e.uid.clone())); + (ino, self.attr(ino, &e.node), provisional) + }) }) }) }; @@ -3351,9 +3357,9 @@ impl ProtonFs { if !off_loop { let fs = self.clone(); let name = name.to_string(); - self.core - .workers - .run(Lane::Meta, move || fs.serve_lookup(parent, &name, reply, true)); + self.core.workers.run(Lane::Meta, move || { + fs.serve_lookup(parent, &name, reply, true) + }); return; } self.core.upgrade_sizes_for_parent(ino, &uid, grandparent); @@ -3932,9 +3938,9 @@ impl Filesystem for ProtonFs { return; } let fs = self.clone(); - self.core - .workers - .run(Lane::Meta, move || fs.serve_lookup(parent, &name, reply, true)); + self.core.workers.run(Lane::Meta, move || { + fs.serve_lookup(parent, &name, reply, true) + }); } fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option, reply: ReplyAttr) { @@ -5198,6 +5204,66 @@ pub enum MountOutcome { Unmounted, } +/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a +/// daemon killed before it could unmount leaves behind. +/// +/// The kernel answers every operation on such a path with `ENOTCONN`, which the +/// ordinary existence checks (`is_dir`, `exists`) report as plain `false`: the +/// path looks *absent* rather than broken. Callers that would otherwise treat +/// that as "nothing to mount here" use this to tell the two apart. +pub(crate) fn is_stale_mount(path: &Path) -> bool { + matches!( + std::fs::metadata(path).map_err(|e| e.raw_os_error()), + Err(Some(libc::ENOTCONN)) + ) +} + +/// A secondary (on-demand sync folder) FUSE session, paired with its FUSE +/// connection id so teardown can abort a mid-transfer connection rather than +/// block on it. See [`Core::mounts`] and [`umount_session_unblocked`]. +type SecondaryMount = (BackgroundSession, Option); + +/// The kernel's id for the FUSE connection backing the mount at `mountpoint` — +/// the directory name under `/sys/fs/fuse/connections`, which is the minor +/// number of the mountpoint's device. Must be read *while still mounted*; after +/// unmount the path resolves to the underlying directory on another device. +/// `None` when the path can't be stat'd. +fn fuse_connection_id(mountpoint: &Path) -> Option { + use std::os::unix::fs::MetadataExt; + let dev = std::fs::metadata(mountpoint).ok()?.dev(); + Some(libc::minor(dev)) +} + +/// Force the kernel to abort FUSE connection `id`, erroring every in-flight +/// request. +/// +/// On a stop signal we unmount lazily (`MNT_DETACH`) so the call succeeds even +/// mid-transfer — but detach only removes the mountpoint; it does *not* end the +/// connection while a request is still in flight. fuser's session loop then +/// blocks on `/dev/fuse` waiting for a next request that never comes, so `join` +/// hangs — long enough during a transfer that systemd's stop timeout SIGKILLs +/// the daemon mid-unmount, stranding the on-demand mounts as dead endpoints. +/// Writing the connection's `abort` file makes the pending reads fail with +/// `ENODEV`, so the loop returns and `join` completes at once. Best-effort: +/// there is nothing more to do on the shutdown path if it fails. +fn abort_fuse_connection(id: u32) { + let path = format!("/sys/fs/fuse/connections/{id}/abort"); + if std::fs::write(&path, b"1").is_ok() { + info!(id, "aborted FUSE connection to unblock unmount"); + } +} + +/// Unmount a background session that may be mid-transfer without wedging: abort +/// its connection first (so the session loop exits promptly), then lazily +/// unmount and join. `conn` is the id captured at mount time; `None` skips the +/// abort and just unmounts (a healthy idle mount joins on its own `Destroy`). +fn umount_session_unblocked(session: BackgroundSession, conn: Option) -> std::io::Result<()> { + if let Some(id) = conn { + abort_fuse_connection(id); + } + session.umount_and_join() +} + /// Best-effort teardown of a stale mount left behind by a crashed daemon. A /// previous run that died without unmounting leaves the kernel mount in place, /// so the fresh `Session::new` below would fail with EBUSY ("Device or resource @@ -5419,6 +5485,9 @@ pub fn mount( // for the event task. `spawn` runs the session loop on its own background // thread; we then wait here for either a stop signal or the mount ending. let bg = Session::new(fs, mountpoint, &config)?.spawn()?; + // The connection id, captured now while the mount is live, so a stop signal + // mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`). + let main_conn = fuse_connection_id(mountpoint); let notifier = bg.notifier(); // Same channel, kept on the `Core` so background work (a size upgrade, say) // can invalidate kernel-cached metadata without threading a handle through. @@ -5470,7 +5539,7 @@ pub fn mount( match sig_rx.recv_timeout(Duration::from_millis(500)) { Ok(()) => { info!("stop requested; unmounting"); - if let Err(e) = bg.umount_and_join() { + if let Err(e) = umount_session_unblocked(bg, main_conn) { warn!(error = %e, "umount_and_join failed"); } break MountOutcome::Shutdown; @@ -5495,8 +5564,8 @@ pub fn mount( // Unmount every on-demand sync folder too, or the kernel mounts linger as // stale and the next start fails with EBUSY (devices.md Phase 3). let secondaries: Vec<_> = core.mounts.lock().drain().collect(); - for (id, session) in secondaries { - if let Err(e) = session.umount_and_join() { + for (id, (session, conn)) in secondaries { + if let Err(e) = umount_session_unblocked(session, conn) { warn!(id, error = %e, "unmount on-demand folder failed"); } } @@ -5880,7 +5949,20 @@ mod pending_size_tests { #[cfg(test)] mod tests { - use super::{Intervals, conflict_name}; + use super::{Intervals, conflict_name, is_stale_mount}; + + /// The predicate must answer *only* for a dead FUSE connection. A healthy + /// directory and an absent path are both "not stale" — widening it to any + /// `metadata` error would make the on-demand restore lazily unmount paths + /// that are simply missing. + #[test] + fn is_stale_mount_is_narrow() { + let dir = std::env::temp_dir().join(format!("pdfs-stale-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + assert!(!is_stale_mount(&dir)); + assert!(!is_stale_mount(&dir.join("no-such-entry"))); + let _ = std::fs::remove_dir_all(&dir); + } /// Flatten `segments` into a readable form for assertions. fn segs(iv: &Intervals, start: u64, end: u64) -> Vec<(u64, u64, bool)> { diff --git a/crates/pdfs-fuse/src/profile.rs b/crates/pdfs-fuse/src/profile.rs new file mode 100644 index 0000000..4ffd9fb --- /dev/null +++ b/crates/pdfs-fuse/src/profile.rs @@ -0,0 +1,394 @@ +//! Reading and writing this machine's `profile.json` in its device root, and +//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3). +//! +//! The document itself is defined in [`pdfs_core::profile`]; this is the half +//! that talks to Drive. Saving is best-effort and never blocks a user action: +//! the profile is a convenience for a *future* machine, so failing to write it +//! must not fail the folder add that triggered it. + +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +use pdfs_core::control::RestorableFolder; +use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin}; +use pdfs_core::{CoreError, CoreResult}; +use proton_drive_rs::proton_sdk::ids::NodeUid; +use tracing::{info, warn}; + +use super::sync; +use super::{Core, now_secs, parse_uid, this_hostname}; + +/// Coalescing flags for background profile saves. +/// +/// A single burst of user actions (adding three folders, pinning a tree) should +/// cost one upload, not one per action. `SAVING` admits one worker at a time and +/// `DIRTY` records that something changed while it was busy, so the worker takes +/// exactly one more lap afterwards. +/// +/// Process-global rather than a [`Core`] field because on-demand mounts fork +/// their own `Core` (`Core::fork_state`) and all of them describe the same +/// machine — there is one profile per daemon, so there is one flag pair. +static SAVE_STATE: OnceLock<(AtomicBool, AtomicBool)> = OnceLock::new(); + +fn save_state() -> &'static (AtomicBool, AtomicBool) { + SAVE_STATE.get_or_init(|| (AtomicBool::new(false), AtomicBool::new(false))) +} + +impl Core { + /// Note that the arrangement this machine's profile describes has changed, + /// and upload a fresh profile in the background. + /// + /// Fire-and-forget by design: callers are mutation paths (add/remove a sync + /// folder, switch a mode, pin a node, save settings) whose success does not + /// depend on the backup succeeding. + pub(crate) fn touch_profile(&self) { + let (saving, dirty) = save_state(); + dirty.store(true, Ordering::SeqCst); + // Someone is already uploading; they will see DIRTY and take another lap. + if saving.swap(true, Ordering::SeqCst) { + return; + } + let core = self.clone(); + std::thread::spawn(move || { + let (saving, dirty) = save_state(); + while dirty.swap(false, Ordering::SeqCst) { + if let Err(e) = core.save_profile() { + warn!(error = %e.message, "profile backup failed"); + break; + } + } + saving.store(false, Ordering::SeqCst); + }); + } + + /// Serialize this machine's arrangement and upload it to `profile.json` in + /// the device root, replacing any existing one. + /// + /// Does nothing when no device is registered: a machine that has never + /// synced a folder has nothing to describe, and registering a device just to + /// hold an empty profile would put a stray computer in the user's Drive. + pub(crate) fn save_profile(&self) -> CoreResult<()> { + let Some(device) = self + .db + .device_get() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + else { + return Ok(()); + }; + let root_uid = parse_uid(&device.root_uid).ok_or_else(|| { + CoreError::internal(format!("bad device root uid: {}", device.root_uid)) + })?; + + let profile = self.build_profile(&device.uid)?; + let bytes = profile.to_bytes().map_err(CoreError::internal)?; + let len = bytes.len() as i64; + + // Replace the existing document rather than adding a second file of the + // same name: the profile is a single mutable record, and a device root + // with `profile.json` and `profile (1).json` in it teaches the restore + // nothing about which to trust. + let existing = self.find_device_child_file(&root_uid, PROFILE_FILE_NAME)?; + match existing { + Some(uid) => self + .rt + .block_on(self.client.upload_new_revision_from( + &uid, + Cursor::new(bytes), + len, + Vec::new(), + Some(now_secs()), + )) + .map(|_| ()) + .map_err(|e| CoreError::from_api(&e, "upload profile revision"))?, + None => { + self.rt + .block_on(self.client.upload_file_replacing_draft_from( + &root_uid, + PROFILE_FILE_NAME, + "application/json", + Cursor::new(bytes), + len, + Vec::new(), + Some(now_secs()), + false, + )) + .map_err(|e| CoreError::from_api(&e, "upload profile"))?; + } + } + info!(folders = profile.folders.len(), "profile backed up"); + Ok(()) + } + + /// Snapshot the local arrangement into a [`Profile`]. + fn build_profile(&self, device_uid: &str) -> CoreResult { + let folders = self + .db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .into_iter() + .map(|f| ProfileFolder { + remote_uid: f.remote_uid, + local_path: f.local_path, + // A queued switch is where the user asked the folder to be, so + // that is what the next machine should set up. + mode: f.pending_mode.unwrap_or(f.mode), + }) + .collect(); + let pins = self + .db + .pin_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .into_iter() + .map(|(uid, path, recursive)| ProfilePin { + uid, + path, + recursive, + }) + .collect(); + let cfg = pdfs_core::config::AppDirs::new() + .map(|d| d.load_config()) + .unwrap_or_default(); + Ok(Profile { + version: PROFILE_VERSION, + device_uid: device_uid.to_string(), + hostname: this_hostname(), + saved_at: now_secs(), + folders, + pins, + ignore_patterns: cfg.ignore_patterns, + cache_budget: cfg.cache_budget, + mountpoint: cfg.mountpoint, + }) + } + + /// An untrashed *file* named `name` directly under the device root. + /// + /// The sibling of `find_device_child_folder`; kept separate because a folder + /// named `profile.json` must not be mistaken for the profile. + fn find_device_child_file( + &self, + root_uid: &NodeUid, + name: &str, + ) -> CoreResult> { + let uids = self + .rt + .block_on(self.client.enumerate_folder_children_node_uids(root_uid)) + .map_err(|e| CoreError::from_api(&e, "list device root"))?; + if uids.is_empty() { + return Ok(None); + } + let nodes = self + .rt + .block_on(self.client.enumerate_nodes(&uids)) + .map_err(|e| CoreError::from_api(&e, "resolve device root children"))?; + Ok(nodes + .into_iter() + .find(|n| !n.is_folder() && !n.trashed && n.name == name) + .map(|n| n.uid)) + } + + /// Download and parse the profile in the device root, if there is one. + fn load_profile(&self, root_uid: &NodeUid) -> CoreResult> { + let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else { + return Ok(None); + }; + let mut buf: Vec = Vec::new(); + self.rt + .block_on(self.client.download_file_to(&uid, &mut buf)) + .map_err(|e| CoreError::from_api(&e, "download profile"))?; + Profile::parse(&buf).map(Some).map_err(CoreError::invalid) + } + + // ---- restore (features.md 5.2) ---------------------------------------- + + /// The folders under this machine's device that could be synced here, each + /// carrying a proposed local path. + /// + /// The proposal comes from the profile when one exists and its path is + /// usable on this machine, and from `~/` otherwise — a restored + /// `/home/other-user/Documents` would be wrong on a machine where that user + /// does not exist, and silently creating it would be worse. + pub(crate) fn list_restorable_folders(&self) -> CoreResult> { + let device = self.ensure_device()?; + let root_uid = parse_uid(&device.root_uid).ok_or_else(|| { + CoreError::internal(format!("bad device root uid: {}", device.root_uid)) + })?; + + let profile = match self.load_profile(&root_uid) { + Ok(p) => p, + // A missing or unreadable profile is not fatal: the remote tree + // alone is enough to offer a restore, just with plainer defaults. + Err(e) => { + warn!(error = %e.message, "could not read profile; proposing defaults"); + None + } + }; + + let uids = self + .rt + .block_on(self.client.enumerate_folder_children_node_uids(&root_uid)) + .map_err(|e| CoreError::from_api(&e, "list device root"))?; + let nodes = if uids.is_empty() { + Vec::new() + } else { + self.rt + .block_on(self.client.enumerate_nodes(&uids)) + .map_err(|e| CoreError::from_api(&e, "resolve device root children"))? + }; + + // Folders already synced here are not restorable — they are already + // restored — but they are still listed, flagged, so the picker can show + // the whole device rather than a confusing subset. + let existing: Vec = self + .db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .into_iter() + .map(|f| f.remote_uid) + .collect(); + + Ok(nodes + .into_iter() + .filter(|n| n.is_folder() && !n.trashed) + .map(|n| { + let uid = n.uid.to_string(); + let saved = profile + .as_ref() + .and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid)); + let proposed = saved + .map(|f| PathBuf::from(&f.local_path)) + .filter(|p| path_is_usable_here(p)) + .unwrap_or_else(|| default_local_path(&n.name)); + RestorableFolder { + already_synced: existing.iter().any(|e| e == &uid), + remote_uid: uid, + name: n.name, + local_path: proposed.to_string_lossy().to_string(), + mode: saved + .map(|f| f.mode.clone()) + .unwrap_or_else(|| "mirror".into()), + } + }) + .collect()) + } + + /// Attach remote device folders to local paths and let the sync engine fill + /// them in. + /// + /// The download is not written here: a new sync-folder row starts with an + /// empty baseline, and an empty baseline against an empty local directory + /// and a populated remote already classifies as "download everything". The + /// restore is therefore just bookkeeping plus a `Reconcile`, and it reuses + /// the engine's conflict handling for the case where the local directory + /// turns out not to be empty after all. + pub(crate) fn restore_sync_folders( + &self, + items: &[pdfs_core::control::RestoreItem], + ) -> CoreResult { + if items.is_empty() { + return Err(CoreError::invalid("nothing to restore")); + } + let device = self.ensure_device()?; + let existing = self + .db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + + let mut restored = 0usize; + let mut skipped: Vec = Vec::new(); + for item in items { + let local = PathBuf::from(&item.local_path); + if !local.is_absolute() { + skipped.push(format!("{}: local path must be absolute", item.local_path)); + continue; + } + if existing.iter().any(|f| f.remote_uid == item.remote_uid) { + skipped.push(format!("{}: already synced", item.local_path)); + continue; + } + if existing + .iter() + .any(|f| Path::new(&f.local_path) == local.as_path()) + { + skipped.push(format!("{}: path already in use", item.local_path)); + continue; + } + if let Err(e) = std::fs::create_dir_all(&local) { + skipped.push(format!("{}: {e}", item.local_path)); + continue; + } + let mode = match item.mode.as_str() { + "mirror" | "ondemand" => item.mode.as_str(), + other => { + skipped.push(format!("{}: unknown mode {other}", item.local_path)); + continue; + } + }; + let id = self + .db + .sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + // `ondemand` is applied through the normal request path, which + // mounts the FUSE session and handles the folder being busy — the + // restore does not get its own copy of that logic. + if mode == "ondemand" { + let _ = self.request_sync_folder_mode(id, "ondemand"); + } + let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); + restored += 1; + } + let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); + + let mut message = format!("restored {restored} folder(s)"); + if !skipped.is_empty() { + message.push_str(&format!("; skipped: {}", skipped.join(", "))); + } + info!(restored, skipped = skipped.len(), "restore complete"); + Ok(message) + } +} + +/// Whether a path recorded on another machine can be reused verbatim here. +/// +/// Requires the *parent* to exist: `~/Documents` on a machine with the same +/// username is a fine proposal even though the directory itself is gone (the +/// restore creates it), but `/home/someone-else/Documents` is not. +fn path_is_usable_here(path: &Path) -> bool { + path.is_absolute() && path.parent().is_some_and(|p| p.is_dir()) +} + +/// `~/`, or `/` if the home directory cannot be determined. +fn default_local_path(name: &str) -> PathBuf { + match pdfs_core::config::AppDirs::new() + .ok() + .and_then(|d| d.home_dir()) + { + Some(home) => home.join(name), + None => PathBuf::from("/").join(name), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn foreign_home_is_not_reused() { + assert!(!path_is_usable_here(Path::new( + "/home/definitely-not-a-user-here/Documents" + ))); + assert!(!path_is_usable_here(Path::new("relative/path"))); + } + + /// A path whose parent exists but which does not itself is exactly the + /// restore case — the directory is about to be created. + #[test] + fn missing_leaf_under_existing_parent_is_reused() { + let dir = std::env::temp_dir().join("pdfs-restore-probe"); + std::fs::create_dir_all(&dir).unwrap(); + assert!(path_is_usable_here(&dir.join("Documents"))); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index 9c2deb5..e45b709 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -15,7 +15,34 @@ use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{MemberRole, NodeKind}; -use super::{Core, ROOT_INO, parse_uid, public_link_info, role_from_str, role_to_str}; +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 { + let is_dir = n.is_folder(); + let size = match &n.kind { + NodeKind::File { + claimed_size, + total_size_on_storage, + .. + } => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64, + NodeKind::Folder => 0, + }; + DirEntry { + name: n.name, + is_dir, + size, + modified: n.modification_time, + pinned: false, + cached: false, + uid: n.uid.to_string(), + path: String::new(), + } +} impl Core { // ---- sharing a node --------------------------------------------------- @@ -232,30 +259,62 @@ impl Core { .rt .block_on(self.client.enumerate_nodes(&uids)) .map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?; - Ok(nodes - .into_iter() - .map(|n| { - let is_dir = n.is_folder(); - let size = match &n.kind { - NodeKind::File { - claimed_size, - total_size_on_storage, - .. - } => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64, - NodeKind::Folder => 0, - }; - DirEntry { - name: n.name, - is_dir, - size, - modified: 0, - pinned: false, - cached: false, - uid: n.uid.to_string(), - path: String::new(), - } - }) - .collect()) + Ok(nodes.into_iter().map(shared_entry).collect()) + } + + /// List the children of a folder shared with me, addressed by uid. + /// + /// A shared subtree has no path in this account's mount — the FUSE tree only + /// spans my own volume — so browsing into one is uid-addressed the whole way + /// down, each listing handing the front-end the uids it descends with next. + pub(crate) fn list_shared_folder(&self, uid: &str) -> CoreResult> { + let uid = + parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?; + let child_uids = self + .rt + .block_on(self.client.enumerate_folder_children_node_uids(&uid)) + .map_err(|e| CoreError::from_api(&e, "enumerate shared children"))?; + if child_uids.is_empty() { + return Ok(Vec::new()); + } + let nodes = self + .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()) + } + + /// Download a file shared with me into the content cache, returning its + /// on-disk path (served from cache when a fresh blob already exists). + /// + /// The by-uid twin of [`Core::open_file`]: same cache and same tracked + /// download, but the node is fetched from the server rather than resolved + /// through the inode tree, which does not cover other people's volumes. + /// + /// [`Core::open_file`]: super::Core::open_file + pub(crate) fn open_shared_file(&self, uid: &str) -> CoreResult { + let uid = + parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?; + let node = self + .rt + .block_on(self.client.get_node(&uid)) + .map_err(|e| CoreError::from_api(&e, "get shared node"))? + .ok_or_else(|| CoreError::not_found("shared node not found"))?; + if !node.is_file() { + return Err(CoreError::invalid("not a regular file")); + } + let size = node_size(&node); + let mtime = node.modification_time; + if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) { + return Ok(p); + } + let bytes = self + .download_file_tracked(&uid, &node.name, size) + .map_err(|e| CoreError::from_api(&e, "download"))?; + self.cache + .store(&uid, mtime, size, &bytes) + .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; + Ok(self.cache.content_path(&uid)) } /// Leave a shared node by its uid. diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 95b7e57..4c82f48 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -2094,7 +2094,8 @@ mod tests { let remote: HashMap = HashMap::new(); let baseline: HashMap = HashMap::new(); - let order = classification_order(&local, &remote, &baseline, &rules_for(&["node_modules/"])); + let order = + classification_order(&local, &remote, &baseline, &rules_for(&["node_modules/"])); assert_eq!( order, @@ -2181,6 +2182,16 @@ mod tests { assert!(guard_local_wipe(&base, &fresh).is_err()); } + /// A restored folder (features.md 5.2) starts with an empty baseline against + /// an empty local directory and a full remote. That must reconcile as + /// "download everything" rather than tripping the wipe guard — otherwise + /// every restore would wedge its folder on the first pass. + #[test] + fn guard_local_wipe_is_inert_for_a_restored_folder() { + let empty: HashMap = HashMap::new(); + assert!(guard_local_wipe(&empty, &empty).is_ok()); + } + #[test] fn parent_and_base_split_relative_paths() { assert_eq!(parent_rel("a"), ""); diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 7071806..01144ec 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -41,9 +41,10 @@ use pdfs_core::config::AppDirs; use pdfs_core::control::{ ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo, - JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, - ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, - TransferDirection, TransferItem, pending_summary, send, + JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, + RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem, + SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary, + send, }; use pdfs_core::service; @@ -478,6 +479,7 @@ fn build_window(app: &adw::Application) { with_me_group: shared_widgets.shared_with_me.clone(), invitations_group: shared_widgets.invitations.clone(), bookmarks_group: shared_widgets.bookmarks.clone(), + nav: RefCell::new(Vec::new()), rows: RefCell::new(Vec::new()), inflight: Cell::new(false), loaded_at: Cell::new(None), @@ -535,7 +537,12 @@ 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); + wire_devices( + &ui, + &devices_widgets.retry, + &devices_widgets.add_folder, + &devices_widgets.restore, + ); wire_activity(&ui, &activity_widgets.retry); wire_refresh( &ui, diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 905af9c..9f2d476 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -25,6 +25,9 @@ pub(crate) struct DevicesWidgets { /// [`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, pub(crate) retry: gtk4::Button, pub(crate) refresh: gtk4::Button, } @@ -47,11 +50,18 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { .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") + .valign(gtk4::Align::Center) + .build(); + restore.add_css_class("flat"); let refresh = refresh_button(); let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); 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 @@ -115,6 +125,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { sync_group, group, add_folder, + restore, retry, refresh, }, @@ -122,7 +133,12 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { } /// Install the Devices page's retry button and the "Add Folder" action. -pub(crate) fn wire_devices(ui: &Rc, retry: >k4::Button, add_folder: >k4::Button) { +pub(crate) fn wire_devices( + ui: &Rc, + retry: >k4::Button, + add_folder: >k4::Button, + restore: >k4::Button, +) { let ui_retry = ui.clone(); retry.connect_clicked(move |_| { service::restart(); @@ -130,6 +146,8 @@ pub(crate) fn wire_devices(ui: &Rc, retry: >k4::Button, add_folder: >k4: }); 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)); } /// Show a status page in place of the Devices list. @@ -294,6 +312,21 @@ pub(crate) fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { .valign(gtk4::Align::Center) .build(); rename.add_css_class("flat"); + // Adoption is how a reinstalled or renamed machine re-attaches to the + // device it used to be, instead of registering a duplicate. It only + // makes sense on *another* device's row, which is the only place this + // loop paints. + let adopt = gtk4::Button::builder() + .icon_name("insert-object-symbolic") + .tooltip_text("Use this computer's identity for this machine") + .valign(gtk4::Align::Center) + .build(); + adopt.add_css_class("flat"); + let ui_ad = ui.clone(); + let uid_ad = dev.uid.clone(); + let name_ad = dev.name.clone(); + adopt.connect_clicked(move |_| prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)); + row.add_suffix(&adopt); let ui_ren = ui.clone(); let uid_ren = dev.uid.clone(); let name_ren = dev.name.clone(); @@ -533,6 +566,110 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc) { }); } +/// Ask the daemon what this machine's device holds, then show a picker mapping +/// each remote folder onto a local directory. +/// +/// The daemon's paths are proposals — from the device's `profile.json` when it +/// makes sense here, else `~/` — so every one of them is editable and +/// nothing is restored without being ticked. +pub(crate) fn prompt_restore_folders(ui: &Rc) { + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListRestorableFolders); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::RestorableFolders { items })) => show_restore_picker(&ui, items), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't list folders to restore", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't list folders to restore", + "The mount service didn't respond.", + ), + } + }); +} + +/// The restore picker itself: one editable row per restorable folder. +fn show_restore_picker(ui: &Rc, items: Vec) { + let win = ui_window(ui); + let candidates: Vec = + items.into_iter().filter(|f| !f.already_synced).collect(); + if candidates.is_empty() { + toast( + ui, + "Nothing to restore — this computer's folders are all synced here.", + ); + return; + } + + let group = adw::PreferencesGroup::builder() + .description( + "Tick the folders to sync to this machine, and adjust where each one should live.", + ) + .build(); + // Held so the response handler can read back what the user ticked and typed. + let mut controls: Vec<(String, String, gtk4::CheckButton, adw::EntryRow)> = Vec::new(); + for f in candidates { + let check = gtk4::CheckButton::builder() + .active(true) + .valign(gtk4::Align::Center) + .build(); + let row = adw::EntryRow::builder().title(&f.name).build(); + row.set_text(&f.local_path); + row.add_prefix(&check); + group.add(&row); + controls.push((f.remote_uid, f.mode, check, row)); + } + + let scroll = gtk4::ScrolledWindow::builder() + .propagate_natural_height(true) + .max_content_height(420) + .child(&group) + .build(); + let dialog = adw::AlertDialog::builder() + .heading("Restore folders") + .body("These folders are backed up under this computer in Proton Drive.") + .extra_child(&scroll) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("restore", "Restore"); + dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("restore")); + dialog.set_close_response("cancel"); + + let ui = ui.clone(); + dialog.connect_response(None, move |_, resp| { + if resp != "restore" { + return; + } + let items: Vec = controls + .iter() + .filter(|(_, _, check, _)| check.is_active()) + .map(|(uid, mode, _, row)| RestoreItem { + remote_uid: uid.clone(), + local_path: row.text().to_string(), + mode: mode.clone(), + }) + .collect(); + if items.is_empty() { + return; + } + // The daemon acks before the downloads finish, like AddSyncFolder — the + // rows appear as the periodic refresh picks them up. + run_devices_mutation( + &ui, + Request::RestoreSyncFolders { items }, + "Restoring folders…", + "Couldn't restore folders", + ); + }); + dialog.present(win.as_ref()); +} + /// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the /// row's subtitle and switch reflect the daemon's real state (the request may be /// rejected, e.g. switching to on-demand while a folder is mid-sync). @@ -698,6 +835,46 @@ pub(crate) fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) { dialog.present(win.as_ref()); } +/// Confirm, then adopt another device as this machine's identity. +/// +/// Worth a confirmation rather than a plain click: adoption re-points this +/// machine's syncing at another computer's device folder, and the folders +/// already synced here keep pointing at the old one until they are removed. The +/// dialog says so, because the alternative is a user discovering it by watching +/// their folders diverge. +pub(crate) fn prompt_adopt_device(ui: &Rc, uid: &str, name: &str) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading("Use this computer's identity") + .body(format!( + "Treat this machine as “{name}”?\n\nNew synced folders are created under that \ + computer in Proton Drive, and this machine keeps that identity even if its hostname \ + changes. Folders already synced here are not moved.\n\nUse “Restore folders” \ + afterwards to bring back what “{name}” was syncing." + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("adopt", "Use identity"); + dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let ui = ui.clone(); + let uid = uid.to_string(); + dialog.connect_response(None, move |_, resp| { + if resp == "adopt" { + run_devices_mutation( + &ui, + Request::AdoptDevice { + uid: Some(uid.clone()), + }, + "Identity adopted", + "Couldn't adopt that computer", + ); + } + }); + dialog.present(win.as_ref()); +} + /// Run a mutation raised from the Devices page and reload the page on success. pub(crate) fn run_devices_mutation( ui: &Rc, diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 51f5149..95c3a6f 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -10,6 +10,10 @@ pub(crate) struct SharedState { pub(crate) with_me_group: adw::PreferencesGroup, pub(crate) invitations_group: adw::PreferencesGroup, pub(crate) bookmarks_group: adw::PreferencesGroup, + /// Where in a shared folder the page currently is, as `(uid, name)` from the + /// top level down. Empty = the top level. Shared subtrees have no path in the + /// mount, so descending is uid-addressed and the stack *is* the breadcrumb. + pub(crate) nav: RefCell>, pub(crate) rows: RefCell>, /// Guards the Shared page's load so overlapping navigations don't stack. pub(crate) inflight: Cell, @@ -139,10 +143,19 @@ pub(crate) fn shared_status(ui: &Rc, icon: &str, title: &str, description: & /// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in /// parallel and repaint the page once all three land. +/// +/// Inside a shared folder ([`SharedState::nav`] non-empty) only that folder's +/// children are fetched: invitations and bookmarks belong to the top level, and +/// carrying them down a subtree would read as if they lived there. pub(crate) fn load_shared(ui: &Rc) { if ui.shared.inflight.get() { return; } + let current = ui.shared.nav.borrow().last().cloned(); + if let Some((uid, _)) = current { + load_shared_folder(ui, uid); + return; + } ui.shared.inflight.set(true); shared_status( ui, @@ -192,6 +205,163 @@ pub(crate) fn load_shared(ui: &Rc) { }); } +/// List one shared folder's children and repaint the page as that folder's view. +/// The uid comes from the row that was activated (or from the nav stack on a +/// reload) — a shared subtree is reachable no other way. +fn load_shared_folder(ui: &Rc, uid: String) { + ui.shared.inflight.set(true); + shared_status( + ui, + "folder-symbolic", + "Loading…", + "Reading this shared folder.", + false, + ); + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedFolder { uid }); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.shared.inflight.set(false); + match result { + Ok(Ok(Response::Entries { entries })) => { + repaint_shared_folder(&ui, &entries); + ui.shared.loaded_at.set(Some(Instant::now())); + } + Ok(Ok(Response::Error { message, kind })) => { + // The folder is gone or access was revoked: fall back to the top + // level rather than stranding the page on a dead uid. + ui.shared.nav.borrow_mut().pop(); + toast_failure(&ui, "Couldn't open shared folder", &message, kind); + load_shared(&ui); + } + _ => { + ui.shared.loaded_at.set(None); + shared_unreachable(&ui); + } + } + }); +} + +/// Paint the contents of the shared folder at the top of the nav stack: a row to +/// go back up, then the children. Invitations and bookmarks are hidden here — +/// they are top-level things, not contents of this folder. +fn repaint_shared_folder(ui: &Rc, entries: &[DirEntry]) { + for (group, row) in ui.shared.rows.borrow_mut().drain(..) { + group.remove(&row); + } + ui.shared.content.set_visible_child_name("list"); + ui.shared.invitations_group.set_visible(false); + ui.shared.bookmarks_group.set_visible(false); + + let nav = ui.shared.nav.borrow().clone(); + let title = nav + .iter() + .map(|(_, name)| name.as_str()) + .collect::>() + .join(" / "); + ui.shared.with_me_group.set_title(&title); + + let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); + let up = adw::ActionRow::builder() + .title("Back") + .activatable(true) + .build(); + up.add_prefix(>k4::Image::from_icon_name("go-up-symbolic")); + let ui_up = ui.clone(); + up.connect_activated(move |_| { + ui_up.shared.nav.borrow_mut().pop(); + load_shared(&ui_up); + }); + ui.shared.with_me_group.add(&up); + rows.push((ui.shared.with_me_group.clone(), up.upcast())); + + if entries.is_empty() { + let row = dim_row("This folder is empty."); + ui.shared.with_me_group.add(&row); + rows.push((ui.shared.with_me_group.clone(), row.upcast())); + } else { + for entry in entries { + let row = shared_entry_row(ui, entry); + ui.shared.with_me_group.add(&row); + rows.push((ui.shared.with_me_group.clone(), row.upcast())); + } + } + *ui.shared.rows.borrow_mut() = rows; +} + +/// A row for one node shared with me: folders descend into, files download and +/// open with the user's default application. +fn shared_entry_row(ui: &Rc, entry: &DirEntry) -> adw::ActionRow { + let row = adw::ActionRow::builder() + .title(&entry.name) + .activatable(true) + .build(); + if !entry.is_dir { + row.set_subtitle(&human_bytes(entry.size)); + } + row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir { + "folder-symbolic" + } else { + "text-x-generic-symbolic" + })); + let ui_act = ui.clone(); + let uid = entry.uid.clone(); + let name = entry.name.clone(); + let is_dir = entry.is_dir; + row.connect_activated(move |_| { + if is_dir { + ui_act + .shared + .nav + .borrow_mut() + .push((uid.clone(), name.clone())); + load_shared(&ui_act); + } else { + open_shared_file(&ui_act, &uid, &name); + } + }); + row +} + +/// 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. +fn open_shared_file(ui: &Rc, uid: &str, name: &str) { + // Ignore a repeat activation of a file already downloading, so an impatient + // double-click doesn't kick off a second round-trip. + if !ui.opening.borrow_mut().insert(uid.to_string()) { + return; + } + ui.busy_begin(); + toast(ui, &format!("Downloading “{name}”…")); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenSharedFile { + uid: uid.to_string(), + }, + ); + let ui = ui.clone(); + let uid = uid.to_string(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.opening.borrow_mut().remove(&uid); + match result { + Ok(Ok(Response::FilePath { path })) => open_path(&path), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't open file", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't open file", + "The mount service didn't respond.", + ), + } + }); +} + /// The daemon didn't answer the Shared page. Same still-starting vs. down split /// as the other pages. pub(crate) fn shared_unreachable(ui: &Rc) { @@ -233,6 +403,9 @@ pub(crate) fn repaint_shared( group.remove(&row); } ui.shared.content.set_visible_child_name("list"); + ui.shared.invitations_group.set_visible(true); + ui.shared.bookmarks_group.set_visible(true); + ui.shared.with_me_group.set_title("Shared with me"); let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); // Shared with me: name + Leave. @@ -242,12 +415,7 @@ pub(crate) fn repaint_shared( rows.push((ui.shared.with_me_group.clone(), row.upcast())); } else { for entry in shared { - let row = adw::ActionRow::builder().title(&entry.name).build(); - row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); + let row = shared_entry_row(ui, entry); let leave = gtk4::Button::builder() .label("Leave") .valign(gtk4::Align::Center)