diff --git a/Cargo.lock b/Cargo.lock index ec75f35..401ac36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pdfs-cli" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "clap 4.6.2", @@ -3533,7 +3533,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "0.6.2" +version = "0.7.0" dependencies = [ "directories", "ignore", @@ -3552,7 +3552,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "0.6.2" +version = "0.7.0" dependencies = [ "fuser", "image", @@ -3568,7 +3568,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "0.6.2" +version = "0.7.0" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index e486510..d2d1d1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "0.6.2" +version = "0.7.0" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] diff --git a/README.md b/README.md index ae96378..2d2c214 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,109 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc - **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages. - **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI). - **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token. +- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules. + +## Selective Sync (`.pdfsignore`) + +Two-way synced folders skip paths matched by ignore rules, so syncing a project +directory does not upload `node_modules/`, `target/`, or `.git/`. + +Rules come from two places, and both apply: + +1. **Per folder** — a `.pdfsignore` file at the root of the synced folder + (`.protonignore` also works). Gitignore syntax, including negation: + + ```gitignore + # everything build-related + build/ + *.log + + # ...except this one + !important.log + ``` + +2. **Globally** — an `ignore_patterns` list in `config.json`, applied to every + synced folder. When unset, sensible defaults apply: `.git/`, `.hg/`, `.svn/`, + `node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`, + `.DS_Store`, and `Thumbs.db`. + + ```json + { + "ignore_patterns": ["node_modules/", "target/", "*.iso"] + } + ``` + + Set it to `[]` to opt out of the defaults entirely. + +Rules are re-read at the start of every sync pass, so edits take effect on the +next pass without restarting the daemon. + +**Ignoring is never destructive.** If a rule starts matching a file that was +already synced, its copy on Drive is left untouched — the file simply stops +being tracked. Removing the rule later picks the existing remote file back up +rather than re-uploading it. + +## Diagnostics & Maintenance + +When something looks wrong, `pdfs diagnose` checks the installation and prints a +report. It runs without a daemon on purpose — the state worth diagnosing is +usually the state where the daemon will not start: + +```console +$ pdfs diagnose +Paths +[ok ] state dir: /home/you/.local/state/proton-drive-linux +[ok ] database: /home/you/.local/state/proton-drive-linux/cache.db (170.3 MiB) + +Account +[ok ] keyring session: you@proton.me + +Daemon +[ok ] daemon responding +[ok ] mounted at: /home/you/ProtonDrive +[ok ] queued writes: none + +No problems found. +``` + +It exits non-zero if any check fails, so it works in a health-check script. + +For the local metadata database and content cache: + +| Command | What it does | +|---|---| +| `pdfs cache inspect` | Database size, reclaimable space, per-table row counts, cache usage against budget | +| `pdfs cache inspect --deep` | Also runs SQLite's integrity check — reads every page, so it is slow on a large database | +| `pdfs cache vacuum` | Checkpoints the write-ahead log and compacts the database | +| `pdfs cache clear` | Deletes cached file content, keeping pinned files | + +`vacuum` takes a write lock for its duration and needs room for a second copy of +the database while it runs, so it is a deliberate operation rather than +something the daemon does on a timer. + +## Scripting (`--json`) + +Query commands accept a global `--json` flag and emit machine-readable output: + +```console +$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path' +$ pdfs --json cache inspect | jq '.db_reclaimable_bytes' +$ pdfs --json status | jq -r '.mount.mountpoint' +``` + +Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`, +`activity`, and `cache inspect`. Commands that perform an action keep their +human output — a script that needs to know whether one worked has the exit code. + +Two things worth relying on: + +- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's + internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal + variant. +- **Errors still fail.** A daemon-side error prints its JSON body (with a + machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and + `if pdfs …` behave as expected rather than treating a failed lookup as + success. ## Performance & Caching diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 79f286a..3ba2c41 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; @@ -23,10 +23,74 @@ use pdfs_core::db::Db; about = "Proton Drive for Linux (Files On-Demand)" )] struct Cli { + /// Emit machine-readable JSON instead of formatted text. + /// + /// Applies to the query commands (`status`, `ls`, `pins`, `sync list`, + /// `devices list`, `transfers`, `activity`, `cache inspect`). Commands that + /// perform an action keep their human output — a script that needs to know + /// whether one succeeded has the exit code. + #[arg(long, global = true)] + json: bool, + #[command(subcommand)] command: Command, } +/// Whether `--json` was passed. A global rather than a threaded parameter +/// because it is read at the leaves — one line per query command — and +/// threading a display flag through every command signature would cost more +/// than it explains. +static JSON_OUTPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn json_enabled() -> bool { + JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed) +} + +/// The daemon's reply as JSON, with the response-variant tag stripped. +/// +/// Serializing [`CtlResponse`] directly yields serde's externally tagged form, +/// `{"SyncFolders": {"items": […]}}`, which makes every script write +/// `jq '.SyncFolders.items[]'` and couples it to the name of an internal enum +/// variant. The payload is what the caller asked for, so that is what is +/// emitted: `{"items": […]}`. +/// +/// The reply is still serialized from the daemon's own type rather than +/// rebuilt by hand, so new fields appear in the JSON without anyone +/// remembering to add them here. +fn response_payload(response: &CtlResponse) -> Result { + let value = serde_json::to_value(response)?; + // Every variant serializes as a one-key object keyed by the variant name; + // anything else is left alone rather than guessed at. + if let serde_json::Value::Object(map) = &value + && map.len() == 1 + && let Some((_, payload)) = map.iter().next() + { + return Ok(payload.clone()); + } + Ok(value) +} + +/// Print the daemon's reply as JSON if `--json` is in effect, reporting whether +/// it did so — a `true` means the caller should skip its human formatting. +/// +/// A daemon-side error still prints its payload, so a script can read the +/// `kind`, but then fails the command. Emitting the error and exiting `0` would +/// make every `--json` caller inspect the body to find out whether it worked, +/// when the exit code is the thing they already check. +fn emit_json(response: &CtlResponse) -> Result { + if !json_enabled() { + return Ok(false); + } + println!( + "{}", + serde_json::to_string_pretty(&response_payload(response)?)? + ); + if let CtlResponse::Error { message, kind } = response { + bail!("{}", cli_error(*kind, message)); + } + Ok(true) +} + #[derive(Subcommand)] enum Command { /// Log in to a Proton account (SRP + optional 2FA) and store the session. @@ -212,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. @@ -235,6 +310,36 @@ enum Command { #[arg(short, long, default_value_t = 50)] limit: usize, }, + /// Show account storage usage (used of total, across all Proton products). + Quota, + /// Inspect and maintain the local metadata database and content cache. + Cache { + #[command(subcommand)] + action: CacheCmd, + }, + /// Check this installation for problems and print a report. + /// + /// Runs without a daemon on purpose: the state worth diagnosing is usually + /// the state where the daemon will not start. + Diagnose, +} + +#[derive(Subcommand)] +enum CacheCmd { + /// Report database size, row counts, and cache usage. + Inspect { + /// Also run SQLite's integrity check. Reads every page, so it is slow + /// on a large database — worth it when you suspect corruption. + #[arg(long)] + deep: bool, + }, + /// Checkpoint the write-ahead log and compact the database. + /// + /// Takes a write lock for the duration and needs room for a second copy of + /// the database while it runs. + Vacuum, + /// Delete cached file content, keeping pinned files. + Clear, } /// Which collection a share entry lives in, for `share-role` / `unshare`. @@ -266,6 +371,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)] @@ -298,6 +412,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)] @@ -379,6 +500,7 @@ fn main() -> Result<()> { .init(); let cli = Cli::parse(); + JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed); match cli.command { Command::Login { username } => cmd_login(username), Command::Logout => cmd_logout(), @@ -421,37 +543,59 @@ 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), Command::Activity { limit } => cmd_activity(limit), + Command::Quota => cmd_quota(), + Command::Cache { action } => cmd_cache(action), + Command::Diagnose => cmd_diagnose(), } } fn cmd_devices(action: DeviceCmd) -> Result<()> { match action { - DeviceCmd::List => match control_request(CtlRequest::ListDevices)? { - CtlResponse::Devices { items } if items.is_empty() => println!("No devices."), - CtlResponse::Devices { items } => { - for d in items { - let sync = d - .last_sync - .map(|t| t.to_string()) - .unwrap_or_else(|| "never".to_string()); - println!( - "{} {} (synced: {sync}) [{}]", - d.device_type, d.name, d.uid - ); - } + DeviceCmd::List => { + let response = control_request(CtlRequest::ListDevices)?; + if emit_json(&response)? { + return Ok(()); } - CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), - other => bail!("unexpected response: {other:?}"), - }, + match response { + CtlResponse::Devices { items } if items.is_empty() => println!("No devices."), + CtlResponse::Devices { items } => { + for d in items { + let sync = d + .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}) [{}]{tag}", + d.device_type, d.name, d.uid + ); + } + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + } DeviceCmd::Rename { uid, name } => { 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(()) } @@ -467,47 +611,57 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { .to_string(); ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)? } - SyncCmd::List => match control_request(CtlRequest::ListSyncFolders)? { - CtlResponse::SyncFolders { items } if items.is_empty() => { - println!("No synced folders.") + SyncCmd::List => { + let response = control_request(CtlRequest::ListSyncFolders)?; + if emit_json(&response)? { + return Ok(()); } - 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)) + 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) + ) + } + 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 } => { ok_or_bail(control_request(CtlRequest::RemoveSyncFolder { id, @@ -518,10 +672,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, @@ -648,8 +880,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.") } @@ -665,8 +909,60 @@ 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_quota() -> Result<()> { + let response = control_request(CtlRequest::AccountQuota)?; + if emit_json(&response)? { + return Ok(()); + } + match response { + CtlResponse::AccountQuota { + max_space, + used_space, + } => { + let used = used_space.max(0) as u64; + if max_space > 0 { + let total = max_space as u64; + let pct = (used as f64 / total as f64 * 100.0).round() as u64; + println!( + "{} of {} used ({pct}%)", + human_bytes(used), + human_bytes(total) + ); + } else { + println!("{} used", human_bytes(used)); + } + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + fn cmd_activity(limit: usize) -> Result<()> { - match control_request(CtlRequest::ListActivity { limit })? { + let response = control_request(CtlRequest::ListActivity { limit })?; + if emit_json(&response)? { + return Ok(()); + } + match response { CtlResponse::Activity { items } if items.is_empty() => println!("No recent activity."), CtlResponse::Activity { items } => { for a in items { @@ -842,6 +1138,32 @@ fn cmd_logout() -> Result<()> { } fn cmd_status() -> Result<()> { + // `status` is the one query that merges two sources — the local keyring and + // the daemon — so its JSON is composed here rather than being a daemon reply + // passed through. A script asking for status wants both halves in one + // object, including the "logged in but no daemon" case that has no reply at + // all. + if json_enabled() { + let session = match auth::load() { + Ok(s) => Some(s), + Err(pdfs_core::Error::NotLoggedIn) => None, + Err(e) => return Err(e.into()), + }; + let mount = match control_request(CtlRequest::Status) { + Ok(r @ CtlResponse::Status { .. }) => response_payload(&r)?, + _ => serde_json::Value::Null, + }; + let out = serde_json::json!({ + "logged_in": session.is_some(), + "username": session.as_ref().map(|s| s.username.clone()), + "user_id": session.as_ref().map(|s| s.user_id.clone()), + "scopes": session.as_ref().map(|s| s.scopes.clone()), + "mount": mount, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(()); + } + match auth::load() { Ok(s) => { println!("Logged in as {} (user {})", s.username, s.user_id); @@ -1002,7 +1324,11 @@ fn cmd_unpin(path: PathBuf) -> Result<()> { fn cmd_transfers() -> Result<()> { use pdfs_core::control::TransferDirection; - match control_request(CtlRequest::GetQueueStatus)? { + let response = control_request(CtlRequest::GetQueueStatus)?; + if emit_json(&response)? { + return Ok(()); + } + match response { CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => { println!("Nothing in progress.") } @@ -1049,7 +1375,11 @@ fn cmd_transfers() -> Result<()> { } fn cmd_pins() -> Result<()> { - match control_request(CtlRequest::ListPins)? { + let response = control_request(CtlRequest::ListPins)?; + if emit_json(&response)? { + return Ok(()); + } + match response { CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."), CtlResponse::Pins { pins } => { for p in pins { @@ -1067,7 +1397,11 @@ fn cmd_ls(path: Option) -> Result<()> { Some(p) => path_arg(&p)?, None => String::new(), }; - match control_request(CtlRequest::ListDir { path })? { + let response = control_request(CtlRequest::ListDir { path })?; + if emit_json(&response)? { + return Ok(()); + } + match response { CtlResponse::Entries { entries } if entries.is_empty() => println!("(empty)"), CtlResponse::Entries { entries } => { for e in entries { @@ -1310,6 +1644,228 @@ fn path_arg(path: &Path) -> Result { .ok_or_else(|| anyhow!("path is not valid UTF-8")) } +/// Format a byte count for a report line. +fn human_bytes(bytes: u64) -> String { + const UNITS: [(&str, f64); 4] = [ + ("GiB", 1_073_741_824.0), + ("MiB", 1_048_576.0), + ("KiB", 1024.0), + ("B", 1.0), + ]; + let b = bytes as f64; + for (unit, scale) in UNITS { + if b >= scale { + return format!("{:.1} {unit}", b / scale); + } + } + "0 B".to_string() +} + +fn cmd_cache(action: CacheCmd) -> Result<()> { + match action { + CacheCmd::Inspect { deep } => cmd_cache_inspect(deep), + CacheCmd::Vacuum => match control_request(CtlRequest::CacheVacuum)? { + CtlResponse::Ok { message } => { + println!("{message}"); + Ok(()) + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + }, + CacheCmd::Clear => match control_request(CtlRequest::PurgeCache)? { + CtlResponse::Ok { message } => { + println!("{message}"); + Ok(()) + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + }, + } +} + +fn cmd_cache_inspect(deep: bool) -> Result<()> { + let response = control_request(CtlRequest::CacheInspect { deep })?; + if emit_json(&response)? { + return Ok(()); + } + match response { + CtlResponse::CacheReport { + schema_version, + db_bytes, + db_reclaimable_bytes, + tables, + cache_used, + cache_budget, + integrity_problems, + integrity_checked, + } => { + println!( + "Database schema v{schema_version}, {}", + human_bytes(db_bytes) + ); + if db_reclaimable_bytes > 0 { + println!( + " {} reclaimable — run `pdfs cache vacuum`", + human_bytes(db_reclaimable_bytes) + ); + } + let budget = if cache_budget == 0 { + "unlimited".to_string() + } else { + human_bytes(cache_budget) + }; + println!("Content {} cached of {budget}", human_bytes(cache_used)); + + println!("\nRows"); + for (name, count) in tables { + println!(" {name:<16} {count:>10}"); + } + + if integrity_checked { + if integrity_problems.is_empty() { + println!("\nIntegrity ok"); + } else { + println!("\nIntegrity {} problem(s):", integrity_problems.len()); + for problem in integrity_problems { + println!(" {problem}"); + } + } + } else { + println!("\nIntegrity not checked (pass --deep)"); + } + Ok(()) + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } +} + +/// One line of the `diagnose` report. +fn check(label: &str, ok: bool, detail: impl AsRef) -> bool { + let mark = if ok { "ok " } else { "FAIL" }; + let detail = detail.as_ref(); + if detail.is_empty() { + println!("[{mark}] {label}"); + } else { + println!("[{mark}] {label}: {detail}"); + } + ok +} + +/// Self-check the installation, reporting each finding and exiting non-zero if +/// any check failed. +/// +/// Every check is written to work with no daemon running, because that is the +/// situation a user runs this in. A missing daemon is reported as a finding, +/// not an error that aborts the rest of the report. +fn cmd_diagnose() -> Result<()> { + let mut all_ok = true; + + let dirs = AppDirs::new().context("resolve application directories")?; + let config = dirs.load_config(); + + println!("Paths"); + let state = dirs.state_dir(); + all_ok &= check( + " state dir", + state.is_dir(), + format!("{}", state.display()), + ); + let cache_dir = dirs.cache_dir(); + all_ok &= check( + " cache dir", + cache_dir.is_dir(), + format!("{}", cache_dir.display()), + ); + // Writability is checked by actually writing: permission bits do not + // account for a full disk, a read-only remount, or an immutable flag. + let probe = state.join(".pdfs-diagnose-probe"); + let writable = std::fs::write(&probe, b"probe").is_ok(); + let _ = std::fs::remove_file(&probe); + all_ok &= check(" state dir writable", writable, ""); + + let db_path = dirs.db_path(); + let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + all_ok &= check( + " database", + db_path.is_file(), + format!("{} ({})", db_path.display(), human_bytes(db_size)), + ); + + println!("\nAccount"); + match pdfs_core::auth::load() { + Ok(session) => { + all_ok &= check(" keyring session", true, session.username); + } + Err(e) => { + // A locked keyring and an absent login look the same from here, so + // the detail carries the distinction rather than the verdict. + all_ok &= check(" keyring session", false, format!("{e}")); + } + } + + println!("\nDaemon"); + let socket = dirs.control_socket(); + let socket_exists = socket.exists(); + check( + " control socket", + socket_exists, + format!("{}", socket.display()), + ); + // A stale socket file with no listener is the common failure, so the reply + // is what decides this rather than the file's existence. + match control_request(CtlRequest::Status) { + Ok(CtlResponse::Status { + mountpoint, + online, + pending_uploads, + pending_changes, + .. + }) => { + check(" daemon responding", true, ""); + check(" mounted at", true, mountpoint); + check(" network", online, if online { "" } else { "offline" }); + let pending = pending_uploads + pending_changes; + check( + " queued writes", + true, + if pending == 0 { + "none".to_string() + } else { + format!("{pending} waiting to upload") + }, + ); + } + Ok(other) => { + all_ok &= check( + " daemon responding", + false, + format!("unexpected: {other:?}"), + ); + } + Err(e) => { + // Not a failure of the installation: a stopped daemon is a normal + // state, and the rest of the report is still worth printing. + check(" daemon responding", false, format!("{e:#}")); + println!(" (start it with `systemctl --user start pdfs.service`)"); + } + } + + let mountpoint = dirs.resolved_mountpoint(&config); + check( + " mountpoint exists", + mountpoint.is_dir(), + format!("{}", mountpoint.display()), + ); + + if all_ok { + println!("\nNo problems found."); + Ok(()) + } else { + bail!("one or more checks failed; see the report above") + } +} + /// Send one request to the running mount daemon's control socket and read its /// reply. Errors if no daemon is listening. fn control_request(req: CtlRequest) -> Result { diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index bafe216..6b5a0a2 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -6,6 +6,7 @@ use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use crate::error::{Error, Result}; +use crate::syncignore::DEFAULT_IGNORE_PATTERNS; /// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈ /// stable/beta/alpha); a malformed value trips the 422 anti-abuse path. @@ -34,6 +35,19 @@ pub struct AppConfig { /// path here. Defaulted for configs predating the field. #[serde(default)] pub mountpoint: Option, + /// Ignore patterns (gitignore syntax) applied to every mirror sync folder, + /// on top of each folder's own `.pdfsignore`. `None` means "use + /// [`DEFAULT_IGNORE_PATTERNS`]"; an explicit empty list disables the + /// 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 { @@ -43,6 +57,8 @@ impl Default for AppConfig { user_agent: USER_AGENT.to_string(), cache_budget: None, mountpoint: None, + ignore_patterns: None, + device_uid: None, } } } @@ -53,6 +69,20 @@ impl AppConfig { pub fn resolved_cache_budget(&self) -> u64 { self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES) } + + /// The effective global sync ignore patterns: the user's explicit list, or + /// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is + /// honoured as "no global patterns", which is why this is not a + /// `unwrap_or_default`. + pub fn resolved_ignore_patterns(&self) -> Vec { + match &self.ignore_patterns { + Some(patterns) => patterns.clone(), + None => DEFAULT_IGNORE_PATTERNS + .iter() + .map(|s| (*s).to_string()) + .collect(), + } + } } /// Resolved XDG locations for state, cache, and the default mountpoint. @@ -271,6 +301,8 @@ mod tests { user_agent: "test-agent/1.0".to_string(), 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(); @@ -284,4 +316,30 @@ mod tests { assert_eq!(default_config.app_version, APP_VERSION); assert_eq!(default_config.user_agent, USER_AGENT); } + + #[test] + fn a_config_predating_ignore_patterns_still_loads() { + // Every installed config on disk looks like this; the field must not be + // required or the daemon would fall back to a default config on upgrade. + let json = r#"{"app_version":"v","user_agent":"u"}"#; + let decoded: AppConfig = serde_json::from_str(json).unwrap(); + assert!(decoded.ignore_patterns.is_none()); + assert_eq!( + decoded.resolved_ignore_patterns().len(), + DEFAULT_IGNORE_PATTERNS.len(), + "an absent list means the defaults apply" + ); + } + + #[test] + fn an_explicit_empty_ignore_list_disables_the_defaults() { + let config = AppConfig { + ignore_patterns: Some(Vec::new()), + ..AppConfig::default() + }; + assert!( + config.resolved_ignore_patterns().is_empty(), + "opting out must be expressible, so this cannot be unwrap_or_default" + ); + } } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 5d1b616..ed750f0 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -136,6 +136,22 @@ pub enum Request { /// persist it to config so the next mount keeps it. Replies with /// [`Response::Ok`]. SetCacheBudget { bytes: u64 }, + /// Report on the health of the metadata database and content cache: sizes, + /// row counts, and — when `deep` — SQLite's own integrity check. Replies + /// with [`Response::CacheReport`]. + /// + /// `deep` reads every page of the database, so it is opt-in: worth it when + /// diagnosing a suspected corruption, wasteful as a routine status call. + CacheInspect { + #[serde(default)] + deep: bool, + }, + /// Checkpoint the write-ahead log and compact the database, replying with + /// [`Response::Ok`] reporting the bytes reclaimed. + /// + /// Takes a write lock for the duration and needs room for a second copy of + /// the database, which is why it is user-invoked rather than periodic. + CacheVacuum, /// Snapshot what the daemon is working on: in-flight transfers (active /// uploads/downloads) and the longer jobs around them (scans, folder /// skeletons, the local index, sync passes). Replies with @@ -172,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 @@ -188,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 @@ -243,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 }, @@ -269,6 +307,11 @@ pub enum Request { /// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`]. DeleteBookmark { token: String }, + // ---- account ---------------------------------------------------------- + /// Total account storage usage (used/total, all Proton products, not just + /// Drive). Replies with [`Response::AccountQuota`]. + AccountQuota, + // ---- activity --------------------------------------------------------- /// Fetch the daemon's recent activity log, newest first, capped at `limit` /// entries. Replies with [`Response::Activity`]. The log is persisted, so it @@ -324,6 +367,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`]). @@ -353,6 +401,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 { @@ -817,6 +895,28 @@ pub enum Response { }, /// A human-readable success message. Ok { message: String }, + /// Health report for the metadata database and content cache (reply to + /// [`Request::CacheInspect`]). + CacheReport { + /// Schema version the database file is at. + schema_version: i64, + /// Bytes the database file accounts for. + db_bytes: u64, + /// Bytes a vacuum could hand back. + db_reclaimable_bytes: u64, + /// Row counts per table, in display order. + tables: Vec<(String, i64)>, + /// Bytes of cached content blobs, blocks, and thumbnails. + cache_used: u64, + /// Configured soft byte cap (`0` = unlimited). + cache_budget: u64, + /// Problems SQLite reported. Empty means sound; absent when the caller + /// did not ask for a deep check. + integrity_problems: Vec, + /// Whether the integrity check actually ran, so an empty problem list + /// is not mistaken for a clean bill of health it never gave. + integrity_checked: bool, + }, /// The pin registry. Pins { pins: Vec }, /// A directory listing (reply to [`Request::ListDir`]) or a trash listing @@ -860,6 +960,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 { @@ -873,6 +975,11 @@ pub enum Response { Invitations { items: Vec }, /// Saved public links (reply to [`Request::ListBookmarks`]). Bookmarks { items: Vec }, + /// Account storage usage in bytes (reply to [`Request::AccountQuota`]). + /// `max_space` is the total; `used_space` what is consumed. `max_space == 0` + /// means the total is unknown, which a front-end shows as a plain used figure + /// rather than a full bar. + AccountQuota { max_space: i64, used_space: i64 }, /// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]). SharedByMe { items: Vec }, /// The daemon's recent activity, newest first (reply to @@ -1123,6 +1230,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()], @@ -1154,6 +1273,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(), }, @@ -1168,6 +1293,7 @@ mod tests { Request::DeleteBookmark { token: "tok".into(), }, + Request::AccountQuota, ]; for req in reqs { let line = serde_json::to_string(&req).unwrap(); diff --git a/crates/pdfs-core/src/db/maintenance.rs b/crates/pdfs-core/src/db/maintenance.rs new file mode 100644 index 0000000..4bd7499 --- /dev/null +++ b/crates/pdfs-core/src/db/maintenance.rs @@ -0,0 +1,335 @@ +//! Database health reporting and repair (features.md 4.3). +//! +//! Everything here exists for the case where something has already gone wrong: +//! a crash mid-write, a full disk, a cache that has grown out of proportion to +//! what it holds. The daemon never calls these on its own — they are what +//! `pdfs cache inspect`, `pdfs cache vacuum`, and `pdfs diagnose` are built on, +//! so a user with a suspect install can get an answer without being told to +//! delete their state directory and re-sync from scratch. + +use rusqlite::OptionalExtension; + +use super::Db; +use crate::Result; + +/// A point-in-time picture of the database's size and contents. +/// +/// Sizes are computed from SQLite's own page accounting rather than by stat'ing +/// the file, so they stay right when the file is sparse or a checkpoint is +/// pending. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbStats { + /// Schema version the file is currently at. + pub schema_version: i64, + /// Total pages allocated to the database. + pub page_count: i64, + /// Pages allocated but unused — what a `VACUUM` would reclaim. + pub free_pages: i64, + /// Bytes per page. + pub page_size: i64, + /// Row counts for the tables worth reporting, in display order. + pub tables: Vec<(String, i64)>, +} + +impl DbStats { + /// Bytes the database file accounts for. + pub fn total_bytes(&self) -> u64 { + (self.page_count * self.page_size).max(0) as u64 + } + + /// Bytes a `VACUUM` could hand back to the filesystem. + pub fn reclaimable_bytes(&self) -> u64 { + (self.free_pages * self.page_size).max(0) as u64 + } +} + +/// What a maintenance run reclaimed. +/// +/// Deliberately does not carry an orphaned-row count: the content cache already +/// rebuilds its index from what is actually on disk every time it opens (see +/// [`crate::cache::ContentCache`]'s `reconcile`), so a second pruning pass here +/// would be a duplicate of that logic with a worse view of the truth. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct VacuumOutcome { + /// Database bytes before the run. + pub before_bytes: u64, + /// Database bytes after it. + pub after_bytes: u64, + /// WAL frames written back into the database by the checkpoint. + pub wal_frames_checkpointed: i64, +} + +impl VacuumOutcome { + /// Bytes handed back to the filesystem. Saturating: a database can legitimately + /// grow across a vacuum (a checkpoint folds WAL frames in), and reporting that + /// as a huge positive number via wraparound would be worse than reporting zero. + pub fn freed_bytes(&self) -> u64 { + self.before_bytes.saturating_sub(self.after_bytes) + } +} + +/// The tables `inspect` reports on, in the order a reader wants them. +const REPORTED_TABLES: &[&str] = &[ + "nodes", + "cache_entries", + "pins", + "photos", + "trash", + "device", + "sync_folder", + "sync_entry", + "pending_op", + "activity", + "local_files", +]; + +impl Db { + /// SQLite's own consistency check. + /// + /// Returns an empty vector when the database is sound; otherwise each entry + /// is one problem SQLite found. Read-only and safe against a live daemon, + /// though it reads every page, so it is not free on a large file. + pub fn integrity_check(&self) -> Result> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("PRAGMA integrity_check")?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + let mut problems = Vec::new(); + for row in rows { + let row = row?; + // A sound database reports the single line "ok"; anything else is a + // finding. + if row != "ok" { + problems.push(row); + } + } + Ok(problems) + } + + /// Size and row-count snapshot for `pdfs cache inspect`. + pub fn stats(&self) -> Result { + let conn = self.conn.lock(); + let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?; + let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?; + let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?; + // The schema version lives in the `sync_state` key/value table, not in + // `PRAGMA user_version` — see `migrations`. + let schema_version: i64 = conn + .query_row( + "SELECT value FROM sync_state WHERE key = 'schema_version'", + [], + |r| r.get::<_, String>(0), + ) + .optional()? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let mut tables = Vec::new(); + for name in REPORTED_TABLES { + // A table listed here may not exist yet on an older schema, and a + // report is not worth failing over a missing one. + let exists: Option = conn + .query_row( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?1", + [name], + |r| r.get(0), + ) + .optional()?; + if exists.is_none() { + continue; + } + let count: i64 = + conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| { + r.get(0) + })?; + tables.push(((*name).to_string(), count)); + } + + Ok(DbStats { + schema_version, + page_count, + free_pages, + page_size, + tables, + }) + } + + /// Fold the WAL back into the database and compact the file. + /// + /// `VACUUM` rewrites the whole database into a fresh file, so it needs room + /// for a second copy and takes a write lock for the duration. That makes it + /// a deliberate, user-invoked operation rather than something the daemon + /// does on a timer. + /// + /// The checkpoint runs *after* the vacuum. Checkpointing only beforehand + /// folds a log that the vacuum is about to rewrite anyway, and leaves the + /// rewrite itself sitting in the WAL: measured on a live install, a 170 MiB + /// database vacuumed to 136 MiB while its WAL grew 67 MiB → 143 MiB, + /// erasing the reclaim in net disk terms and reporting `0 frames` + /// checkpointed because it ran when there was nothing to fold. + /// + /// Ordering it afterwards is strictly better but is *not* a proven fix for + /// that inflation. The live case inflates because the daemon's other + /// connections hold read transactions, and a `TRUNCATE` checkpoint waits on + /// readers — so under a busy daemon this can still return having moved + /// little. `journal_size_limit` remains the backstop that caps the file. + /// Confirming the fix needs a measurement against a live daemon, not a + /// single-connection test, which folds the WAL on its own either way. + pub fn vacuum(&self) -> Result { + let before = self.stats()?.total_bytes(); + + { + let conn = self.conn.lock(); + conn.execute_batch("VACUUM")?; + } + + let wal_frames_checkpointed = self.checkpoint_wal()?; + + let after = self.stats()?.total_bytes(); + Ok(VacuumOutcome { + before_bytes: before, + after_bytes: after, + wal_frames_checkpointed, + }) + } + + /// Checkpoint the WAL and truncate it, returning the number of frames + /// written back into the database. + /// + /// `TRUNCATE` blocks on readers rather than giving up part-way, which is + /// what makes the result meaningful: a `PASSIVE` checkpoint against a busy + /// daemon can legitimately move nothing and report success. + pub fn checkpoint_wal(&self) -> Result { + let conn = self.conn.lock(); + // Columns are (busy, log frames, frames checkpointed). + let frames: i64 = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(2))?; + Ok(frames.max(0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_database_passes_its_integrity_check() { + let db = Db::open_in_memory().unwrap(); + assert!(db.integrity_check().unwrap().is_empty()); + } + + #[test] + fn stats_report_the_schema_and_the_known_tables() { + let db = Db::open_in_memory().unwrap(); + let stats = db.stats().unwrap(); + + assert!(stats.schema_version > 0, "migrations set a user_version"); + assert!(stats.page_size > 0); + assert!( + stats.tables.iter().any(|(name, _)| name == "nodes"), + "the nodes table should be reported; got {:?}", + stats.tables + ); + // A fresh database has no rows in any of them. + assert!(stats.tables.iter().all(|(_, count)| *count == 0)); + } + + #[test] + fn total_and_reclaimable_bytes_derive_from_the_page_counts() { + let stats = DbStats { + schema_version: 1, + page_count: 10, + free_pages: 4, + page_size: 4096, + tables: Vec::new(), + }; + assert_eq!(stats.total_bytes(), 40960); + assert_eq!(stats.reclaimable_bytes(), 16384); + } + + /// A vacuum that leaves the file larger (a checkpoint folded WAL frames in) + /// must report nothing freed, not an underflowed enormous number. + #[test] + fn freed_bytes_does_not_underflow_when_the_file_grew() { + let outcome = VacuumOutcome { + before_bytes: 100, + after_bytes: 400, + ..VacuumOutcome::default() + }; + assert_eq!(outcome.freed_bytes(), 0); + } + + #[test] + fn freed_bytes_reports_the_difference_when_the_file_shrank() { + let outcome = VacuumOutcome { + before_bytes: 4096, + after_bytes: 1024, + ..VacuumOutcome::default() + }; + assert_eq!(outcome.freed_bytes(), 3072); + } + + #[test] + fn vacuuming_a_fresh_database_leaves_it_sound() { + let db = Db::open_in_memory().unwrap(); + db.vacuum().unwrap(); + assert!(db.integrity_check().unwrap().is_empty()); + } + + /// A unique temp directory removed on drop; avoids a dev-dependency, as in + /// [`crate::cache`]'s tests. Needed here because an in-memory database has + /// no WAL file to measure. + struct TempDir(std::path::PathBuf); + impl TempDir { + fn new() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let p = std::env::temp_dir().join(format!( + "pdfs-maint-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&p).unwrap(); + TempDir(p) + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A file-backed vacuum reclaims space and leaves the file sound. + /// + /// Note what this does *not* pin: the WAL-inflation behaviour that motivated + /// the checkpoint ordering in [`Db::vacuum`]. Measured here, both orderings + /// report `frames=0` and leave a 0-byte WAL, because a single-connection + /// test lets SQLite fold the log itself. The live case inflated only because + /// the daemon holds concurrent readers that block a checkpoint, and + /// reproducing that needs a second connection parked in a read transaction. + /// An assertion on frame count or WAL size here would pass under both + /// orderings and so would be decoration — see the note in `vacuum`. + #[test] + fn a_file_backed_vacuum_reclaims_space_and_stays_sound() { + let dir = TempDir::new(); + let path = dir.0.join("cache.db"); + let db = Db::open(&path).unwrap(); + + for i in 0..2000 { + db.set_state_str(&format!("k{i}"), &"v".repeat(256)) + .unwrap(); + } + let full = db.stats().unwrap().total_bytes(); + for i in 0..2000 { + db.set_state_str(&format!("k{i}"), "").unwrap(); + } + + let outcome = db.vacuum().unwrap(); + + assert!( + outcome.after_bytes < full, + "vacuum did not reclaim: {} → {} (peak {full})", + outcome.before_bytes, + outcome.after_bytes + ); + assert!(db.integrity_check().unwrap().is_empty()); + } +} diff --git a/crates/pdfs-core/src/db/mod.rs b/crates/pdfs-core/src/db/mod.rs index 63e6d9f..db59889 100644 --- a/crates/pdfs-core/src/db/mod.rs +++ b/crates/pdfs-core/src/db/mod.rs @@ -25,6 +25,7 @@ mod activity; mod cache; mod devices; mod local; +mod maintenance; mod migrations; mod nodes; mod ops; @@ -38,6 +39,7 @@ mod utils; pub use cache::CacheEntryInput; pub use devices::StoredDevice; pub use local::LocalFileHit; +pub use maintenance::{DbStats, VacuumOutcome}; pub use nodes::{SearchHit, StoredNode}; pub use ops::{ AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 27dc6f0..5ee34a5 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -9,8 +9,10 @@ pub mod control; pub mod db; pub mod error; pub mod localindex; +pub mod profile; pub mod service; pub mod shell; +pub mod syncignore; pub use error::{CoreError, CoreResult, Error, Result}; 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-core/src/syncignore.rs b/crates/pdfs-core/src/syncignore.rs new file mode 100644 index 0000000..d2bc461 --- /dev/null +++ b/crates/pdfs-core/src/syncignore.rs @@ -0,0 +1,275 @@ +//! Selective-sync ignore rules for mirror folders (features.md 1.1). +//! +//! A mirror folder otherwise uploads every path beneath its root, which for a +//! project directory means `node_modules/`, `.git/`, and `target/` — quota +//! spent on files nobody wants on Drive, and enough API traffic to invite rate +//! limiting. +//! +//! Rules come from two places, unioned: +//! +//! * `.pdfsignore` (or `.protonignore`) at the root of the synced folder, +//! gitignore syntax. +//! * A global pattern list from the config, applied to every synced folder. +//! +//! Both are read fresh at the start of each reconcile pass, so editing the file +//! takes effect on the next pass without restarting the daemon. +//! +//! # Ignoring is not deleting +//! +//! A path that becomes ignored after it was already synced keeps its remote +//! copy and its baseline row. The sync engine drops ignored paths from +//! classification entirely rather than treating them as locally deleted — +//! see the union filter in `pdfs-fuse`'s `do_reconcile`. Editing a text file +//! must never trash data on Drive. + +use std::path::Path; + +use ignore::gitignore::{Gitignore, GitignoreBuilder}; + +/// Patterns applied to every synced folder when the config does not override +/// them. Version-control metadata, dependency and build trees, editor +/// leftovers, and OS turds — none of which belong on Drive, all of which are +/// large or rewritten constantly. +pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[ + ".git/", + ".hg/", + ".svn/", + "node_modules/", + "target/", + ".venv/", + "__pycache__/", + "*~", + "*.swp", + "*.tmp", + ".DS_Store", + "Thumbs.db", +]; + +/// Filenames accepted as a folder's ignore file, in precedence order. +const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"]; + +/// Compiled ignore rules for one synced folder. +pub struct IgnoreRules { + matcher: Option, +} + +impl IgnoreRules { + /// Build the rules for a folder rooted at `root`: `globals` first, then any + /// ignore file found at the root, so a folder-local rule can negate a + /// global one with `!pattern`. + /// + /// Malformed patterns are skipped individually rather than failing the + /// load — a typo in `.pdfsignore` must not take the folder's sync down. + pub fn load(root: &Path, globals: &[String]) -> Self { + let mut builder = GitignoreBuilder::new(root); + let mut any = false; + + for pattern in globals { + if builder.add_line(None, pattern).is_ok() { + any = true; + } + } + + for name in IGNORE_FILE_NAMES { + let path = root.join(name); + if !path.is_file() { + continue; + } + // `add` returns the first parse error but still takes the lines it + // could read, so a bad line costs that line and nothing more. + let _ = builder.add(&path); + any = true; + break; + } + + if !any { + return Self { matcher: None }; + } + Self { + matcher: builder.build().ok(), + } + } + + /// Rules matching nothing, for callers with no folder context. + pub fn empty() -> Self { + Self { matcher: None } + } + + /// Whether any rule is loaded. When false, [`is_ignored`](Self::is_ignored) + /// is always false and callers can skip filtering entirely. + pub fn is_empty(&self) -> bool { + self.matcher.is_none() + } + + /// Whether `rel` — a `/`-joined path relative to the folder root — is + /// ignored. + /// + /// A path under an ignored directory is ignored too. `Gitignore` matches + /// one path against one rule set and does not walk parents itself, so each + /// ancestor is tested as a directory. This is what makes filtering the + /// classification union sound: the engine can ask about any path in + /// isolation, in any order, without having walked its parents first. + pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool { + let Some(matcher) = &self.matcher else { + return false; + }; + if rel.is_empty() { + return false; + } + + let mut prefix = String::new(); + let mut parts = rel.split('/').peekable(); + while let Some(part) = parts.next() { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(part); + let last = parts.peek().is_none(); + // Every ancestor is a directory; only the final component's kind + // is the caller's to say. + let as_dir = if last { is_dir } else { true }; + if matcher.matched(&prefix, as_dir).is_ignore() { + return true; + } + } + false + } +} + +impl std::fmt::Debug for IgnoreRules { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IgnoreRules") + .field("active", &!self.is_empty()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A unique temp directory removed on drop; avoids a dev-dependency, as in + /// [`crate::cache`]'s tests. + struct TempDir(std::path::PathBuf); + impl TempDir { + fn new() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let p = std::env::temp_dir().join(format!( + "pdfs-ignore-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&p).unwrap(); + TempDir(p) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn globals() -> Vec { + DEFAULT_IGNORE_PATTERNS + .iter() + .map(|s| (*s).to_string()) + .collect() + } + + fn write(root: &Path, name: &str, body: &str) { + std::fs::write(root.join(name), body).unwrap(); + } + + #[test] + fn no_rules_at_all_ignores_nothing() { + let dir = TempDir::new(); + let rules = IgnoreRules::load(dir.path(), &[]); + assert!(rules.is_empty()); + assert!(!rules.is_ignored("anything", false)); + assert!(!rules.is_ignored("deep/nested/path.rs", false)); + } + + #[test] + fn global_defaults_match_without_an_ignore_file() { + let dir = TempDir::new(); + let rules = IgnoreRules::load(dir.path(), &globals()); + assert!(rules.is_ignored("node_modules", true)); + assert!(rules.is_ignored("target", true)); + assert!(rules.is_ignored("notes.txt~", false)); + assert!(!rules.is_ignored("src/main.rs", false)); + } + + #[test] + fn a_path_under_an_ignored_directory_is_ignored() { + let dir = TempDir::new(); + let rules = IgnoreRules::load(dir.path(), &globals()); + // The engine asks about deep paths in isolation, so each must answer + // correctly without its parents having been tested first. + assert!(rules.is_ignored("node_modules/left-pad/index.js", false)); + assert!(rules.is_ignored("node_modules/left-pad", true)); + assert!(rules.is_ignored("target/debug/build/x/out", false)); + } + + #[test] + fn a_folder_ignore_file_adds_to_the_globals() { + let dir = TempDir::new(); + write(dir.path(), ".pdfsignore", "secrets/\n*.log\n"); + let rules = IgnoreRules::load(dir.path(), &globals()); + assert!(rules.is_ignored("secrets/key.pem", false)); + assert!(rules.is_ignored("run.log", false)); + // Globals still apply alongside the file. + assert!(rules.is_ignored("node_modules", true)); + assert!(!rules.is_ignored("run.txt", false)); + } + + #[test] + fn a_folder_rule_can_negate_a_global_one() { + let dir = TempDir::new(); + write(dir.path(), ".pdfsignore", "!keep.tmp\n"); + let rules = IgnoreRules::load(dir.path(), &globals()); + assert!(rules.is_ignored("scratch.tmp", false)); + assert!(!rules.is_ignored("keep.tmp", false)); + } + + #[test] + fn protonignore_is_accepted_as_an_alias() { + let dir = TempDir::new(); + write(dir.path(), ".protonignore", "build/\n"); + let rules = IgnoreRules::load(dir.path(), &[]); + assert!(rules.is_ignored("build/out.o", false)); + } + + #[test] + fn pdfsignore_wins_when_both_files_exist() { + let dir = TempDir::new(); + write(dir.path(), ".pdfsignore", "from-pdfs/\n"); + write(dir.path(), ".protonignore", "from-proton/\n"); + let rules = IgnoreRules::load(dir.path(), &[]); + assert!(rules.is_ignored("from-pdfs/x", false)); + assert!(!rules.is_ignored("from-proton/x", false)); + } + + #[test] + fn a_malformed_pattern_does_not_take_the_rest_down() { + let dir = TempDir::new(); + // An unclosed character class is not a valid glob. + write(dir.path(), ".pdfsignore", "[unclosed\nvalid/\n"); + let rules = IgnoreRules::load(dir.path(), &globals()); + assert!(rules.is_ignored("valid/x", false)); + assert!(rules.is_ignored("node_modules", true)); + } + + #[test] + fn the_root_itself_is_never_ignored() { + let dir = TempDir::new(); + write(dir.path(), ".pdfsignore", "*\n"); + let rules = IgnoreRules::load(dir.path(), &[]); + // A blanket rule must not classify the folder root, or the pass would + // have nothing to reconcile against. + assert!(!rules.is_ignored("", true)); + } +} diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index df1c809..e923db5 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), @@ -404,6 +410,45 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U ), } } + Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() { + Ok(stats) => { + // The deep check reads every page, so it runs only on request; + // `integrity_checked` tells the caller which answer it is + // holding, since "no problems found" and "never looked" are + // both an empty list. + let (integrity_problems, integrity_checked) = if deep { + match core.db.integrity_check() { + Ok(problems) => (problems, true), + Err(e) => (vec![format!("integrity check failed: {e:?}")], true), + } + } else { + (Vec::new(), false) + }; + CtlResponse::CacheReport { + schema_version: stats.schema_version, + db_bytes: stats.total_bytes(), + db_reclaimable_bytes: stats.reclaimable_bytes(), + tables: stats.tables, + cache_used: core.cache.usage(), + cache_budget: core.cache.budget(), + integrity_problems, + integrity_checked, + } + } + Err(e) => CtlResponse::error(CoreError::internal(format!("read db stats: {e:?}"))), + }, + Ok(CtlRequest::CacheVacuum) => match core.db.vacuum() { + Ok(outcome) => CtlResponse::Ok { + message: format!( + "vacuumed: {:.1} MiB freed ({:.1} MiB → {:.1} MiB), {} WAL frame(s) checkpointed", + outcome.freed_bytes() as f64 / 1_048_576.0, + outcome.before_bytes as f64 / 1_048_576.0, + outcome.after_bytes as f64 / 1_048_576.0, + outcome.wal_frames_checkpointed, + ), + }, + Err(e) => CtlResponse::error(CoreError::internal(format!("vacuum: {e:?}"))), + }, Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers { items: core.transfers.snapshot(), jobs: core.jobs_snapshot(), @@ -417,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}" ))), @@ -444,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 @@ -455,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()) @@ -483,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 } } @@ -507,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, @@ -601,6 +680,23 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(entries) => CtlResponse::Entries { entries }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::AccountQuota) => match core.account_quota() { + Ok((max_space, used_space)) => CtlResponse::AccountQuota { + max_space, + used_space, + }, + 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..8d9f36f 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. @@ -333,6 +392,19 @@ impl Core { .map_err(|e| CoreError::from_api(&e, "delete bookmark")) } + // ---- account ---------------------------------------------------------- + + /// Total account storage usage `(max_space, used_space)` in bytes, across all + /// Proton products (not Drive-only). A remote round-trip; nothing here is + /// cached. + pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> { + let q = self + .rt + .block_on(self.client.quota()) + .map_err(|e| CoreError::from_api(&e, "account quota"))?; + Ok((q.max_space, q.used_space)) + } + // ---- shared by me ----------------------------------------------------- /// List the nodes I have shared with others, each with a summary of its share diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 6ee20ce..4c82f48 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -29,6 +29,7 @@ use crate::transfers::{CountingWriter, OwnedCountingReader}; use crate::{Core, now_secs, parse_uid}; use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder}; +use pdfs_core::syncignore::IgnoreRules; /// How long the watcher must go quiet before a burst counts as finished. /// @@ -448,6 +449,94 @@ impl From for PassAbort { /// /// One surviving path is enough to clear the guard, since the classification is /// only ever wrong about all of them at once. +/// The paths a reconcile pass will classify: the union of the local, remote, +/// and baseline states, minus anything the ignore rules exclude, shallowest +/// first so a parent folder is created before its children are placed in it. +/// +/// The ignore filter belongs *here*, on the union, rather than on the local walk +/// alone — filtering the walk alone would be actively destructive. A file synced +/// before it became ignored is absent from `local` but present in `remote` and +/// `baseline`, which is precisely the "local deleted, remote untouched" shape +/// `do_reconcile` responds to by trashing the remote copy. Adding a line to +/// `.pdfsignore` must never delete anything from Drive. +/// +/// Ignored baseline rows are deliberately left in the database rather than +/// removed: with the row intact, un-ignoring the path later re-adopts the +/// existing remote file instead of reading as a brand-new local one. +fn classification_order( + local: &HashMap, + remote: &HashMap, + baseline: &HashMap, + rules: &IgnoreRules, +) -> Vec +where + L: HasKind, + R: HasKind, +{ + let mut paths: HashSet = HashSet::new(); + paths.extend(local.keys().cloned()); + paths.extend(remote.keys().cloned()); + paths.extend(baseline.keys().cloned()); + let mut order: Vec = paths.into_iter().collect(); + if !rules.is_empty() { + order.retain(|rel| { + // A baseline-only path has no kind recorded, so it is tested as + // both; see `filter_baseline` for why erring towards "ignored" is + // the safe direction here. + let kind = local + .get(rel) + .map(HasKind::is_dir) + .or_else(|| remote.get(rel).map(HasKind::is_dir)); + match kind { + Some(is_dir) => !rules.is_ignored(rel, is_dir), + None => !rules.is_ignored(rel, false) && !rules.is_ignored(rel, true), + } + }); + } + order.sort_by_key(|p| p.matches('/').count()); + order +} + +/// Lets [`classification_order`] ask either walk's item whether it is a +/// directory without knowing which walk it came from. +trait HasKind { + fn is_dir(&self) -> bool; +} + +impl HasKind for LocalItem { + fn is_dir(&self) -> bool { + self.is_dir + } +} + +impl HasKind for RemoteItem { + fn is_dir(&self) -> bool { + self.is_dir + } +} + +/// The baseline minus its ignored paths, for [`guard_local_wipe`]. +/// +/// The guard asks "did every synced path vanish locally?", and an ignored path +/// is absent from the local walk by rule rather than by loss. Left in, a rule +/// covering the whole tree would trip the guard on every pass and wedge that +/// folder's sync for good. +/// +/// A baseline row does not record whether it was a file or a folder, so a path +/// counts as ignored if it matches as either. Erring towards "ignored" only ever +/// shrinks the set the guard checks, which weakens a safety net rather than +/// causing a deletion — the wrong direction to be wrong in is the other one. +fn filter_baseline<'a, B>( + baseline: &'a HashMap, + rules: &IgnoreRules, +) -> HashMap { + baseline + .iter() + .filter(|(rel, _)| !rules.is_ignored(rel, false) && !rules.is_ignored(rel, true)) + .map(|(rel, entry)| (rel.clone(), entry)) + .collect() +} + fn guard_local_wipe( baseline: &HashMap, local: &HashMap, @@ -529,11 +618,16 @@ impl Core { .db .sync_folder_set_state(folder.id, "syncing", folder.last_sync); let name = base_name(&folder.local_path); + // Rebuilt per pass rather than cached on `Core`, so editing `.pdfsignore` + // takes effect on the next reconcile instead of at the next daemon start. + // The cost is one small file read against a pass that is about to walk the + // whole tree. + let rules = self.ignore_rules(&local_root); self.progress_begin(folder.id); let result = if push_only { - self.push_pass(folder.id, &local_root, &remote_root) + self.push_pass(folder.id, &local_root, &remote_root, &rules) } else { - match self.do_reconcile(folder.id, &local_root, &remote_root) { + match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) { Ok(outcome) => Ok(outcome), Err(PassAbort::Failed(e)) => Err(e), // The user asked for on-demand while this pass was running. Rather @@ -548,7 +642,7 @@ impl Core { ); push_only = true; self.progress_begin(folder.id); - self.push_pass(folder.id, &local_root, &remote_root) + self.push_pass(folder.id, &local_root, &remote_root, &rules) } } }; @@ -619,6 +713,27 @@ impl Core { } } + /// Compile the ignore rules for a synced folder: the global patterns from + /// the config, plus any `.pdfsignore` at `local_root`. + /// + /// A config that cannot be read falls back to the built-in defaults rather + /// than to "ignore nothing" — the defaults exclude build and VCS trees, and + /// silently uploading those because a config read failed is the outcome this + /// feature exists to prevent. + fn ignore_rules(&self, local_root: &Path) -> IgnoreRules { + let globals = match pdfs_core::config::AppDirs::new() { + Ok(dirs) => dirs.load_config().resolved_ignore_patterns(), + Err(e) => { + warn!(error = %e, "sync: cannot resolve config dir; using default ignore patterns"); + pdfs_core::syncignore::DEFAULT_IGNORE_PATTERNS + .iter() + .map(|s| (*s).to_string()) + .collect() + } + }; + IgnoreRules::load(local_root, &globals) + } + /// Whether a switch to on-demand is waiting on this folder — i.e. whether a /// pass in flight is still doing work worth doing. Read from the db rather than /// held in memory because the request arrives on the control-socket thread while @@ -658,6 +773,7 @@ impl Core { folder_id: i64, local_root: &Path, remote_root: &NodeUid, + rules: &IgnoreRules, ) -> Result { let baseline = self .db @@ -667,8 +783,12 @@ impl Core { self.progress_scan_total(folder_id, baseline.len()); let mut local: HashMap = HashMap::new(); - self.walk_local(folder_id, local_root, local_root, &mut local)?; - guard_local_wipe(&baseline, &local)?; + self.walk_local(folder_id, local_root, local_root, rules, &mut local)?; + // The guard compares against the paths this pass could still see: an + // ignored path is absent from `local` by rule, not by loss, and counting + // those as missing would trip the guard on every pass — wedging the + // folder's sync permanently — the moment a rule covers the whole tree. + guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; // The remote folder uids come from the baseline instead of a walk: every // directory this device has synced recorded its uid there when it was @@ -909,6 +1029,7 @@ impl Core { folder_id: i64, local_root: &Path, remote_root: &NodeUid, + rules: &IgnoreRules, ) -> Result { // Loaded before the walks: the remote one uses it to tell which files are // unchanged and so can skip decrypting their claimed size, and its size is @@ -922,8 +1043,9 @@ impl Core { self.progress_scan_total(folder_id, baseline.len() * 2); let mut local: HashMap = HashMap::new(); - self.walk_local(folder_id, local_root, local_root, &mut local)?; - guard_local_wipe(&baseline, &local)?; + self.walk_local(folder_id, local_root, local_root, rules, &mut local)?; + // See `push_pass`: the guard must not count rule-excluded paths as lost. + guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; let mut remote: HashMap = HashMap::new(); let mut remote_dirs: HashMap = HashMap::new(); @@ -937,14 +1059,7 @@ impl Core { &baseline, )?; - // Union of every path across the three states, shallow paths first so a - // parent folder is created before its children are placed inside it. - let mut paths: HashSet = HashSet::new(); - paths.extend(local.keys().cloned()); - paths.extend(remote.keys().cloned()); - paths.extend(baseline.keys().cloned()); - let mut order: Vec = paths.into_iter().collect(); - order.sort_by_key(|p| p.matches('/').count()); + let order = classification_order(&local, &remote, &baseline, rules); let mut outcome = Outcome::default(); // Folders to delete, collected here and removed deepest-first at the end @@ -1189,6 +1304,7 @@ impl Core { folder_id: i64, root: &Path, dir: &Path, + rules: &IgnoreRules, out: &mut HashMap, ) -> Result<(), String> { let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?; @@ -1208,6 +1324,13 @@ impl Core { if rel.contains(".pdfs-tmp-") { continue; } + // Ignored paths are dropped here as well as from the classification + // union — not for correctness (the union filter is what makes this + // safe) but so an ignored `node_modules/` is never descended into or + // counted against scan progress. + if rules.is_ignored(rel, meta.is_dir()) { + continue; + } if meta.is_dir() { out.insert( rel.to_string(), @@ -1218,7 +1341,7 @@ impl Core { }, ); self.progress_scanned(folder_id, base_name(rel)); - self.walk_local(folder_id, root, &path, out)?; + self.walk_local(folder_id, root, &path, rules, out)?; } else if meta.is_file() { out.insert( rel.to_string(), @@ -1895,6 +2018,139 @@ mod tests { h.join().unwrap(); } + fn rules_for(patterns: &[&str]) -> IgnoreRules { + // No folder root is needed: these patterns are all root-relative globs, + // and `load` only touches the filesystem to look for an ignore file. + let globals: Vec = patterns.iter().map(|s| (*s).to_string()).collect(); + IgnoreRules::load(Path::new("/nonexistent-sync-root"), &globals) + } + + fn local_item(is_dir: bool) -> LocalItem { + LocalItem { + is_dir, + mtime: 1, + size: 1, + } + } + + fn remote_item(is_dir: bool) -> RemoteItem { + RemoteItem { + uid: uid(), + is_dir, + mtime: 1, + size: 1, + } + } + + /// The regression this whole feature has to not cause: a file that was + /// synced *before* a rule started matching it is absent locally but present + /// remotely and in the baseline — the exact shape `do_reconcile` answers by + /// trashing the remote copy. It must never reach classification. + #[test] + fn a_newly_ignored_synced_file_is_never_classified_for_deletion() { + let local: HashMap = HashMap::new(); + let remote: HashMap = + [("secrets/key.pem".to_string(), remote_item(false))] + .into_iter() + .collect(); + let baseline = baseline_with("secrets/key.pem", 1, 1); + + let order = classification_order(&local, &remote, &baseline, &rules_for(&["secrets/"])); + + assert!( + order.is_empty(), + "an ignored path reached classification as {order:?}; with no local \ + entry and a baseline row it would be trashed on Drive" + ); + } + + /// Filtering only the local walk is not enough, and this is why: the remote + /// and baseline sides carry the path too. + #[test] + fn the_filter_covers_the_remote_and_baseline_sides_not_just_local() { + let local: HashMap = [("target".to_string(), local_item(true))] + .into_iter() + .collect(); + let remote: HashMap = [("target".to_string(), remote_item(true))] + .into_iter() + .collect(); + let baseline = baseline_with("target", 1, 1); + + let order = classification_order(&local, &remote, &baseline, &rules_for(&["target/"])); + + assert!(order.is_empty(), "got {order:?}"); + } + + #[test] + fn unignored_paths_still_classify_shallowest_first() { + let local: HashMap = [ + ("a/b/c.txt".to_string(), local_item(false)), + ("a".to_string(), local_item(true)), + ("a/b".to_string(), local_item(true)), + ("node_modules/x.js".to_string(), local_item(false)), + ] + .into_iter() + .collect(); + let remote: HashMap = HashMap::new(); + let baseline: HashMap = HashMap::new(); + + let order = + classification_order(&local, &remote, &baseline, &rules_for(&["node_modules/"])); + + assert_eq!( + order, + vec!["a", "a/b", "a/b/c.txt"], + "a parent must be created before the paths inside it" + ); + } + + #[test] + fn without_rules_every_path_is_classified() { + let local: HashMap = [("node_modules".to_string(), local_item(true))] + .into_iter() + .collect(); + let remote: HashMap = HashMap::new(); + let baseline: HashMap = HashMap::new(); + + let order = classification_order(&local, &remote, &baseline, &IgnoreRules::empty()); + + assert_eq!(order, vec!["node_modules"]); + } + + /// A rule covering the whole tree empties the local walk. Without the + /// baseline being filtered to match, the wipe guard reads that as "every + /// synced path vanished" and aborts the pass — every pass, forever. + #[test] + fn the_wipe_guard_ignores_paths_excluded_by_rule() { + let mut baseline = baseline_with("build/a.o", 1, 1); + baseline.extend(baseline_with("build/b.o", 1, 1)); + let local: HashMap = HashMap::new(); + let rules = rules_for(&["build/"]); + + assert!( + guard_local_wipe(&baseline, &local).is_err(), + "unfiltered, this is the wedge: the guard fires on a rule, not a loss" + ); + assert!( + guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok(), + "filtered, an all-ignored baseline leaves the guard nothing to check" + ); + } + + /// The guard must still catch a real disappearance when rules are active. + #[test] + fn the_wipe_guard_still_fires_on_a_real_loss_with_rules_active() { + let mut baseline = baseline_with("docs/a.md", 1, 1); + baseline.extend(baseline_with("docs/b.md", 1, 1)); + let local: HashMap = HashMap::new(); + let rules = rules_for(&["build/"]); + + assert!( + guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_err(), + "these paths are not ignored; their absence is a genuine wipe" + ); + } + /// The guard that keeps a vanished local tree (dead mount, unplugged disk) /// from being read as "the user deleted everything" and trashing the folder /// on Drive. @@ -1926,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..e6dff9d 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; @@ -399,6 +400,11 @@ fn build_window(app: &adw::Application) { transfers_inflight: Cell::new(false), cache_bar: main_widgets.cache_bar.clone(), cache_label: main_widgets.cache_label.clone(), + quota_group: main_widgets.quota_group.clone(), + quota_bar: main_widgets.quota_bar.clone(), + quota_label: main_widgets.quota_label.clone(), + quota_inflight: Cell::new(false), + quota_checked_at: Cell::new(None), autostart_row: main_widgets.autostart_row.clone(), budget_row: main_widgets.budget_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(), @@ -478,6 +484,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), @@ -499,6 +506,8 @@ fn build_window(app: &adw::Application) { rows: RefCell::new(Vec::new()), sync_group: devices_widgets.sync_group.clone(), sync_rows: RefCell::new(Vec::new()), + rename_this: devices_widgets.rename_this.clone(), + this_device: RefCell::new(None), inflight: Cell::new(false), loaded_at: Cell::new(None), }, @@ -535,7 +544,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..bda1b36 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -10,6 +10,13 @@ pub(crate) struct DevicesState { pub(crate) rows: RefCell>, pub(crate) sync_group: adw::PreferencesGroup, pub(crate) sync_rows: RefCell>, + /// "Rename" action in the "This computer" header. Insensitive until the + /// device list identifies this machine's own device. + pub(crate) rename_this: gtk4::Button, + /// This machine's own device `(uid, name)`, from the last device list. The + /// current device is filtered out of the "Other computers" rows, so this is + /// where its rename target lives. `None` until identified. + pub(crate) this_device: RefCell>, pub(crate) inflight: Cell, pub(crate) loaded_at: Cell>, } @@ -20,11 +27,16 @@ pub(crate) struct DevicesWidgets { pub(crate) status: adw::StatusPage, /// "This computer" — the local folders synced to this machine's device. pub(crate) sync_group: adw::PreferencesGroup, + /// "Rename" in the "This computer" header — renames this machine's device. + pub(crate) rename_this: gtk4::Button, /// "Other computers" — the account's *other* registered devices. This /// 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, pub(crate) retry: gtk4::Button, pub(crate) refresh: gtk4::Button, } @@ -47,11 +59,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 @@ -65,6 +84,17 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { them as you open them.", ) .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 + // place it can be renamed; insensitive until the device list identifies it. + let rename_this = gtk4::Button::builder() + .icon_name("document-edit-symbolic") + .tooltip_text("Rename this computer") + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); + rename_this.add_css_class("flat"); + sync_group.set_header_suffix(Some(&rename_this)); let group = adw::PreferencesGroup::builder() .title("Other computers") .description("Other devices backing up to this account.") @@ -113,8 +143,10 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { content, status, sync_group, + rename_this, group, add_folder, + restore, retry, refresh, }, @@ -122,7 +154,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 +167,14 @@ 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)); + let ui_ren = ui.clone(); + ui.devices.rename_this.connect_clicked(move |_| { + if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() { + prompt_rename_device(&ui_ren, &uid, &name); + } + }); } /// Show a status page in place of the Devices list. @@ -264,6 +309,22 @@ pub(crate) fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { for row in ui.devices.rows.borrow_mut().drain(..) { ui.devices.group.remove(&row); } + // Identify this machine's own device so the "This computer" header's Rename + // action has a target. It never appears in the rows below, so this is the + // only place its name/uid is captured. + match devices.iter().find(|d| d.this_device) { + Some(me) => { + *ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone())); + ui.devices.rename_this.set_sensitive(true); + ui.devices + .rename_this + .set_tooltip_text(Some(&format!("Rename this computer ({})", me.name))); + } + None => { + *ui.devices.this_device.borrow_mut() = None; + ui.devices.rename_this.set_sensitive(false); + } + } let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect(); if others.is_empty() { let row = adw::ActionRow::builder() @@ -294,6 +355,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 +609,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 +878,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) diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 029f758..fdd9536 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -17,6 +17,14 @@ pub(crate) struct StatusState { pub(crate) mount_row: adw::ActionRow, pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_label: gtk4::Label, + /// Account-quota group + its bar/label. Hidden until the first reading. + pub(crate) quota_group: adw::PreferencesGroup, + pub(crate) quota_bar: gtk4::ProgressBar, + pub(crate) quota_label: gtk4::Label, + /// One `AccountQuota` in flight at a time, and when it last succeeded — quota + /// barely moves, so [`refresh_quota`] refetches on a long TTL, not every tick. + pub(crate) quota_inflight: Cell, + pub(crate) quota_checked_at: Cell>, /// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic /// sets so reflecting the systemd state doesn't fire the toggle handler. pub(crate) autostart_row: adw::SwitchRow, @@ -78,6 +86,10 @@ pub(crate) struct MainWidgets { pub(crate) mount_row: adw::ActionRow, /// Live upload/download progress, populated by the refresh loop. pub(crate) transfers_group: adw::PreferencesGroup, + /// Account-quota group; hidden until `refresh_quota` gets a reading. + pub(crate) quota_group: adw::PreferencesGroup, + pub(crate) quota_bar: gtk4::ProgressBar, + pub(crate) quota_label: gtk4::Label, pub(crate) cache_bar: gtk4::ProgressBar, pub(crate) cache_label: gtk4::Label, pub(crate) pins_group: adw::PreferencesGroup, @@ -111,6 +123,29 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { account_row.add_suffix(&logout_button); account_group.add(&account_row); + // Account storage: the Proton account quota (used of total), distinct from + // the local content cache below. A progress bar + "X of Y used" line, painted + // by `refresh_quota`. Hidden until the first successful read so a cold start + // (or an account the API can't report) shows nothing rather than an empty bar. + let quota_group = adw::PreferencesGroup::builder() + .title("Account storage") + .description("Your Proton storage across all products.") + .visible(false) + .build(); + let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + quota_box.set_margin_top(6); + quota_box.set_margin_bottom(6); + let quota_bar = gtk4::ProgressBar::new(); + let quota_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + quota_label.add_css_class("dim-label"); + quota_box.append("a_bar); + quota_box.append("a_label); + let quota_row = adw::PreferencesRow::builder() + .activatable(false) + .child("a_box) + .build(); + quota_group.add("a_row); + // Mount group: a read-only status line. The mount is managed automatically // by the systemd user service; there is no toggle to fiddle with. let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); @@ -219,6 +254,7 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { inner.set_margin_start(12); inner.set_margin_end(12); inner.append(&account_group); + inner.append("a_group); inner.append(&mount_group); inner.append(&transfers_group); inner.append(&storage_group); @@ -238,6 +274,9 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) { account_row, mount_row, transfers_group, + quota_group, + quota_bar, + quota_label, cache_bar, cache_label, pins_group, @@ -463,12 +502,65 @@ pub(crate) fn refresh(ui: &Rc) { // Both of these pages show work as it happens, so they follow the tick while // 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("activity") => refresh_activity(ui), _ => {} } } +/// How long a quota reading stays fresh. Account storage barely moves, so the +/// Settings tick refetches it only this often rather than every 2s. +const QUOTA_TTL: Duration = Duration::from_secs(60); + +/// Fetch the account quota (if the last reading is stale) and paint the Account +/// storage group. Runs only while Settings is on screen. The group stays hidden +/// until a reading lands, so an account the API can't report — or a still-starting +/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last +/// good reading in place. +pub(crate) fn refresh_quota(ui: &Rc) { + if ui.status.quota_inflight.get() { + return; + } + if let Some(at) = ui.status.quota_checked_at.get() + && at.elapsed() < QUOTA_TTL + { + return; + } + ui.status.quota_inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::AccountQuota); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.status.quota_inflight.set(false); + if let Ok(Ok(Response::AccountQuota { + max_space, + used_space, + })) = result + { + ui.status.quota_checked_at.set(Some(Instant::now())); + let used = used_space.max(0) as u64; + if max_space > 0 { + let total = max_space as u64; + let fraction = (used as f64 / total as f64).min(1.0); + ui.status.quota_bar.set_fraction(fraction); + let pct = (fraction * 100.0).round() as u64; + ui.status.quota_label.set_text(&format!( + "{} of {} used ({pct}%)", + human_bytes(used), + human_bytes(total) + )); + } else { + ui.status.quota_bar.set_fraction(0.0); + ui.status + .quota_label + .set_text(&format!("{} used", human_bytes(used))); + } + ui.status.quota_group.set_visible(true); + } + }); +} + /// Record the mount state seen by the last status poll: gate every control that /// needs a live daemon, and notify the desktop when the state actually flips. /// diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 57bdb6c..7407de1 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=0.6.1 +pkgver=0.7.0 pkgrel=1 pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" arch=('x86_64')