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