diff --git a/Cargo.lock b/Cargo.lock index ed5509b..1eb4a04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pdfs-cli" -version = "3.1.2" +version = "3.2.0" dependencies = [ "anyhow", "clap", @@ -3490,7 +3490,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "3.1.2" +version = "3.2.0" dependencies = [ "async-trait", "base64 0.23.1", @@ -3516,7 +3516,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "3.1.2" +version = "3.2.0" dependencies = [ "async-trait", "base64 0.23.1", @@ -3538,7 +3538,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "3.1.2" +version = "3.2.0" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index c4ec5bd..7092855 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 = "3.1.2" +version = "3.2.0" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 376da60..7f8d9a9 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -628,6 +628,15 @@ enum DeviceCmd { #[arg(long, conflicts_with = "uid")] clear: bool, }, + /// Move another computer's folders into this computer's backup and sync + /// them here. The other computer stays registered, without those folders. + Migrate { + /// Device uid (from `devices list`). + uid: String, + /// Skip the confirmation and accept every proposed local path. + #[arg(long)] + yes: bool, + }, } #[derive(Subcommand)] @@ -967,6 +976,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> { let uid = if clear { None } else { uid }; ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)? } + DeviceCmd::Migrate { uid, yes } => { + if !yes { + println!( + "Every folder that device backs up moves into this computer's backup.\n\ + The device stays registered, without those folders. Stop Proton Drive\n\ + on that computer first, or it sees its folders disappear." + ); + confirm("Move them here?")?; + } + let response = control_request(CtlRequest::MigrateDevice { uid })?; + // A script gets the one reply and runs `sync restore` itself. + if emit_json(&response)? { + return Ok(()); + } + ok_or_bail(response)?; + // The folders are this device's now, so the plain restore offers them. + cmd_sync_restore(yes, None)? + } } Ok(()) } diff --git a/crates/pdfs-core/src/cache.rs b/crates/pdfs-core/src/cache.rs index dde7458..225992c 100644 --- a/crates/pdfs-core/src/cache.rs +++ b/crates/pdfs-core/src/cache.rs @@ -2309,42 +2309,26 @@ mod tests { /// call also needs. #[test] fn budget_check_is_free_when_under_budget() { - use std::time::Instant; - // Cap far above what we store, so every store stays under budget. let (c, _d) = cache_capped(1 << 30); let payload = vec![0u8; 4096]; - - // Cost of a store against a near-empty index... - let t = Instant::now(); - for i in 0..500u64 { + for i in 0..8u64 { c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload) .unwrap(); } - let empty = t.elapsed() / 500; - // ...and against one with a few thousand entries in it. - for i in 500..2500u64 { - c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload) - .unwrap(); - } - let t = Instant::now(); - for i in 2500..3000u64 { - c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload) - .unwrap(); - } - let full = t.elapsed() / 500; + // Plant an index row far over the cap behind the running total's back, + // and older than every real block. Any budget check that consults the + // index would see the pool over budget, evict, and reseed the total. + c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap(); - println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500"); - // A ratio, not a wall-clock bound: the claim is that the under-budget - // path does not grow with the index, and only a ratio tests that. An - // absolute threshold measures the machine's load instead, and this test - // runs alongside the rest of the suite. - assert!( - full < empty * 3, - "under-budget store should not scale with the index: \ - {empty:?} at ~0 entries vs {full:?} at 2500" - ); + c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload) + .unwrap(); + + // Not a timing ratio: that measured the CI runner's load and flaked. + assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096); + let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap(); + assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}"); } /// The running totals are a fast path, not a second source of truth: after diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 770c5d4..ae0e043 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -446,6 +446,13 @@ pub enum Request { /// of registering a duplicate. `uid: None` clears the pin. /// Replies with [`Response::Ok`]. AdoptDevice { uid: Option }, + /// Move every folder another device backs up into this machine's own + /// device, as server-side moves on the main volume. The other device stays + /// registered, without those folders. Sync folders here that already track + /// one of them follow it to its new share. Nothing is downloaded: the moved + /// folders are then offered by [`Request::ListRestorableFolders`]. + /// Replies with [`Response::Ok`]. + MigrateDevice { uid: String }, // ---- device folder sync ---------------------------------- /// Add a local folder to this machine's device, uploading its tree and @@ -1218,6 +1225,12 @@ pub struct JobItem { /// Steps known to need doing, or `0` when unknown (indeterminate progress). /// May *grow* mid-job as more work is discovered. pub total: u64, + /// Housekeeping that changes nothing in the user's files: a mirror folder + /// scan that finds nothing to do yet, an index rebuild, a cache check. Front + /// ends may show it, but should not report the drive as busy over it. Absent + /// from older daemons, which never set it. + #[serde(default)] + pub background: bool, } /// One in-flight transfer in a [`Response::Transfers`] snapshot. @@ -3082,6 +3095,9 @@ mod tests { uid: Some("dev-1".into()), }, Request::AdoptDevice { uid: None }, + Request::MigrateDevice { + uid: "dev-2".into(), + }, Request::ListRestorableFolders, Request::RestoreSyncFolders { items: vec![RestoreItem { diff --git a/crates/pdfs-core/src/db/sync.rs b/crates/pdfs-core/src/db/sync.rs index 404a990..af93b07 100644 --- a/crates/pdfs-core/src/db/sync.rs +++ b/crates/pdfs-core/src/db/sync.rs @@ -139,6 +139,17 @@ impl Db { Ok(()) } + /// Re-point a synced folder at the share that now holds its remote folder, + /// after the folder moved to another device without changing its uid. + pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1", + params![id, share_id], + )?; + Ok(()) + } + /// Queue (or, with `None`, withdraw) a mode the folder should move to once it /// is safe to switch. pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> { diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 559e560..6991ba5 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -4940,6 +4940,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() { assert_eq!(folder.pending_mode, None); } +#[test] +fn moved_sync_folder_points_at_its_new_share() { + let db = Db::open_in_memory().unwrap(); + let id = db + .sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share") + .unwrap(); + let other = db + .sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share") + .unwrap(); + + db.sync_folder_set_share(id, "linux-share").unwrap(); + + let folder = db.sync_folder_get(id).unwrap().unwrap(); + assert_eq!(folder.remote_share_id, "linux-share"); + // A move keeps the uid, so nothing but the share changes. + assert_eq!(folder.remote_uid, "vol~folder"); + assert_eq!( + db.sync_folder_get(other).unwrap().unwrap().remote_share_id, + "windows-share" + ); +} + #[test] fn mode_commit_clears_only_the_intent_it_satisfies() { let db = Db::open_in_memory().unwrap(); diff --git a/crates/pdfs-fuse/src/background.rs b/crates/pdfs-fuse/src/background.rs index 00772e6..7f14d5d 100644 --- a/crates/pdfs-fuse/src/background.rs +++ b/crates/pdfs-fuse/src/background.rs @@ -710,7 +710,7 @@ fn scan_local_once( // The walk has no idea how many files it will find, so the job counts what it // has seen and stays indeterminate. - let job = transfers.begin_job("Indexing this computer"); + let job = transfers.begin_background_job("Indexing this computer"); job.detail("Scanning your files"); let walked = localindex::scan(&[home], &excludes, |batch| { if let Err(e) = db.local_upsert_batch(generation, &batch) { diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index f0a5bfe..f308493 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -369,7 +369,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] { RefreshScope::Photos { .. } => &[], }, R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], - R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations], + R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations], R::AddSyncFolder { .. } | R::RemoveSyncFolder { .. } | R::SetSyncFolderMode { .. } @@ -1297,6 +1297,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(message) => CtlResponse::Ok { message }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) { + 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 diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 79c131e..476b4e0 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -19,6 +19,7 @@ use pdfs_core::control::{ }; use pdfs_core::db::{StoredDevice, StoredSyncFolder}; use pdfs_core::mounts::MountMode; +use pdfs_core::profile::PROFILE_DIR_NAME; use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid}; use proton_drive_rs::{DeviceType, Node}; @@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> { .map_err(|e| CoreError::internal(format!("write config: {e}"))) } +/// The name a folder moved in from `device` takes when `name` is already +/// taken under this device's root, or `None` when it can keep its own. +fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option { + let free = |candidate: &str| !taken.iter().any(|t| t == candidate); + if free(name) { + return None; + } + let base = format!("{name} ({device})"); + if free(&base) { + return Some(base); + } + (2..) + .map(|n| format!("{base} {n}")) + .find(|candidate| free(candidate)) +} + impl Core { // ---- devices ---------------------------------------------------------- @@ -260,6 +277,107 @@ impl Core { }) } + /// Move every folder another device backs up into this machine's own + /// device, so this machine carries on backing them up as itself. The other + /// device stays registered, without those folders. + /// + /// Device roots all live on the main volume, so each folder is one + /// server-side move: nothing is downloaded or uploaded, and no quota is + /// used twice. A folder whose name is taken here keeps its content under + /// " ()". A failed folder does not stop the others, and a + /// second run picks up whatever is still left on the other device. + /// + /// Nothing is synced to this disk here. The moved folders are now this + /// device's, so the ordinary restore offers them. Only folders that were + /// already synced here from the other device are touched: their uid + /// survives the move but their share does not. + pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult { + let source = self.restore_source(Some(uid))?; + let target = self.ensure_device()?; + if source.uid == target.uid { + return Err(CoreError::invalid(format!( + "{} is this computer's own device", + source.name + ))); + } + let bad_root = |d: &StoredDevice| { + parse_uid(&d.root_uid) + .ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid))) + }; + let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?); + if source_root.volume_id != target_root.volume_id { + return Err(CoreError::invalid( + "the two devices are on different Proton Drive volumes; copy it instead", + )); + } + + let mut taken: Vec = self + .device_root_nodes(&target_root)? + .into_iter() + .filter(|n| !n.trashed) + .map(|n| n.name) + .collect(); + let synced = self + .db + .sync_folder_list() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + + let mut moved = 0usize; + let mut renamed: Vec = Vec::new(); + let mut left: Vec = Vec::new(); + let mut failed: Vec = Vec::new(); + for node in self.device_root_nodes(&source_root)? { + if node.trashed || node.name == PROFILE_DIR_NAME { + continue; + } + // A device root only ever holds folders (B68); anything else was + // put there by hand and is left for the user to deal with. + if !node.is_folder() { + left.push(node.name); + continue; + } + let new_name = migrated_name(&node.name, &source.name, &taken); + if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) { + warn!(name = %node.name, error = %e, "migrate: could not move folder"); + failed.push(format!("{}: {e}", node.name)); + continue; + } + let landed = new_name.clone().unwrap_or_else(|| node.name.clone()); + if new_name.is_some() { + renamed.push(format!("{} -> {landed}", node.name)); + } + taken.push(landed); + moved += 1; + + let uid = node.uid.to_string(); + for folder in synced.iter().filter(|f| f.remote_uid == uid) { + if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) { + warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder"); + } + self.sync_now(Some(folder.id)); + } + } + self.invalidate_children_of(&source_root); + self.invalidate_children_of(&target_root); + + let mut message = format!("moved {moved} folder(s) from {}", source.name); + if !renamed.is_empty() { + message.push_str(&format!("; renamed: {}", renamed.join(", "))); + } + if !left.is_empty() { + message.push_str(&format!("; left behind: {}", left.join(", "))); + } + if !failed.is_empty() { + message.push_str(&format!("; failed: {}", failed.join(", "))); + } + info!(from = %source.name, to = %target.name, moved, failed = failed.len(), + "device migrated"); + if moved == 0 && !failed.is_empty() { + return Err(CoreError::conflict(message)); + } + Ok(message) + } + // ---- device folder sync ------------------------- /// Auto-register (or recover) this machine as a Proton Drive Device, caching @@ -360,21 +478,25 @@ impl Core { root_uid: &NodeUid, name: &str, ) -> CoreResult> { + Ok(self + .device_root_nodes(root_uid)? + .into_iter() + .find(|n| n.is_folder() && !n.trashed && n.name == name) + .map(|n| n.uid)) + } + + /// Everything directly under a device root, trashed nodes included. + fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult> { let uids = self .rt .block_on(self.drive.enumerate_folder_children_node_uids(root_uid)) .map_err(|e| CoreError::from_api(&e, "list device root"))?; if uids.is_empty() { - return Ok(None); + return Ok(Vec::new()); } - let nodes = self - .rt + self.rt .block_on(self.drive.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)) + .map_err(|e| CoreError::from_api(&e, "resolve device root children")) } /// Add a local folder to this device's sync set: register the device if @@ -535,12 +657,16 @@ impl Core { } else { p.total.max(p.done) as u64 }, + // Most passes find nothing; only one that queues work is + // the drive syncing. + background: true, }, SyncPhase::Applying => JobItem { title: format!("Syncing {folder}"), detail: p.current.clone(), done: p.done as u64, total: p.total.max(p.done) as u64, + background: false, }, }); } @@ -842,6 +968,23 @@ impl Core { if folder.state != "idle" { return Err(SwitchBlocked::NotNow); } + // `idle` only says the last pass applied everything it tried. + // It never tries an ignored path, a symlink or a folder it + // cannot read, it defers a file open for writing, and a file can + // change after it ends. The eviction below deletes all of those + // for good, so it waits until nothing here is missing from Drive + // (B198). + let baseline = self + .db + .sync_entries(id) + .map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?; + if let Some(reason) = + sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local)) + { + return Err(SwitchBlocked::Failed(format!( + "going online-only would delete what is only on this computer: {reason}" + ))); + } let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| { SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid)) })?; @@ -1107,10 +1250,38 @@ mod tests { use pdfs_core::db::StoredSyncFolder; use super::{ - ApplicableMountMode, commit_after_teardown, pending_intent_matches, + ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches, restore_snapshot_matches, select_mode_transition, }; + #[test] + fn migrated_folder_keeps_its_name_when_it_is_free() { + let taken = vec!["Pictures".to_string()]; + assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None); + } + + #[test] + fn migrated_folder_is_named_after_its_device_on_a_clash() { + let taken = vec!["Documents".to_string()]; + assert_eq!( + migrated_name("Documents", "DESKTOP-1", &taken).as_deref(), + Some("Documents (DESKTOP-1)") + ); + } + + #[test] + fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() { + let taken = vec![ + "Documents".to_string(), + "Documents (DESKTOP-1)".to_string(), + "Documents (DESKTOP-1) 2".to_string(), + ]; + assert_eq!( + migrated_name("Documents", "DESKTOP-1", &taken).as_deref(), + Some("Documents (DESKTOP-1) 3") + ); + } + #[test] fn unknown_mode_cannot_select_a_destructive_transition() { let mut unmounts = 0; diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 7e75e3b..ff95fe1 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -744,6 +744,9 @@ struct Core { /// uploading that same tree — the engine would upload files as they vanish /// and then walk the FUSE mount as if it were local. sync_locks: Arc>>>>, + /// Per-sync-folder paths the last pass could not read, so the activity feed + /// says so when the set changes rather than on every poll (B196). + sync_unscanned: Arc>>>, /// Nodes this daemon changed on the remote itself, and how many echoes of /// those changes the event feed still owes us. /// @@ -1031,7 +1034,9 @@ impl Core { let core = self.clone(); std::thread::spawn(move || { - let job = core.transfers.begin_job("Checking the cache database"); + let job = core + .transfers + .begin_background_job("Checking the cache database"); job.detail("Verifying every page"); let problems = match core.db.integrity_check() { Ok(problems) => problems, diff --git a/crates/pdfs-fuse/src/mount.rs b/crates/pdfs-fuse/src/mount.rs index fde3203..a5f163a 100644 --- a/crates/pdfs-fuse/src/mount.rs +++ b/crates/pdfs-fuse/src/mount.rs @@ -475,6 +475,7 @@ pub(crate) fn mount_with( sync_tx, mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())), sync_locks: Arc::new(Mutex::new(HashMap::new())), + sync_unscanned: Arc::new(Mutex::new(HashMap::new())), states: Arc::new(StateRegistry::default()), }; // Before anything can queue work against it: the drain thread below reaches diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index c4875ca..4e429e1 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -660,6 +660,12 @@ impl Core { /// the shared uids; the per-node detail is then gathered best-effort — a single /// node racing with an unshare drops from the list rather than failing the whole /// request. + /// + /// A share with no member, no invitation and no link is left out: it is + /// shared with no one. Stop Sharing leaves exactly that behind, since the SDK + /// cannot delete a share, and Drive keeps listing it until its backend gets + /// round to removing it, minutes later. A share whose details could not all + /// be read stays listed, so a failed lookup never hides a live share. pub(crate) fn list_shared_by_me(&self) -> CoreResult> { let uids = self .rt @@ -678,24 +684,26 @@ impl Core { let members = self .rt .block_on(self.client.list_share_members(&uid)) - .map(|m| m.len()) - .unwrap_or(0); + .map(|m| m.len()); let proton_invites = self .rt .block_on(self.client.list_share_invitations(&uid)) - .map(|i| i.len()) - .unwrap_or(0); + .map(|i| i.len()); let external_invites = self .rt .block_on(self.client.list_external_invitations(&uid)) - .map(|i| i.len()) - .unwrap_or(0); - let link = self - .rt - .block_on(self.client.get_public_link(&uid)) - .ok() - .flatten() - .map(public_link_info); + .map(|i| i.len()); + let link = self.rt.block_on(self.client.get_public_link(&uid)); + if let (Ok(0), Ok(0), Ok(0), Ok(None)) = + (&members, &proton_invites, &external_invites, &link) + { + debug!(%uid, "shared by me: skipping an emptied share"); + continue; + } + let members = members.unwrap_or(0); + let proton_invites = proton_invites.unwrap_or(0); + let external_invites = external_invites.unwrap_or(0); + let link = link.ok().flatten().map(public_link_info); items.push(SharedItem { uid: uid.to_string(), is_dir: n.is_folder(), diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 22af1f1..abe74ba 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -50,7 +50,7 @@ mod planner; use engine::SYNC_CONCURRENCY; pub(crate) use engine::{SyncMsg, spawn}; #[cfg(test)] -use engine::{classify, settle_with}; +use engine::{classify, event_target, settle_with}; #[cfg(test)] use local_scan::is_write_mode; use local_scan::open_for_write_set; @@ -59,8 +59,9 @@ pub(crate) use planner::base_name; #[cfg(test)] use planner::conflict_path; use planner::{ - FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe, - join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size, + FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline, + guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig, + tracked_baseline, unchanged_remote_size, }; /// What a [`Pending`] op did, so the reconcile can update shared state on the /// engine thread (never inside a task). @@ -187,6 +188,7 @@ impl Core { "idle" }; let _ = self.db.sync_folder_set_state(folder.id, state, now_secs()); + self.report_unscanned(folder.id, name, &outcome.unscanned); // Summarise a pass that actually moved something. A poll over an // unchanged folder does nothing and says nothing — otherwise the // feed would fill with "synced, 0 changes" every two minutes. @@ -243,6 +245,37 @@ impl Core { } } + /// Tell the activity feed what a pass over this folder had to leave out + /// because it cannot be read, once each time that changes. Every other path + /// in the folder still synced, so this does not make the folder's state an + /// error; but a folder the user has no permission for is never going to + /// reach Drive, and they should hear that once rather than not at all + /// (B196). + fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) { + let previous = { + let mut last = self.sync_unscanned.lock(); + if unscanned.is_empty() { + last.remove(&folder_id) + } else { + last.insert(folder_id, unscanned.to_vec()) + } + }; + if unscanned.is_empty() || previous.as_deref() == Some(unscanned) { + return; + } + const SHOWN: usize = 3; + let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", "); + if unscanned.len() > SHOWN { + detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN)); + } + self.log_activity( + ActivityKind::Sync, + format!("{} item(s) in {name} cannot be synced", unscanned.len()), + detail, + false, + ); + } + /// Compile the ignore rules for a synced folder: the global patterns from /// the config, plus any `.pdfsignore` at `local_root`. /// @@ -250,7 +283,7 @@ impl Core { /// 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 { + pub(crate) 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) => { @@ -313,15 +346,23 @@ impl Core { self.progress_scan_total(folder_id, baseline.len()); let mut local: HashMap = HashMap::new(); + let mut unscanned = Unscanned::default(); let writing = open_for_write_set(local_root); self.walk_local( - folder_id, local_root, local_root, rules, &writing, &mut local, + folder_id, + local_root, + rules, + &writing, + &mut local, + &mut unscanned, )?; // 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)?; + // ignored or unreadable path is absent from `local` by rule or by + // failure, 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. + let tracked = tracked_baseline(&baseline, rules, &unscanned); + guard_local_wipe(&tracked, &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 @@ -391,6 +432,7 @@ impl Core { let mut outcome = Outcome { deferred: local.values().filter(|item| item.open_for_write).count(), + unscanned: unscanned.report(), ..Default::default() }; let mut order: Vec<&String> = local.keys().collect(); @@ -515,13 +557,8 @@ impl Core { // showing the folder's remote contents — otherwise everything they deleted // comes back the moment the switch lands. Shallowest first, skipping anything // under a folder already trashed (its children went with it). - let mut missing: Vec<&String> = baseline - .keys() - .filter(|rel| !local.contains_key(*rel)) - .collect(); - missing.sort_by_key(|p| p.matches('/').count()); let mut trashed: Vec = Vec::new(); - for rel in missing { + for rel in &local_deletions(&tracked, &local) { if trashed .iter() .any(|dir| rel.starts_with(&format!("{dir}/"))) @@ -586,12 +623,18 @@ impl Core { self.progress_scan_total(folder_id, baseline.len() * 2); let mut local: HashMap = HashMap::new(); + let mut unscanned = Unscanned::default(); let writing = open_for_write_set(local_root); self.walk_local( - folder_id, local_root, local_root, rules, &writing, &mut local, + folder_id, + local_root, + rules, + &writing, + &mut local, + &mut unscanned, )?; - // See `push_pass`: the guard must not count rule-excluded paths as lost. - guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; + // See `push_pass`: the guard must not count excluded paths as lost. + guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?; let mut remote: HashMap = HashMap::new(); let mut remote_dirs: HashMap = HashMap::new(); @@ -605,9 +648,15 @@ impl Core { &baseline, )?; - let order = classification_order(&local, &remote, &baseline, rules); + let mut order = classification_order(&local, &remote, &baseline, rules); + // A path the scan could not read is no more a local deletion than an + // ignored one, and a remote path under it has nowhere to be written. + order.retain(|rel| !unscanned.covers(rel)); - let mut outcome = Outcome::default(); + let mut outcome = Outcome { + unscanned: unscanned.report(), + ..Default::default() + }; // Folders to delete, collected here and removed deepest-first at the end // so a parent is never removed before its children. let mut delete_local_dirs: Vec = Vec::new(); @@ -873,19 +922,19 @@ impl Core { Ok(outcome) } - /// Recursively walk a local directory into `out`, keyed by `/`-joined relative - /// path. Symlinks and other special files are skipped. Reports each entry to the - /// pass's scan progress. + /// Recursively walk a local tree into `out`, keyed by `/`-joined relative + /// path. Symlinks and other special files are skipped, and paths that cannot + /// be read go to `unscanned`. Reports each entry to the pass's scan progress. fn walk_local( &self, folder_id: i64, root: &Path, - dir: &Path, rules: &IgnoreRules, writing: &HashSet, out: &mut HashMap, + unscanned: &mut Unscanned, ) -> Result<(), String> { - walk_local_tree(root, dir, rules, writing, out, &mut |rel| { + walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| { self.progress_scanned(folder_id, base_name(rel)) }) } @@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> { } /// The body of [`Core::walk_local`], without a `Core`: recursively collect the -/// local tree under `dir` into `out`, reporting each entry to `scanned`. +/// local tree under `root` into `out`, reporting each entry to `scanned`. /// -/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that -/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of -/// them would, if skipped, leave `out` describing a *smaller* tree than the one -/// on disk. Reconciliation reads a path present in the baseline and absent from -/// the scan as "the user deleted this", and propagates that to Drive. So an -/// unreadable subdirectory would trash its remote counterpart, and the deeper -/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the -/// whole pass keeps the previous baseline and leaves the remote alone; the next -/// pass retries once the transient condition clears. +/// **A path the scan cannot read is left out and recorded in `unscanned`, never +/// silently dropped.** Every pass drops what `unscanned` covers from +/// classification, so a baseline row under it is neither a local deletion nor a +/// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no +/// permission for, an entry that vanished between the listing and its `stat`, +/// and a name that is not UTF-8. Failing the whole pass on any of them, as this +/// did before, left one root-owned folder stopping everything else in the tree +/// from syncing, on every pass, for good (B196). +/// +/// The root is the exception, and so is any other I/O error: an absent or +/// unreadable root fails the pass, since leaving the root out would leave +/// nothing in, and an error this cannot name a cause for is not one to guess +/// about. /// /// Split out from the method so exactly that can be tested against a real tree /// with a real unreadable directory in it, which needs no `Core` at all. fn walk_local_tree( + root: &Path, + rules: &IgnoreRules, + writing: &HashSet, + out: &mut HashMap, + unscanned: &mut Unscanned, + scanned: &mut dyn FnMut(&str), +) -> Result<(), String> { + walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string()) +} + +/// Why [`walk_local_dir`] could not finish a folder. +enum ScanError { + /// An I/O error on `what`, which the caller may leave out of the pass. + Io { what: String, error: std::io::Error }, + /// Anything else, which fails the pass. + Failed(String), +} + +impl std::fmt::Display for ScanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ScanError::Io { what, error } => write!(f, "{what}: {error}"), + ScanError::Failed(message) => f.write_str(message), + } + } +} + +/// Leave `rel` out of the pass for `failure`, or hand it back when it is one +/// that fails the pass. Permission denied is reported to the user; an entry +/// that vanished mid-scan is not, since the next pass sees it settled. +fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> { + let ScanError::Io { what, error } = &failure else { + return Err(failure); + }; + match error.kind() { + std::io::ErrorKind::PermissionDenied => { + warn!(%what, %error, "sync: cannot read; leaving it out of the pass"); + unscanned.unreadable(rel.to_string(), &error.to_string()); + Ok(()) + } + std::io::ErrorKind::NotFound => { + debug!(%what, "sync: gone mid-scan; leaving it out of the pass"); + unscanned.vanished(rel.to_string()); + Ok(()) + } + _ => Err(failure), + } +} + +fn walk_local_dir( root: &Path, dir: &Path, rules: &IgnoreRules, writing: &HashSet, out: &mut HashMap, + unscanned: &mut Unscanned, scanned: &mut dyn FnMut(&str), -) -> Result<(), String> { - let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?; +) -> Result<(), ScanError> { + let io = |what: String| move |error| ScanError::Io { what, error }; + let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?; for entry in entries { - let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?; + let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?; let path = entry.path(); - let meta = std::fs::symlink_metadata(&path) - .map_err(|e| format!("stat {}: {e}", path.display()))?; let stripped = path.strip_prefix(root).map_err(|e| { - format!( + ScanError::Failed(format!( "local path {} escaped sync root {}: {e}", path.display(), root.display() - ) - })?; - let rel = stripped.to_str().ok_or_else(|| { - format!( - "local path {} is not valid UTF-8; refusing a destructive sync pass", - path.display() - ) + )) })?; + let Some(rel) = stripped.to_str() else { + // Nothing can be keyed by this name: not the baseline, which only + // ever held paths this scan produced, and not Drive, whose names are + // UTF-8. Leaving it out cannot read as a deletion of anything. + warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass"); + unscanned.unreadable( + stripped.to_string_lossy().into_owned(), + "name is not valid UTF-8", + ); + continue; + }; // Ignore our own in-flight download temp files. if rel.contains(".pdfs-tmp-") { continue; } + let meta = match std::fs::symlink_metadata(&path) { + Ok(meta) => meta, + // Gone since the listing, or in a folder that lists but does not + // allow `stat`. Either way this pass cannot say what it is. + Err(error) => { + let what = format!("stat {}", path.display()); + leave_out(rel, ScanError::Io { what, error }, unscanned)?; + 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 @@ -1646,7 +1764,15 @@ fn walk_local_tree( }, ); scanned(rel); - walk_local_tree(root, &path, rules, writing, out, scanned)?; + if let Err(failure) = + walk_local_dir(root, &path, rules, writing, out, unscanned, scanned) + { + leave_out(rel, failure, unscanned)?; + // What the folder did list before failing goes with it: a + // half-read folder is as unknown as an unread one. + let prefix = format!("{rel}/"); + out.retain(|k, _| k != rel && !k.starts_with(&prefix)); + } } else if meta.is_file() { // Check whether any process holds this file open for writing. // The canonical path is used because /proc/*/fd links resolve @@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced( baseline: &HashMap, ) -> Option { let mut seen = HashSet::new(); - if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) { + if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) { return Some(reason); } let prefix = format!("{rel}/"); @@ -1695,45 +1821,93 @@ pub(crate) fn mirror_subtree_unsynced( .map(|r| format!("{r} was deleted here but not on Proton Drive yet")) } +/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy +/// holds something Proton Drive does not, or `None` when evicting it loses +/// nothing. The switch to on-demand asks this before it empties the folder. +/// +/// A push pass that ends `idle` is not enough on its own. It never uploads an +/// ignored path, a symlink or a folder it cannot read, it defers a file open +/// for writing, and a file can change after it finishes; the eviction deleted +/// all of those for good (B198). +/// +/// A tracked path deleted here but still in the baseline counts, since the +/// mount would show it again. An ignored one does not: ignoring never carries +/// a deletion to Drive, so its row stays in the baseline for good, and holding +/// the switch on it would hold it forever. +pub(crate) fn mirror_tree_unsynced( + root: &Path, + baseline: &HashMap, + rules: &IgnoreRules, +) -> Option { + let mut seen = HashSet::new(); + if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) { + return Some(reason); + } + let mut missing: Vec = filter_baseline(baseline, rules) + .into_keys() + .filter(|r| !seen.contains(r.as_str())) + .collect(); + missing.sort_unstable(); + missing + .first() + .map(|r| format!("{r} was deleted here but not on Proton Drive yet")) +} + +/// The walk behind both checks. `rules`, when given, only names an ignored +/// path as such in the reason; it is still something only this disk has. fn unsynced_local( root: &Path, rel: &str, baseline: &HashMap, + rules: Option<&IgnoreRules>, seen: &mut HashSet, ) -> Option { - let path = root.join(rel_to_path(rel)); - let meta = match std::fs::symlink_metadata(&path) { - Ok(meta) => meta, - Err(e) => return Some(format!("cannot read {rel}: {e}")), + let path = if rel.is_empty() { + root.to_path_buf() + } else { + root.join(rel_to_path(rel)) }; - let Some(base) = baseline.get(rel) else { - return Some(format!("{rel} is not on Proton Drive yet")); - }; - seen.insert(rel.to_string()); - if meta.is_file() { - return (!LocalSig::from(&meta).same_content(&LocalSig::from(base))) - .then(|| format!("{rel} has changes that are not on Proton Drive yet")); - } - if !meta.is_dir() { - return Some(format!("{rel} is not a regular file or folder")); + // The folder itself has no baseline row; only what is in it does. + if !rel.is_empty() { + let meta = match std::fs::symlink_metadata(&path) { + Ok(meta) => meta, + Err(e) => return Some(format!("cannot read {rel}: {e}")), + }; + let Some(base) = baseline.get(rel) else { + if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) { + return Some(format!( + "{rel} is excluded from sync, so it is only on this computer" + )); + } + return Some(format!("{rel} is not on Proton Drive yet")); + }; + seen.insert(rel.to_string()); + if meta.is_file() { + return (!LocalSig::from(&meta).same_content(&LocalSig::from(base))) + .then(|| format!("{rel} has changes that are not on Proton Drive yet")); + } + if !meta.is_dir() { + return Some(format!("{rel} is not a regular file or folder")); + } } + let shown = if rel.is_empty() { "the folder" } else { rel }; let entries = match std::fs::read_dir(&path) { Ok(entries) => entries, - Err(e) => return Some(format!("cannot read {rel}: {e}")), + Err(e) => return Some(format!("cannot read {shown}: {e}")), }; for entry in entries { let name = match entry { Ok(entry) => entry.file_name(), - Err(e) => return Some(format!("cannot read {rel}: {e}")), + Err(e) => return Some(format!("cannot read {shown}: {e}")), }; let Some(name) = name.to_str() else { - return Some(format!("{rel} holds a name that is not valid UTF-8")); + return Some(format!("{shown} holds a name that is not valid UTF-8")); }; // A download the engine left half done; it is the engine's to clean up. if name.contains(".pdfs-tmp-") { continue; } - if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) { + if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) { return Some(reason); } } @@ -1923,6 +2097,32 @@ mod tests { (ids, started.elapsed()) } + #[test] + fn a_change_in_one_mirror_folder_reconciles_only_that_folder() { + let watched = vec![ + (PathBuf::from("/home/me/Docs"), 1), + (PathBuf::from("/home/me/Photos"), 2), + (PathBuf::from("/home/me/Photos/Raw"), 3), + ]; + let target = |paths: &[&str]| { + let paths: Vec = paths.iter().map(PathBuf::from).collect(); + match event_target(&watched, &paths) { + SyncMsg::Reconcile(id) => Some(id), + SyncMsg::ReconcileAll => None, + _ => panic!("unexpected message"), + } + }; + assert_eq!(target(&["/home/me/Docs/a.txt"]), Some(1)); + // A rename within one folder. + assert_eq!(target(&["/home/me/Docs/a", "/home/me/Docs/b"]), Some(1)); + // The deepest root wins. + assert_eq!(target(&["/home/me/Photos/Raw/x.cr2"]), Some(3)); + // Across folders, outside them all, or no path at all: walk everything. + assert_eq!(target(&["/home/me/Docs/a", "/home/me/Photos/a"]), None); + assert_eq!(target(&["/home/me/Docsx/a"]), None); + assert_eq!(target(&[]), None); + } + /// The bug this replaced a fixed sleep for: a save that keeps writing past /// the debounce used to be walked mid-write, uploading a torn snapshot as a /// real revision. The window has to restart on every event, so settling @@ -2154,7 +2354,15 @@ mod tests { let writing = HashSet::new(); let mut local: HashMap = HashMap::new(); let mut progress = no_progress(); - walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap(); + walk_local_tree( + &root, + &rules, + &writing, + &mut local, + &mut Unscanned::default(), + &mut progress, + ) + .unwrap(); assert!(local.is_empty(), "the replacement root is genuinely empty"); for size in 1..=3 { @@ -2170,7 +2378,15 @@ mod tests { // remounted folder resumes rather than staying wedged. std::fs::write(root.join("file0.txt"), b"back").unwrap(); let mut local: HashMap = HashMap::new(); - walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap(); + walk_local_tree( + &root, + &rules, + &writing, + &mut local, + &mut Unscanned::default(), + &mut progress, + ) + .unwrap(); let baseline: HashMap = [("file0.txt".to_string(), ())].into_iter().collect(); assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok()); @@ -2475,33 +2691,176 @@ mod tests { |_: &str| {} } - /// A directory the pass cannot read must fail the pass. Omitting it would - /// hand reconciliation a baseline-minus-subtree, which it reads as the user - /// having deleted that subtree and propagates to Drive (B55). + /// Scan `root` with no rules, returning what it found and left out. + fn scan(root: &Path) -> Result<(HashMap, Unscanned), String> { + let mut out = HashMap::new(); + let mut unscanned = Unscanned::default(); + let mut progress = no_progress(); + walk_local_tree( + root, + &rules_for(&[]), + &HashSet::new(), + &mut out, + &mut unscanned, + &mut progress, + )?; + Ok((out, unscanned)) + } + + fn sorted_keys(map: &HashMap) -> Vec<&str> { + let mut keys: Vec<&str> = map.keys().map(String::as_str).collect(); + keys.sort_unstable(); + keys + } + + /// B196, the report in #33: a folder the user may not read, such as a + /// container's root-owned cache, is left out of the pass and named, and + /// everything around it still scans. It used to fail the whole pass, so + /// nothing else in the tree ever synced again. #[test] - fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() { + fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() { use std::os::unix::fs::PermissionsExt as _; let root = sync_test_dir("scan-eacces"); std::fs::write(root.join("keep.txt"), b"a").unwrap(); - let locked = root.join("locked"); + std::fs::create_dir_all(root.join("docker/cache")).unwrap(); + std::fs::write(root.join("docker/compose.yml"), b"c").unwrap(); + let locked = root.join("docker/cache/locked"); std::fs::create_dir(&locked).unwrap(); std::fs::write(locked.join("inner.txt"), b"b").unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); - let rules = rules_for(&[]); - let writing = HashSet::new(); - let mut out = HashMap::new(); - let mut progress = no_progress(); - let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress); + let result = scan(&root); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::remove_dir_all(&root).unwrap(); - let error = result.expect_err("an unreadable directory must abort the pass"); - assert!( - error.contains("locked"), - "the error should name the subtree it could not read: {error}" + let (out, unscanned) = result.expect("one unreadable folder must not fail the pass"); + assert_eq!( + sorted_keys(&out), + vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"] + ); + assert!(unscanned.covers("docker/cache/locked")); + assert!(unscanned.covers("docker/cache/locked/inner.txt")); + assert!(!unscanned.covers("docker/cache")); + let report = unscanned.report(); + assert_eq!(report.len(), 1); + assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}"); + } + + /// A folder with read but no search permission lists its names but cannot + /// `stat` them. Whatever it did list must go with it: a half-read folder + /// is as unknown as an unread one. + #[test] + fn a_listable_but_unsearchable_folder_is_left_out_whole() { + use std::os::unix::fs::PermissionsExt as _; + + let root = sync_test_dir("scan-no-search"); + std::fs::write(root.join("keep.txt"), b"a").unwrap(); + let half = root.join("half"); + std::fs::create_dir(&half).unwrap(); + std::fs::write(half.join("one.txt"), b"1").unwrap(); + std::fs::write(half.join("two.txt"), b"2").unwrap(); + std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap(); + + let result = scan(&root); + + std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + + let (out, unscanned) = result.unwrap(); + // Each entry fails its own `stat` and is left out by name. + assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]); + assert!(unscanned.covers("half/one.txt")); + assert!(unscanned.covers("half/two.txt")); + } + + /// The other half of B196: what was left out is not a deletion, and not a + /// download target either. Its baseline rows leave the wipe guard's set + /// and a push pass's deletions, and its paths leave classification. + #[test] + fn a_path_the_scan_left_out_is_never_a_local_deletion() { + let mut baseline = baseline_with("keep.txt", 1, 1); + baseline.extend(baseline_with("locked", 0, 0)); + baseline.extend(baseline_with("locked/inner.txt", 1, 1)); + baseline.extend(baseline_with("gone.txt", 1, 1)); + let local: HashMap = [("keep.txt".to_string(), local_item(false))] + .into_iter() + .collect(); + let mut unscanned = Unscanned::default(); + unscanned.unreadable("locked".to_string(), "Permission denied"); + let rules = rules_for(&[]); + + let tracked = tracked_baseline(&baseline, &rules, &unscanned); + assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]); + assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]); + + // Every tracked path but one is gone, and the guard still lets the + // pass run: one survivor proves the tree is there. + assert!(guard_local_wipe(&tracked, &local).is_ok()); + // With everything readable left out, nothing is tracked, so nothing + // can be lost and the guard has nothing to object to. + let mut all = Unscanned::default(); + all.unreadable("keep.txt".to_string(), "Permission denied"); + all.unreadable("locked".to_string(), "Permission denied"); + all.vanished("gone.txt".to_string()); + assert!(tracked_baseline(&baseline, &rules, &all).is_empty()); + + let remote: HashMap = HashMap::new(); + let mut order = classification_order(&local, &remote, &baseline, &rules); + order.retain(|rel| !unscanned.covers(rel)); + order.sort_unstable(); + assert_eq!(order, vec!["gone.txt", "keep.txt"]); + } + + #[test] + fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() { + let mut unscanned = Unscanned::default(); + unscanned.unreadable("a/locked".to_string(), "Permission denied"); + unscanned.vanished("b.txt".to_string()); + + assert!(unscanned.covers("a/locked")); + assert!(unscanned.covers("a/locked/x/y.txt")); + assert!(unscanned.covers("b.txt")); + assert!(!unscanned.covers("a")); + assert!(!unscanned.covers("a/locked2")); + assert!(!unscanned.covers("b.txt.bak")); + // A file that only vanished mid-scan is not worth telling anyone about. + assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]); + } + + /// B197: a push pass read its deletions from the raw baseline, so a path + /// synced before it became ignored, which the walk skips, was trashed on + /// Drive, and the switch the push pass ran for then evicted the local copy. + #[test] + fn a_push_pass_never_reads_an_ignored_path_as_deleted() { + let mut baseline = baseline_with("notes.md", 1, 1); + baseline.extend(baseline_with("node_modules", 0, 0)); + baseline.extend(baseline_with("node_modules/x/index.js", 1, 1)); + baseline.extend(baseline_with("old.md", 1, 1)); + let local: HashMap = [("notes.md".to_string(), local_item(false))] + .into_iter() + .collect(); + let rules = rules_for(&["node_modules/"]); + + let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default()); + + assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]); + } + + /// Deletions come out shallowest first, so a trashed folder can take its + /// children with it. + #[test] + fn local_deletions_come_out_shallowest_first() { + let mut baseline = baseline_with("d/e/f.txt", 1, 1); + baseline.extend(baseline_with("d", 0, 0)); + baseline.extend(baseline_with("d/e", 0, 0)); + let local: HashMap = HashMap::new(); + let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default()); + + assert_eq!( + local_deletions(&tracked, &local), + vec!["d", "d/e", "d/e/f.txt"] ); } @@ -2593,6 +2952,94 @@ mod tests { assert_eq!(reason, None); } + // ---- B198: going online-only never evicts what Drive does not have ----- + + #[test] + fn a_fully_synced_mirror_folder_may_go_online_only() { + let (root, baseline) = synced_subtree("evict-synced"); + let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[])); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(reason, None); + } + + /// The case that lost data: `.git/` is ignored by default, so no pass ever + /// uploads it, and the eviction deleted it with the rest. + #[test] + fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() { + let (root, baseline) = synced_subtree("evict-ignored"); + std::fs::create_dir(root.join(".git")).unwrap(); + std::fs::write(root.join(".git/HEAD"), b"ref").unwrap(); + let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"])); + std::fs::remove_dir_all(&root).unwrap(); + let reason = reason.expect("an ignored folder is only on this disk"); + assert!( + reason.contains(".git") && reason.contains("excluded"), + "{reason}" + ); + } + + /// An ignored row stays in the baseline for good once its file is gone, so + /// counting it as a deletion not carried up would hold the switch forever. + #[test] + fn a_deleted_ignored_path_does_not_hold_the_switch_back() { + let (root, mut baseline) = synced_subtree("evict-ignored-gone"); + baseline.insert( + "build/out.o".to_string(), + StoredSyncEntry { + rel_path: "build/out.o".to_string(), + ..baseline["dir/a.txt"].clone() + }, + ); + let rules = rules_for(&["build/"]); + let ignored = mirror_tree_unsynced(&root, &baseline, &rules); + let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[])); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(ignored, None); + assert!(tracked.unwrap().contains("build/out.o")); + } + + #[test] + fn an_edit_after_the_last_pass_holds_the_switch_back() { + let (root, baseline) = synced_subtree("evict-edit"); + std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap(); + let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[])); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("dir/sub/b.txt")); + } + + #[test] + fn a_new_file_at_the_root_holds_the_switch_back() { + let (root, baseline) = synced_subtree("evict-new"); + std::fs::write(root.join("new.txt"), b"n").unwrap(); + let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[])); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("new.txt")); + } + + /// The folder B196 now leaves out of the pass instead of failing it is, + /// by the same token, something the eviction must not delete. + #[test] + fn an_unreadable_folder_holds_the_switch_back() { + use std::os::unix::fs::PermissionsExt as _; + + let (root, baseline) = synced_subtree("evict-eacces"); + let locked = root.join("dir/sub"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[])); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("dir/sub")); + } + + #[test] + fn a_symlink_holds_the_switch_back() { + let (root, baseline) = synced_subtree("evict-symlink"); + std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap(); + let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[])); + std::fs::remove_dir_all(&root).unwrap(); + assert!(reason.unwrap().contains("link")); + } + /// The same rule one level up: if the sync root itself cannot be read, the /// pass sees nothing, and "nothing" must never mean "delete everything". #[test] @@ -2607,7 +3054,14 @@ mod tests { let writing = HashSet::new(); let mut out = HashMap::new(); let mut progress = no_progress(); - let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress); + let result = walk_local_tree( + &root, + &rules, + &writing, + &mut out, + &mut Unscanned::default(), + &mut progress, + ); std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::remove_dir_all(&root).unwrap(); @@ -2628,30 +3082,40 @@ mod tests { let writing = HashSet::new(); let mut out = HashMap::new(); let mut progress = no_progress(); - assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err()); + assert!( + walk_local_tree( + &root, + &rules, + &writing, + &mut out, + &mut Unscanned::default(), + &mut progress + ) + .is_err() + ); assert!(out.is_empty()); } - /// A name that is not valid UTF-8 cannot be keyed into the scan, so it - /// cannot be compared against the baseline either. Refusing the pass is the - /// only safe reading: skipping it would classify the file as deleted. + /// A name that is not valid UTF-8 cannot be keyed into the scan, but it + /// cannot be in the baseline or on Drive either, so leaving it out reads as + /// the deletion of nothing. It used to fail the pass (B196). #[test] - fn a_non_utf8_name_fails_the_scan() { + fn a_non_utf8_name_is_left_out_and_reported() { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt as _; let root = sync_test_dir("scan-non-utf8"); std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap(); + std::fs::write(root.join("good.txt"), b"y").unwrap(); - let rules = rules_for(&[]); - let writing = HashSet::new(); - let mut out = HashMap::new(); - let mut progress = no_progress(); - let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress); + let result = scan(&root); std::fs::remove_dir_all(&root).unwrap(); - let error = result.expect_err("a name the scan cannot key must abort the pass"); - assert!(error.contains("UTF-8"), "{error}"); + let (out, unscanned) = result.unwrap(); + assert_eq!(sorted_keys(&out), vec!["good.txt"]); + let report = unscanned.report(); + assert_eq!(report.len(), 1); + assert!(report[0].contains("UTF-8"), "{report:?}"); } /// The complement: a scan that *can* read everything reports everything, so @@ -2670,9 +3134,14 @@ mod tests { let writing = HashSet::new(); let mut out = HashMap::new(); let mut seen: Vec = Vec::new(); - let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| { - seen.push(rel.to_string()) - }); + let result = walk_local_tree( + &root, + &rules, + &writing, + &mut out, + &mut Unscanned::default(), + &mut |rel| seen.push(rel.to_string()), + ); std::fs::remove_dir_all(&root).unwrap(); result.unwrap(); diff --git a/crates/pdfs-fuse/src/sync/engine.rs b/crates/pdfs-fuse/src/sync/engine.rs index 688aeb0..7755b5f 100644 --- a/crates/pdfs-fuse/src/sync/engine.rs +++ b/crates/pdfs-fuse/src/sync/engine.rs @@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver) -> Option) { // Paths the watcher currently covers, mapped to their folder id, so an event // path can be resolved back to the folder it belongs to. - let watched: Mutex> = Mutex::new(Vec::new()); + let watched: Arc>> = Arc::new(Mutex::new(Vec::new())); let tx_events = core.sync_tx.clone(); + let watched_events = watched.clone(); let mut watcher = match notify::recommended_watcher(move |res: notify::Result| { let Ok(event) = res else { return }; @@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver) { if matches!(event.kind, notify::EventKind::Access(_)) { return; } - let _ = tx_events.send(SyncMsg::ReconcileAll); + let msg = event_target(&watched_events.lock(), &event.paths); + let _ = tx_events.send(msg); }) { Ok(w) => w, Err(e) => { @@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) { } } +/// The reconcile a watcher event asks for: the one mirror folder all its paths +/// lie in, so an edit in one folder does not walk every other one too. An event +/// that spans folders, or that no watched folder claims (a watch list a moment +/// out of date), falls back to every folder. +pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg { + let mut ids = paths.iter().map(|path| { + watched + .iter() + .filter(|(root, _)| path.starts_with(root)) + // The deepest root, should one mirror folder sit inside another. + .max_by_key(|(root, _)| root.components().count()) + .map(|(_, id)| *id) + }); + match ids.next() { + Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first), + _ => SyncMsg::ReconcileAll, + } +} + /// Bring the filesystem watches in line with the current mirror-folder set: /// watch newly-added folders, drop removed ones. +/// +/// The watch calls run on a copy, outside the lock: the watcher's event +/// callback takes the same lock, and a backend that hands a watch request to +/// its event thread and waits for the answer would deadlock against it. fn rewatch( core: &Core, watcher: &mut notify::RecommendedWatcher, @@ -274,7 +299,7 @@ fn rewatch( return; } }; - let mut have = watched.lock(); + let mut have = watched.lock().clone(); // Drop watches no longer wanted. have.retain(|(path, _)| { if want.iter().any(|(p, _)| p == path) { @@ -294,6 +319,7 @@ fn rewatch( Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"), } } + *watched.lock() = have; } // ---- reconcile ------------------------------------------------------------ diff --git a/crates/pdfs-fuse/src/sync/model.rs b/crates/pdfs-fuse/src/sync/model.rs index 81099ec..b1e3b42 100644 --- a/crates/pdfs-fuse/src/sync/model.rs +++ b/crates/pdfs-fuse/src/sync/model.rs @@ -98,6 +98,11 @@ pub(super) struct Outcome { pub(super) errors: usize, /// Files skipped because another process held them open for writing. pub(super) deferred: usize, + /// Paths the local scan could not read, left out of the pass. Not part of + /// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays + /// unreadable from one poll to the next, so the pass reports it once, when + /// the set changes, rather than on every poll. + pub(super) unscanned: Vec, } impl Outcome { diff --git a/crates/pdfs-fuse/src/sync/planner.rs b/crates/pdfs-fuse/src/sync/planner.rs index c918580..ce51994 100644 --- a/crates/pdfs-fuse/src/sync/planner.rs +++ b/crates/pdfs-fuse/src/sync/planner.rs @@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>( .collect() } +/// The paths a local scan left out because it could not read them: a folder +/// it has no permission for, an entry that vanished between the listing and +/// its `stat`, a name that is not UTF-8. +/// +/// A left-out path is not a deleted one. A pass drops everything an entry +/// covers from classification, exactly as it drops ignored paths, so a +/// baseline row under an unreadable folder is neither read as a local deletion +/// nor answered with a download into a folder the pass cannot write. Failing +/// the whole pass instead is what B55 did, and it left one root-owned folder +/// stopping every other file in the tree from syncing (B196). +#[derive(Default)] +pub(super) struct Unscanned { + covered: HashSet, + /// The subset worth telling the user about, with why: an entry that only + /// vanished mid-scan is not, since the next pass sees it settled. + unreadable: Vec, +} + +impl Unscanned { + /// Leave out `rel`, which vanished while the scan looked at it. + pub(super) fn vanished(&mut self, rel: String) { + self.covered.insert(rel); + } + + /// Leave out `rel`, which cannot be read for `reason`. + pub(super) fn unreadable(&mut self, rel: String, reason: &str) { + self.unreadable.push(format!("{rel} ({reason})")); + self.covered.insert(rel); + } + + /// Whether `rel` is a left-out path or lies under one. + pub(super) fn covers(&self, rel: &str) -> bool { + if self.covered.is_empty() { + return false; + } + let mut at = rel; + loop { + if self.covered.contains(at) { + return true; + } + match at.rfind('/') { + Some(i) => at = &at[..i], + None => return false, + } + } + } + + /// The unreadable paths with their reasons, sorted, for the activity feed. + pub(super) fn report(&self) -> Vec { + let mut lines = self.unreadable.clone(); + lines.sort_unstable(); + lines + } +} + +/// The baseline paths a pass can see: [`filter_baseline`], minus everything +/// under a path the scan could not read. This is the set [`guard_local_wipe`] +/// checks and the set a push pass reads deletions from, since a path outside +/// it is absent from the local walk by rule or by failure, never by loss. +pub(super) fn tracked_baseline<'a, B>( + baseline: &'a HashMap, + rules: &IgnoreRules, + unscanned: &Unscanned, +) -> HashMap { + let mut tracked = filter_baseline(baseline, rules); + tracked.retain(|rel, _| !unscanned.covers(rel)); + tracked +} + +/// The baseline paths a push pass has to trash on Drive: tracked, and gone +/// from the local walk. Shallowest first, so a trashed folder takes its +/// children with it. +/// +/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading +/// it raw trashed every synced path that later became ignored, because the walk +/// skips those, and the switch to on-demand that the push pass runs for then +/// deleted the local copy too (B197). +pub(super) fn local_deletions( + tracked: &HashMap, + local: &HashMap, +) -> Vec { + let mut missing: Vec = tracked + .keys() + .filter(|rel| !local.contains_key(*rel)) + .cloned() + .collect(); + missing.sort_by_key(|p| p.matches('/').count()); + missing +} + /// Refuse to run a pass whose local side has vanished in its entirety. /// /// When every baseline path is absent locally, the likely cause is an unavailable diff --git a/crates/pdfs-fuse/src/transfers.rs b/crates/pdfs-fuse/src/transfers.rs index 078f020..3e461b1 100644 --- a/crates/pdfs-fuse/src/transfers.rs +++ b/crates/pdfs-fuse/src/transfers.rs @@ -46,6 +46,8 @@ struct JobEntry { detail: Mutex, done: AtomicU64, total: AtomicU64, + /// Housekeeping that moves nothing; see [`JobItem::background`]. + background: bool, } /// The set of transfers and jobs currently in flight. Cloned `Arc`-style across @@ -160,12 +162,25 @@ impl TransferRegistry { /// that carries its progress and deregisters the job when dropped. The job /// starts indeterminate; give it a `total` once one is known. pub fn begin_job(self: &Arc, title: impl Into) -> JobGuard { + self.register_job(title.into(), false) + } + + /// [`TransferRegistry::begin_job`] for housekeeping the user did not ask + /// for and that changes nothing in their files, such as an index rebuild or + /// a cache check. It is reported, but front ends don't call the drive busy + /// over it. + pub fn begin_background_job(self: &Arc, title: impl Into) -> JobGuard { + self.register_job(title.into(), true) + } + + fn register_job(self: &Arc, title: String, background: bool) -> JobGuard { let id = self.next.fetch_add(1, Ordering::Relaxed); let entry = Arc::new(JobEntry { - title: title.into(), + title, detail: Mutex::new(String::new()), done: AtomicU64::new(0), total: AtomicU64::new(0), + background, }); self.jobs.lock().insert(id, entry.clone()); JobGuard { @@ -191,6 +206,7 @@ impl TransferRegistry { detail: e.detail.lock().clone(), done: e.done.load(Ordering::Relaxed), total: e.total.load(Ordering::Relaxed), + background: e.background, } }) .collect() diff --git a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-menu-symbolic.svg b/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-menu-symbolic.svg deleted file mode 100644 index 5796a0f..0000000 --- a/crates/pdfs-gui/resources/icons/scalable/actions/pdfs-menu-symbolic.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml index 1026a5a..80b0b1b 100644 --- a/crates/pdfs-gui/resources/pdfs.gresource.xml +++ b/crates/pdfs-gui/resources/pdfs.gresource.xml @@ -14,7 +14,6 @@ scalable/actions/pdfs-details-pane-symbolic.svg scalable/actions/pdfs-link-symbolic.svg scalable/actions/pdfs-map-symbolic.svg - scalable/actions/pdfs-menu-symbolic.svg scalable/actions/pdfs-people-symbolic.svg scalable/actions/pdfs-share-symbolic.svg scalable/actions/pdfs-sync-symbolic.svg diff --git a/crates/pdfs-gui/resources/style.css b/crates/pdfs-gui/resources/style.css index 957d114..a21039a 100644 --- a/crates/pdfs-gui/resources/style.css +++ b/crates/pdfs-gui/resources/style.css @@ -18,7 +18,9 @@ .sidebar-status image.warning { color: var(--warning-color); } .sidebar-status image.error { color: var(--error-color); } .sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; } -.sidebar-account { padding: 4px 4px 4px 8px; } +/* The account row is the main menu's button: a row, not a pushbutton, so + * the name keeps the text weight. */ +button.sidebar-account { padding: 4px 8px; font-weight: normal; } /* A count of what waits on a page, at the end of its sidebar row */ .sidebar-badge { min-width: 10px; @@ -48,6 +50,9 @@ .badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); } .badge-issue { color: var(--error-color); } .browser-statusbar { background-color: alpha(currentColor, 0.025); } +/* The folder on screen, last in the breadcrumb trail: a flat button's side + * padding, so the "›" before it is spaced like the ones between buttons. */ +.crumb-current { padding: 0 10px; } scale.browser-status-meter trough { min-width: 104px; min-height: 6px; } .bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); } .dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; } diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 9bf08a5..7f8f67e 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -34,6 +34,7 @@ use pages::verify::*; use reload::{LoadTicket, Loader, replace_items}; use theme::{load_resources, proton_theme_active, set_proton_theme}; use widgets::compat::*; +use widgets::crumb::*; use widgets::details::*; use widgets::file_list::*; use widgets::menu::*; @@ -830,7 +831,6 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) { let header = adw::HeaderBar::new(); header.set_title_widget(Some(&brand)); - header.pack_end(&build_primary_menu()); let scroll = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::Never) @@ -1153,25 +1153,7 @@ fn toast_failure(ui: &Rc, what: &str, message: &str, kind: ErrorKind) { } } -/// The sidebar's primary (hamburger) menu: the app-level entries that don't -/// belong on any one page. -fn build_primary_menu() -> gtk4::MenuButton { - let menu = gio::Menu::new(); - menu.append(Some(&gettext("Preferences")), Some("win.preferences")); - menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts")); - menu.append( - Some(&gettext("About Proton Drive for Linux")), - Some("win.about"), - ); - gtk4::MenuButton::builder() - .icon_name("pdfs-menu-symbolic") - .tooltip_text(gettext("Main menu")) - .primary(true) - .menu_model(&menu) - .build() -} - -/// Back the primary and account menus' entries with window actions. +/// Back the main menu's entries with window actions. fn install_window_actions(ui: &Rc, window: &adw::ApplicationWindow) { let preferences = gio::SimpleAction::new("preferences", None); let ui_prefs = ui.clone(); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index d9c4e41..c7d2521 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -1220,9 +1220,7 @@ pub(crate) fn repaint_crumb(ui: &Rc, path: &str) { )); let mut acc = String::new(); for (i, seg) in segments.iter().enumerate() { - let sep = gtk4::Label::new(Some("›")); - sep.add_css_class("dim-label"); - ui.browser.crumb.append(&sep); + ui.browser.crumb.append(&crumb_separator()); acc = if acc.is_empty() { seg.to_string() } else { @@ -1237,12 +1235,7 @@ pub(crate) fn repaint_crumb(ui: &Rc, path: &str) { /// flat button that navigates to `target` (clearing any active search first). pub(crate) fn crumb_node(ui: &Rc, label: &str, target: &str, current: bool) -> gtk4::Widget { if current { - let l = gtk4::Label::builder() - .label(label) - .ellipsize(gtk4::pango::EllipsizeMode::Start) - .build(); - l.add_css_class("heading"); - return l.upcast(); + return crumb_current(label); } let button = gtk4::Button::builder().label(label).build(); button.add_css_class("flat"); diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index d12d56d..cdd28c7 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc, dev: &DeviceInfo) -> adw::ActionRow { menu.item(&gettext("Restore to This Computer…"), move || { prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone()))) }); + let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone()); + menu.item(&gettext("Move to This Computer…"), move || { + prompt_migrate_device(&ui_c, &uid_c, &name_c) + }); menu.section(); let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone()); menu.item(&gettext("Rename…"), move || { @@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc, nav: &[(String, String)]) { nav.is_empty(), )); for (i, (_, name)) in nav.iter().enumerate() { - let sep = gtk4::Label::new(Some("›")); - sep.add_css_class("dim-label"); - crumb.append(&sep); + crumb.append(&crumb_separator()); crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len())); } } @@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc, nav: &[(String, String)]) { /// that goes back up to `depth` levels below the device list. fn device_crumb_node(ui: &Rc, label: &str, depth: usize, current: bool) -> gtk4::Widget { if current { - let l = gtk4::Label::builder() - .label(label) - .ellipsize(gtk4::pango::EllipsizeMode::Start) - .build(); - l.add_css_class("heading"); - return l.upcast(); + return crumb_current(label); } let button = gtk4::Button::builder().label(label).build(); button.add_css_class("flat"); @@ -1096,6 +1093,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc, uid: &str, name: &str) { dialog.present(win.as_ref()); } +/// Ask before moving another computer's folders into this computer's backup, +/// then offer them for syncing here. +/// +/// Destructive-looking on purpose: the other computer keeps its device but +/// loses its folders, and a copy of Proton Drive still running there would +/// watch them vanish. +pub(crate) fn prompt_migrate_device(ui: &Rc, uid: &str, name: &str) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading(gettext("Move Backup to This Computer?")) + // Translators: {name} is the name of the other computer whose folders move to this one. + .body(gettext_f( + "The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.", + &[("name", name)], + )) + .build(); + dialog.add_response("cancel", &gettext("Cancel")); + dialog.add_response("move", &gettext("Move Here")); + dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let ui = ui.clone(); + let uid = uid.to_string(); + // Translators: {name} is the name of the computer whose folders moved to this one. + let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]); + dialog.connect_response(None, move |_, resp| { + if resp != "move" { + return; + } + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::MigrateDevice { uid: uid.clone() }, + ); + let (ui, done) = (ui.clone(), done.clone()); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + let failed = gettext("Couldn't move that backup"); + match result { + Ok(Ok(Response::Ok { .. })) => { + reload_sync_pages(&ui); + toast(&ui, &done); + // The folders are this computer's now, so its own restore + // picker is where the user chooses their local paths. + prompt_restore_folders(&ui, None); + } + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, &failed, &message, kind) + } + _ => toast_error( + &ui, + &failed, + &gettext("The Proton Drive service didn't respond."), + ), + } + }); + }); + dialog.present(win.as_ref()); +} + /// Run a mutation raised from Computers or Locations and reload the page on success. pub(crate) fn run_devices_mutation(ui: &Rc, req: Request, done: &str, failed: &str) { ui.busy_begin(); diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 197d96b..efc1352 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc, nav: &[(String, String)]) { nav.is_empty(), )); for (i, (_, name)) in nav.iter().enumerate() { - let sep = gtk4::Label::new(Some("›")); - sep.add_css_class("dim-label"); - crumb.append(&sep); + crumb.append(&crumb_separator()); crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len())); } } @@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc, nav: &[(String, String)]) { /// that goes back up to `depth` folders below the top level. fn shared_crumb_node(ui: &Rc, label: &str, depth: usize, current: bool) -> gtk4::Widget { if current { - let l = gtk4::Label::builder() - .label(label) - .ellipsize(gtk4::pango::EllipsizeMode::Start) - .build(); - l.add_css_class("heading"); - return l.upcast(); + return crumb_current(label); } let button = gtk4::Button::builder().label(label).build(); button.add_css_class("flat"); diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 225d57c..c5e2eb6 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc, entries: &[DirEntry]) { let uids: Vec = entries.iter().map(|e| e.uid.clone()).collect(); dialog.connect_response(Some("stop"), move |_, _| { let done = done.clone(); + let stopped = uids.clone(); run_requests( &ui_c, uids.iter() @@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc, entries: &[DirEntry]) { .collect(), gettext_noop("Couldn't stop sharing"), move |ui| { + forget_shared_by_me(ui, &stopped); reload_listing(ui); toast(ui, &done); }, @@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc, entries: &[DirEntry]) { dialog.present(ui_window(ui).as_ref()); } +/// Take items whose sharing just stopped off the page now, rather than when +/// the reload that confirms it comes back: that reload asks after every share +/// in turn and takes a while with many of them. +fn forget_shared_by_me(ui: &Rc, uids: &[String]) { + let items = { + let mut items = ui.shared_by_me.items.borrow_mut(); + items.retain(|item| !uids.contains(&item.uid)); + items.clone() + }; + repaint_shared_by_me(ui, &items); +} + /// Open a shared item: a folder in My Files, a file the way My Files would. fn open_shared_by_me(ui: &Rc, entry: &DirEntry) { if entry.is_dir { diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index adaece5..fab7142 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets { .hexpand(true) .ellipsize(gtk4::pango::EllipsizeMode::Middle) .build(); + // The app's one menu, on the account row the way Proton's own apps keep it: + // app-wide entries first, the account's last. The whole row opens it, and + // F10 too, as the primary menu. + let app_section = gio::Menu::new(); + app_section.append(Some(&gettext("Preferences")), Some("win.preferences")); + app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts")); + app_section.append( + Some(&gettext("About Proton Drive for Linux")), + Some("win.about"), + ); + let account_section = gio::Menu::new(); + account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out")); let account_menu = gio::Menu::new(); - account_menu.append(Some(&gettext("Preferences")), Some("win.preferences")); - let sign_out = gio::Menu::new(); - sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out")); - account_menu.append_section(None, &sign_out); - let account_button = gtk4::MenuButton::builder() - .icon_name("view-more-symbolic") - .tooltip_text(gettext("Account")) + account_menu.append_section(None, &app_section); + account_menu.append_section(None, &account_section); + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + row.append(&avatar); + row.append(&account_name); + row.append(>k4::Image::from_icon_name("view-more-symbolic")); + let account = gtk4::MenuButton::builder() + .child(&row) + .tooltip_text(gettext("Main menu")) .menu_model(&account_menu) - .valign(gtk4::Align::Center) + .direction(gtk4::ArrowType::Up) + .primary(true) .build(); - account_button.add_css_class("flat"); - let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + account.add_css_class("flat"); account.add_css_class("sidebar-account"); - account.append(&avatar); - account.append(&account_name); - account.append(&account_button); let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10); footer.add_css_class("sidebar-footer"); diff --git a/crates/pdfs-gui/src/app/widgets/crumb.rs b/crates/pdfs-gui/src/app/widgets/crumb.rs new file mode 100644 index 0000000..f99fbdf --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/crumb.rs @@ -0,0 +1,21 @@ +use crate::*; + +/// The "›" between two breadcrumb segments. +pub(crate) fn crumb_separator() -> gtk4::Label { + let sep = gtk4::Label::new(Some("›")); + sep.add_css_class("dim-label"); + sep +} + +/// The last breadcrumb segment, the folder on screen: a heading rather than a +/// button. It carries a flat button's side padding (`.crumb-current`), so the +/// "›" before it sits as far from it as from the button before. +pub(crate) fn crumb_current(label: &str) -> gtk4::Widget { + let l = gtk4::Label::builder() + .label(label) + .ellipsize(gtk4::pango::EllipsizeMode::Start) + .build(); + l.add_css_class("heading"); + l.add_css_class("crumb-current"); + l.upcast() +} diff --git a/crates/pdfs-gui/src/app/widgets/file_list.rs b/crates/pdfs-gui/src/app/widgets/file_list.rs index 3007ec9..dac98cf 100644 --- a/crates/pdfs-gui/src/app/widgets/file_list.rs +++ b/crates/pdfs-gui/src/app/widgets/file_list.rs @@ -132,8 +132,13 @@ impl FileList { // and the grid collapses to a single column. Allowing a mid-word // break is what keeps the two-line-then-ellipsis budget below // enforceable for *every* name rather than only the ones that - // happen to have spaces. + // happen to have spaces. Pango marks such a break with a hyphen + // that is not in the name ("SteamSetup.e-xe"); a file name is + // not prose, so break it bare, as Nautilus does. + let no_hyphens = gtk4::pango::AttrList::new(); + no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false)); let label = gtk4::Label::builder() + .attributes(&no_hyphens) .ellipsize(gtk4::pango::EllipsizeMode::End) .justify(gtk4::Justification::Center) .max_width_chars(13) diff --git a/crates/pdfs-gui/src/app/widgets/menu.rs b/crates/pdfs-gui/src/app/widgets/menu.rs index 74dc19f..0e55a15 100644 --- a/crates/pdfs-gui/src/app/widgets/menu.rs +++ b/crates/pdfs-gui/src/app/widgets/menu.rs @@ -128,20 +128,34 @@ impl ActionMenu { } } -/// Where a menu opened over `anchor` should live in the widget tree: the -/// outermost grid or list view around it, or `anchor` itself. CSS descendant -/// selectors reach through a popover to its parent, and themes style the -/// insides of list items (`gridview > child box { margin: 12px }` in -/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide -/// margin around every section. Under the view itself it is no item's child. +/// Where a menu opened over `anchor` should live in the widget tree. +/// +/// Not inside a list item: CSS descendant selectors reach through a popover to +/// its parent, and themes style the insides of list items (`gridview > child +/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those +/// rules up and puts a wide margin around every section. So the search starts +/// at the outermost grid or list view around `anchor`. +/// +/// Not on a widget without a layout manager either: a popover is sized and +/// placed again when its parent's layout manager allocates it. The list views +/// and `ScrolledWindow` have none, so a menu parented there kept the size it was +/// first given, often too small, and opened scrolling. The host is the first +/// widget from there up that has one. fn popover_host(anchor: >k4::Widget) -> gtk4::Widget { - let mut host = anchor.clone(); + let mut start = anchor.clone(); let mut widget = anchor.parent(); while let Some(w) = widget { widget = w.parent(); if w.is::() || w.is::() || w.is::() { - host = w; + start = w; } } - host + let mut host = Some(start.clone()); + while let Some(w) = host { + if w.layout_manager().is_some() { + return w; + } + host = w.parent(); + } + start } diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs index 3e4d1a4..e0f5620 100644 --- a/crates/pdfs-gui/src/app/widgets/mod.rs +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod compat; +pub(crate) mod crumb; pub(crate) mod details; pub(crate) mod file_list; pub(crate) mod menu; diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index d0e3c4e..8da0459 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -127,6 +127,26 @@ impl Drop for Busy { } } +/// A section's action, such as Send Invitations. It goes under the section's +/// rows, at their right edge, rather than in a row of its own: a row in a boxed +/// list reads as a setting, and a button alone in one looks misplaced. +fn group_button(label: &str) -> gtk4::Button { + let button = gtk4::Button::builder() + .label(label) + .halign(gtk4::Align::End) + .margin_top(12) + .build(); + button.add_css_class("suggested-action"); + button +} + +/// A section's only row while the share is being read. +fn loading_row() -> adw::ActionRow { + let row = adw::ActionRow::builder().title(gettext("Loading…")).build(); + row.add_prefix(&spinner()); + row +} + /// How an open Share dialog addresses its node. /// /// A node the browser is showing has a mountpoint-relative path. A node reached @@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { .selected(0) .valign(gtk4::Align::Center) .build(); - let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build(); - role_wrap.add_suffix(&role_drop); + // The role goes with the addresses it applies to, at the end of their row. + role_drop.set_tooltip_text(Some(&gettext("Role"))); + email_row.add_suffix(&role_drop); let message_row = adw::EntryRow::builder() .title(gettext("Message (optional)")) .build(); - let invite_btn = gtk4::Button::builder() - .label(gettext("Send Invitations")) - .halign(gtk4::Align::End) - .margin_top(6) - .build(); - invite_btn.add_css_class("suggested-action"); + let invite_btn = group_button(&gettext("Send Invitations")); invite_btn.set_sensitive(false); - let invite_wrap = adw::PreferencesRow::builder() - .activatable(false) - .child(&invite_btn) - .build(); invite_group.add(&email_row); - invite_group.add(&role_wrap); invite_group.add(&message_row); - invite_group.add(&invite_wrap); + invite_group.add(&invite_btn); let people = adw::PreferencesGroup::builder() .title(gettext("People with access")) @@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { .title(gettext("Public link")) .build(); - let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); - content.set_margin_top(12); - content.set_margin_bottom(12); - content.set_margin_start(12); - content.set_margin_end(12); - content.append(&invite_group); - content.append(&people); - content.append(&link_group); - let clamp = adw::Clamp::builder().child(&content).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .hscrollbar_policy(gtk4::PolicyType::Never) - .child(&clamp) - .build(); - toolbar.set_content(Some(&scroll)); + // Until the share is read, each section says so rather than standing + // empty under its title. + let people_loading = loading_row(); + people.add(&people_loading); + let link_loading = loading_row(); + link_group.add(&link_loading); + + let content = adw::PreferencesPage::new(); + content.add(&invite_group); + content.add(&people); + content.add(&link_group); + toolbar.set_content(Some(&content)); let dialog = adw::Dialog::builder() .title(pgettext("verb", "Share")) @@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { target, people, link_group, - people_rows: RefCell::new(Vec::new()), - link_rows: RefCell::new(Vec::new()), + people_rows: RefCell::new(vec![people_loading.upcast()]), + link_rows: RefCell::new(vec![link_loading.upcast()]), }); // Check the addresses as they are typed: a malformed one marks the row as @@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc) { repaint_share_link(&state, link.as_ref()); } Ok(Ok(Response::Error { message, .. })) => { + share_dialog_unread(&state); toast_error(&state.ui, &gettext("Couldn't load sharing"), &message) } - _ => toast_error( - &state.ui, - &gettext("Couldn't load sharing"), - &gettext("The Proton Drive service didn't respond."), - ), + _ => { + share_dialog_unread(&state); + toast_error( + &state.ui, + &gettext("Couldn't load sharing"), + &gettext("The Proton Drive service didn't respond."), + ) + } } }); } +/// The share couldn't be read: what the sections showed (rows still loading, +/// or ones that may now be stale) gives way to one line that says so. +fn share_dialog_unread(state: &Rc) { + for row in state.people_rows.borrow_mut().drain(..) { + state.people.remove(&row); + } + for row in state.link_rows.borrow_mut().drain(..) { + state.link_group.remove(&row); + } + let row = dim_row(&gettext("Couldn't load sharing")); + state.people.add(&row); + state.people_rows.borrow_mut().push(row.upcast()); +} + /// Rebuild the "People with access" rows from a fresh share listing. pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntry]) { for row in state.people_rows.borrow_mut().drain(..) { @@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntr return; } for entry in entries { + // A member is the plain case and needs no word; an invitation says + // it is still waiting on the other side. let subtitle = match entry.kind { - ShareEntryKind::Member => gettext("Member"), - ShareEntryKind::ProtonInvite => gettext("Invited (pending)"), - ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"), + ShareEntryKind::Member => String::new(), + ShareEntryKind::ProtonInvite => gettext("Invitation pending"), + ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"), }; let row = adw::ActionRow::builder() .title(&entry.email) .subtitle(&subtitle) .build(); + row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true)); // External invites can't have their role changed; show it read-only. // Members and Proton invites get a role dropdown. @@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi .build(); let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build(); expiry_row.add_suffix(&expiry_drop); - let create = gtk4::Button::builder() - .label(gettext("Create Public Link")) - .halign(gtk4::Align::End) - .margin_top(6) - .build(); - create.add_css_class("suggested-action"); - let create_wrap = adw::PreferencesRow::builder() - .activatable(false) - .child(&create) - .build(); + let create = group_button(&gettext("Create Public Link")); // Enter in the password field creates the link. let btn = create.clone(); @@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi state.link_group.add(&role_row); state.link_group.add(&pw_row); state.link_group.add(&expiry_row); - state.link_group.add(&create_wrap); + state.link_group.add(&create); rows.push(role_row.upcast()); rows.push(pw_row.upcast()); rows.push(expiry_row.upcast()); - rows.push(create_wrap.upcast()); + rows.push(create.upcast()); } } *state.link_rows.borrow_mut() = rows; diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 27ae2d0..7e6cd8b 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -88,8 +88,9 @@ struct DriveState { /// Summarise a work snapshot into one menu line, or empty when idle. A single /// transfer names the file and its percentage; several collapse to counts so the /// menu stays one line regardless of queue depth. With nothing moving bytes, a -/// running job speaks for itself — a scan or an index rebuild is still "busy", -/// and the tray saying nothing there reads as "finished". +/// running job speaks for itself — a scan or an index rebuild is still work +/// going on, and the menu saying nothing there reads as "finished". The icon is +/// [`busy`]'s call, not this line's. fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String { match items { [] => match jobs.first() { @@ -143,12 +144,12 @@ struct DriveTray { fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { match send(socket, &Request::Status) { Ok(status @ Response::Status { .. }) => { - // Same daemon is up, so a cheap follow-up request gives the sync line. - let sync = match send(socket, &Request::GetQueueStatus) { - Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs), - _ => String::new(), + // Same daemon is up, so a cheap follow-up request says what moves. + let (items, jobs) = match send(socket, &Request::GetQueueStatus) { + Ok(Response::Transfers { items, jobs }) => (items, jobs), + _ => (Vec::new(), Vec::new()), }; - status_state(&status, sync, default_mountpoint) + status_state(&status, &items, &jobs, default_mountpoint) } // Socket answered but with something unexpected — treat as up but odd. Ok(_) => unexpected_reply(default_mountpoint), @@ -217,9 +218,14 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState { } } -/// The state a running daemon's [`Response::Status`] describes, with `sync` -/// saying what it is moving right now. -fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState { +/// The state a running daemon's [`Response::Status`] describes, with `items` +/// and `jobs` saying what it is working on right now. +fn status_state( + status: &Response, + items: &[TransferItem], + jobs: &[JobItem], + default_mountpoint: &Path, +) -> DriveState { let Response::Status { mountpoint, pinned, @@ -239,7 +245,7 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D paused, failing_ops, online, - queued.is_some() || !sync.is_empty(), + queued.is_some() || busy(items, jobs), ); DriveState { line: match (online, queued) { @@ -278,11 +284,19 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D mounted: true, paused, mountpoint: PathBuf::from(mountpoint), - sync, + sync: sync_line(items, jobs), failing: failing_ops, } } +/// Whether the daemon is changing something: bytes moving, or a job the user +/// is waiting on. A mirror folder's routine scan and the like are background +/// jobs; they still get the menu line, but the icon spinning every two minutes +/// for a scan that finds nothing reads as constant syncing. +fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool { + !items.is_empty() || jobs.iter().any(|j| !j.background) +} + /// The phase a running mount is in. A pause is the user's own doing and says /// so first; failures outrank everything else the daemon reports. fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase { @@ -547,11 +561,7 @@ fn main() -> Result<()> { heard.take(feed); } let st = match &heard.status { - Some(status) => status_state( - status, - sync_line(&heard.items, &heard.jobs), - &default_mountpoint, - ), + Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint), // Lost the daemon: ask once, which describes the sign-in instead. None => poll_state(&socket, &default_mountpoint), }; @@ -596,6 +606,39 @@ mod tests { assert_eq!(phase_of(false, 0, true, false), Phase::Synced); } + fn job(title: &str, background: bool) -> JobItem { + JobItem { + title: title.to_string(), + detail: String::new(), + done: 0, + total: 0, + background, + } + } + + #[test] + fn a_routine_scan_is_not_busy_but_a_sync_is() { + assert!(!busy(&[], &[])); + assert!(!busy(&[], &[job("Checking Documents", true)])); + assert!(busy( + &[], + &[ + job("Checking Documents", true), + job("Syncing Photos", false) + ] + )); + assert!(busy(&[], &[job("Uploading files", false)])); + } + + #[test] + fn a_job_from_an_older_daemon_counts_as_busy() { + let job: JobItem = serde_json::from_str( + r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#, + ) + .unwrap(); + assert!(!job.background); + } + #[test] fn the_issues_item_counts_in_the_singular_and_plural() { assert_eq!(issues_label(1), "1 issue — View"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 854a20b..2f8aa2e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -310,8 +310,10 @@ its changes go through the queue like My files. A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most 30 s). Passes are serialized per daemon. A pass: -1. **Walks the local tree.** Any `readdir`, metadata or permission failure marks the scan - incomplete, and an incomplete scan cannot delete anything. +1. **Walks the local tree.** A path the user may not read, an entry that vanishes mid-scan and a + name that is not UTF-8 are left out, and the pass drops everything under them, as it drops + ignored paths, so a left-out path is never read as deleted. An unreadable root and any other + I/O error fail the pass. 2. **Walks the remote tree.** 3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass. 4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the @@ -377,6 +379,12 @@ Restore lists the device's folders, proposes a local path from the profile, and adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty baseline. The user side is in [RECOVERY.md](RECOVERY.md). +`Core::migrate_device` moves another device's top-level folders under this machine's device root. +Device roots are separate shares on the main volume, so each folder is one server-side move that +keeps its uid. A name already taken here gets the other device's name as a suffix. A synced folder +that already tracked one of them keeps its `remote_uid` and only has its `remote_share_id` +re-pointed. The moved folders are then offered by the ordinary restore. + --- ## 8. Control protocol diff --git a/docs/BUGS.md b/docs/BUGS.md index 42e95e8..93cca81 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -81,6 +81,7 @@ original wording. | Entry | Title | What is left | |---|---|---| +| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it | | [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions | | [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured | | [B71](#b71--conflict-sweep-is-not-production-ready-auto-trash-loop-crit-13) | Conflict sweep is not production-ready (auto-trash loop, CRIT-13) | Items 5 to 10 of the entry | @@ -100,6 +101,9 @@ a live account since. | Entry | Title | |---|---| +| [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive | +| [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored | +| [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing | | [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue | | [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive | | [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive | @@ -298,6 +302,121 @@ a live account since. Newest first. +### B199 — A stopped share stays on the Shared by me page for minutes + +**Status:** Partly fixed. The share itself is still not deleted. +**Found:** 2026-10-09, reported from the app: after **Stop Sharing**, the row stayed, and an +older share showed with no people and no link. +**Where:** `crates/pdfs-fuse/src/sharing.rs`, `Core::stop_sharing_for_uid` and +`Core::list_shared_by_me`; `crates/pdfs-gui/src/app/pages/shared_by_me.rs`, `prompt_stop_sharing` + +**Repro:** Share a folder with a person, then choose **Stop Sharing…** on the Shared by me page. +The row stays, now with "—" for people and link, until a reload some minutes later. + +**Cause.** The Rust SDK has no call that deletes a share. Stop Sharing removes the public link, the +invitations and the members one by one, and leaves an empty share behind. Drive deletes an empty +share on its own, but only after a while, and until then `GET v2/volumes/{vid}/shares` still lists +it. The JS SDK ends `unshareNode` with `DELETE drive/shares/{shareId}?Force=1` for exactly this +reason. + +**Fix.** The daemon leaves a share with no member, no invitation and no link out of the Shared by +me listing, unless one of those lookups failed. The app also drops a stopped item from the page as +soon as the request succeeds, before the confirming reload. Left: a `delete_share` call in +`proton-drive-rs`, made at the end of `stop_sharing_for_uid`, so the share is really gone. + +**Test:** none automated; the listing needs a live account. + +--- + +### B198 — Switching to online-only deletes local files that never reached Drive + +**Status:** Fixed (unverified). +**Found:** 2026-10-08, reading the switch while fixing B196. +**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`; +`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced` + +**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**. +`.git/` is gone from the disk and was never on Drive. + +**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the +last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored +by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for +writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted +all of those. + +**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against +the baseline, as a move out of a mirror folder already does, and refuses when something is only on +this disk. The message names the path; an ignored one is called excluded from sync. A tracked file +deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row +stays for good and would hold the switch forever. + +**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`, +`an_ignored_folder_only_this_disk_has_holds_the_switch_back`, +`a_deleted_ignored_path_does_not_hold_the_switch_back`, +`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`, +`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch +itself has not been driven against a live account. + +--- + +### B197 — Switching to online-only trashes synced files that became ignored + +**Status:** Fixed (unverified). +**Found:** 2026-10-08, reading the push pass while fixing B196. +**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`, +`local_deletions` + +**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch +the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the +local copy. + +**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in +the local walk". The walk skips ignored paths, so every synced path that later became ignored read +as deleted. A full pass does not do this: it drops ignored paths from classification, which is +what the user guide promises ("Ignoring never deletes anything"). + +**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks: +without ignored paths, and without paths the scan could not read (B196). + +**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and +`local_deletions_come_out_shallowest_first`. + +--- + +### B196 — One unreadable folder stops the whole synced folder from syncing + +**Status:** Fixed (unverified). The reporter has not run the fix yet. +**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced +`~/Documents` held a restic cache a Docker container had created, which the user may not read. +Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission +denied`, and nothing else in `Documents` synced. +**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`, +`Unscanned` + +**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync. + +**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read +as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is +not transient: the pass failed on every poll, for good. The same held for a file deleted between +the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8. + +**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it +from classification, the wipe guard and the push pass's deletions, as it already did for ignored +paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists +but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O +error, still fails the pass. The activity feed names the unreadable paths once each time the set +changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround +and still silences the report. + +**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`, +`a_listable_but_unsearchable_folder_is_left_out_whole`, +`a_path_the_scan_left_out_is_never_a_local_deletion`, +`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and +`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and +`an_absent_root_fails_the_scan` still hold. + +--- + ### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue **Status:** Fixed (unverified). On 2026-10-08 the reporter confirmed that the app survives, but @@ -4831,6 +4950,10 @@ subdirectory holding a file, asserting the error names the subtree), strictness must not cost a working pass, so a healthy tree still reports every entry and still skips symlinks by design. +B196 later relaxed this: a path the user may not read, an entry that vanishes mid-scan and a name +that is not UTF-8 are left out of the pass instead of failing it, and the pass drops everything +under them from classification, so the B55 guarantee holds without the wedge. + Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`. Both are races that would need a filesystem shim to force; the code path they take is the same `symlink_metadata` failure the permission test drives. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f2bec6..3ecdd36 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,47 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing. ([#29](https://github.com/narrrl/proton-drive-linux/issues/29)) +## [3.2.0] — 2026-10-08 + +Schema: **42**, unchanged. + +### Fixed +- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not + read, such as a cache a container created, failed every pass, so nothing else in the synced + folder reached Drive. Such a folder is now left out and named once in the activity feed, and + everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no + longer fail the pass either. + ([#33](https://github.com/narrrl/proton-drive-linux/issues/33)) +- **Switching a folder to online-only could delete files that were never on Drive.** Ignored + folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were + deleted with the local copy. The switch now refuses and names what is only on this computer. +- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path + to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as + the user guide says. +- **A stopped share stayed on the Shared by me page for minutes.** Stop Sharing now takes it off + the page at once, and an emptied share no longer shows with no people and no link. +- **The tray icon showed syncing every two minutes.** The routine check of a synced folder no + longer counts as syncing. Only work that changes files spins the icon. An edit in one synced + folder also no longer rescans all the others. +- **Right-click menus sometimes opened too short and had to be scrolled.** +- **The Share dialog looked unfinished.** Its buttons now sit under their sections instead of in a + row of their own. The role is chosen beside the addresses, people show with avatars, and both + sections show that they are loading. +- **Breadcrumbs spaced the "›" unevenly, and grid file names were broken with hyphens they do not + have.** + +### Changed +- **One main menu.** The menu button beside "Proton Drive" is gone. Preferences, Keyboard + Shortcuts, About and Sign Out are all in the account row at the bottom of the sidebar. F10 opens + it. + +### Added +- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page, + or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old + Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The + folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the + account, without those folders. + ## [3.1.2] — 2026-10-08 Schema: **42**, unchanged. @@ -1671,6 +1712,8 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated by a new POSIX compliance suite for the filesystem. +[3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0 + [3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2 [3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1 diff --git a/docs/CLI.md b/docs/CLI.md index 2720856..2c33d9a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available. | `pdfs devices rename UID NAME` | Rename a computer. | | `pdfs devices rm UID` | Remove a computer and its backup from the account. | | `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. | +| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). | ## Trash diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 9a009b8..8d0b47c 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -133,6 +133,29 @@ pdfs sync restore --device UID The folders stay part of the other computer's backup and sync in both directions. This machine does not take over that computer's backup. +### Moving another computer's backup here + +To switch computers for good, for example from a Windows PC to this Linux machine, move the old +computer's folders into this computer's backup instead. Use **Move to This Computer…** on that +computer's row, or: + +```bash +pdfs devices list # find the old computer's UID +pdfs devices migrate UID +``` + +Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used +twice. A folder whose name this computer already uses gets the old computer's name added, for +example `Documents (DESKTOP-1)`. The restore picker then opens, as in +[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here. + +The old computer stays in your account, without those folders. Unlike +[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup. + +> [!WARNING] +> Stop Proton Drive on the old computer first. A client still running there sees its folders +> disappear, and may upload them again as new. + ## 3. How the restore works This section is for the curious and for anyone debugging a restore. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7848828..e941b52 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -96,7 +96,9 @@ app and in the web app. Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount of the folder, so the files stay visible but take no disk space until opened. Switch with the **Synced / Online only** switch on the folder's card, or `pdfs sync mode ondemand` and back -with `pdfs sync mode mirror`. `pdfs locations` lists every local place the service occupies. +with `pdfs sync mode mirror`. The switch waits until everything in the folder is on Proton +Drive, and refuses while the folder holds something that never goes there: an ignored path such +as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies. ### Moving between synced folders and My files @@ -146,6 +148,9 @@ places, and both apply: Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is simply no longer tracked, and its copy on Proton Drive stays. +A folder you may not read, such as a cache a container created as root, is skipped the same way, +and the activity feed names it once. Add it to `.pdfsignore` to stop that message. + ## Changes waiting for Proton Drive A change you made in the Proton Drive folder exists only on this computer until it reaches @@ -278,6 +283,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i **Restore to This Computer…** downloads its folders here. - **Continue This Backup Here…** makes this computer take over another computer's backup, for example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md). +- **Move to This Computer…** moves another computer's folders into this computer's backup, for + example when you switch from Windows to Linux, and then offers them for syncing here. The other + computer stays in the list, without those folders. ## Search launcher diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index fbb2a0f..372b02f 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=3.1.2 +pkgver=3.2.0 pkgrel=1 pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" arch=('x86_64') diff --git a/packaging/aur/proton-drive-for-linux-bin/.SRCINFO b/packaging/aur/proton-drive-for-linux-bin/.SRCINFO index 2937c83..12d0708 100644 --- a/packaging/aur/proton-drive-for-linux-bin/.SRCINFO +++ b/packaging/aur/proton-drive-for-linux-bin/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = proton-drive-for-linux-bin pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) - pkgver = 3.1.1 + pkgver = 3.1.2 pkgrel = 1 url = https://proton-drive.narl.io arch = x86_64 @@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding - provides = proton-drive-for-linux=3.1.1 - provides = proton-drive-linux=3.1.1 + provides = proton-drive-for-linux=3.1.2 + provides = proton-drive-linux=3.1.2 provides = pdfs conflicts = proton-drive-linux conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux-git options = !strip options = !debug - source = proton-drive-linux-3.1.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.1/proton-drive-linux-3.1.1-x86_64.tar.gz - source = io.narl.proton-drive-linux-3.1.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/io.narl.proton-drive-linux.desktop - source = io.narl.proton-drive-linux-tray-3.1.1.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/io.narl.proton-drive-linux-tray.desktop - source = io.narl.proton-drive-linux-3.1.1.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/io.narl.proton-drive-linux.svg - source = proton-drive-3.1.1.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/proton-drive.service - source = LICENSE-3.1.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/LICENSE - sha256sums = e8a2ca20dc5a4b050a85056196eb7f3f4a428db2a8d41aa09b813e007609c0b6 + source = proton-drive-linux-3.1.2-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.2/proton-drive-linux-3.1.2-x86_64.tar.gz + source = io.narl.proton-drive-linux-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.desktop + source = io.narl.proton-drive-linux-tray-3.1.2.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux-tray.desktop + source = io.narl.proton-drive-linux-3.1.2.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/io.narl.proton-drive-linux.svg + source = proton-drive-3.1.2.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/packaging/proton-drive.service + source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE + sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7 sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0 sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828 diff --git a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD index 8b7ac16..a00a6c0 100644 --- a/packaging/aur/proton-drive-for-linux-bin/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-bin/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Nils Pukropp pkgname=proton-drive-for-linux-bin -pkgver=3.1.1 +pkgver=3.1.2 pkgrel=1 pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" arch=('x86_64') @@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta "io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg" "proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service" "LICENSE-$pkgver::$_raw/LICENSE") -sha256sums=('e8a2ca20dc5a4b050a85056196eb7f3f4a428db2a8d41aa09b813e007609c0b6' +sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828' diff --git a/packaging/aur/proton-drive-for-linux-git/.SRCINFO b/packaging/aur/proton-drive-for-linux-git/.SRCINFO index 37e070a..e759c4f 100644 --- a/packaging/aur/proton-drive-for-linux-git/.SRCINFO +++ b/packaging/aur/proton-drive-for-linux-git/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = proton-drive-for-linux-git pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git) - pkgver = 3.1.1.r0.gedd62c5 + pkgver = 3.1.2.r0.g07c9b67 pkgrel = 1 url = https://proton-drive.narl.io arch = x86_64 @@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding - provides = proton-drive-for-linux=3.1.1.r0.gedd62c5 - provides = proton-drive-linux=3.1.1.r0.gedd62c5 + provides = proton-drive-for-linux=3.1.2.r0.g07c9b67 + provides = proton-drive-linux=3.1.2.r0.g07c9b67 provides = pdfs conflicts = proton-drive-linux conflicts = proton-drive-for-linux diff --git a/packaging/aur/proton-drive-for-linux-git/PKGBUILD b/packaging/aur/proton-drive-for-linux-git/PKGBUILD index 917d341..32b9cbd 100644 --- a/packaging/aur/proton-drive-for-linux-git/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux-git/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Nils Pukropp pkgname=proton-drive-for-linux-git -pkgver=3.1.1.r0.gedd62c5 +pkgver=3.1.2.r0.g07c9b67 pkgrel=1 pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" arch=('x86_64') diff --git a/packaging/aur/proton-drive-for-linux/.SRCINFO b/packaging/aur/proton-drive-for-linux/.SRCINFO index 2c454ba..9f0635a 100644 --- a/packaging/aur/proton-drive-for-linux/.SRCINFO +++ b/packaging/aur/proton-drive-for-linux/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = proton-drive-for-linux pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray - pkgver = 3.1.1 + pkgver = 3.1.2 pkgrel = 1 url = https://proton-drive.narl.io arch = x86_64 @@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding - provides = proton-drive-linux=3.1.1 + provides = proton-drive-linux=3.1.2 provides = pdfs conflicts = proton-drive-linux conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-git options = !lto - source = proton-drive-for-linux-3.1.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.1.tar.gz - sha256sums = c16019c75959d5056c8ae486e0a4dc1f6346828ea93f346dfeae7da717629256 + source = proton-drive-for-linux-3.1.2.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.2.tar.gz + sha256sums = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1 pkgname = proton-drive-for-linux diff --git a/packaging/aur/proton-drive-for-linux/PKGBUILD b/packaging/aur/proton-drive-for-linux/PKGBUILD index 8994da5..5a06922 100644 --- a/packaging/aur/proton-drive-for-linux/PKGBUILD +++ b/packaging/aur/proton-drive-for-linux/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Nils Pukropp pkgname=proton-drive-for-linux -pkgver=3.1.1 +pkgver=3.1.2 pkgrel=1 pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" arch=('x86_64') @@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off. options=('!lto') source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz") -sha256sums=('c16019c75959d5056c8ae486e0a4dc1f6346828ea93f346dfeae7da717629256') +sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1') _srcname="proton-drive-linux-$pkgver" diff --git a/po/de.po b/po/de.po index 9ba25aa..b26f638 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-08 18:47+0200\n" +"POT-Creation-Date: 2026-10-09 02:57+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,50 +57,50 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:760 +#: crates/pdfs-gui/src/app/main.rs:761 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 -#: crates/pdfs-gui/src/app/pages/browser.rs:2090 -#: crates/pdfs-gui/src/app/pages/browser.rs:2167 +#: crates/pdfs-gui/src/app/pages/browser.rs:2083 +#: crates/pdfs-gui/src/app/pages/browser.rs:2160 #: crates/pdfs-gui/src/app/pages/locations.rs:1624 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:763 crates/pdfs-gui/src/app/main.rs:1407 +#: crates/pdfs-gui/src/app/main.rs:764 crates/pdfs-gui/src/app/main.rs:1389 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:768 +#: crates/pdfs-gui/src/app/main.rs:769 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:773 +#: crates/pdfs-gui/src/app/main.rs:774 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:776 +#: crates/pdfs-gui/src/app/main.rs:777 #: crates/pdfs-gui/src/app/pages/devices.rs:140 -#: crates/pdfs-gui/src/app/pages/devices.rs:481 +#: crates/pdfs-gui/src/app/pages/devices.rs:485 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:778 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:779 +#: crates/pdfs-gui/src/app/main.rs:780 msgid "Sync" msgstr "Synchronisierung" @@ -132,29 +132,11 @@ msgstr "Etwas anderes hat dies zuerst geändert" msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1160 crates/pdfs-gui/src/app/main.rs:1389 -#: crates/pdfs-gui/src/app/pages/status.rs:385 -msgid "Preferences" -msgstr "Einstellungen" - -#: crates/pdfs-gui/src/app/main.rs:1161 -#: crates/pdfs-gui/src/app/widgets/compat.rs:262 -msgid "Keyboard Shortcuts" -msgstr "Tastenkürzel" - -#: crates/pdfs-gui/src/app/main.rs:1163 -msgid "About Proton Drive for Linux" -msgstr "Info zu Proton Drive for Linux" - -#: crates/pdfs-gui/src/app/main.rs:1168 -msgid "Main menu" -msgstr "Hauptmenü" - -#: crates/pdfs-gui/src/app/main.rs:1224 +#: crates/pdfs-gui/src/app/main.rs:1206 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1232 +#: crates/pdfs-gui/src/app/main.rs:1214 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -166,23 +148,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1219 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1223 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1245 +#: crates/pdfs-gui/src/app/main.rs:1227 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1250 +#: crates/pdfs-gui/src/app/main.rs:1232 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1254 +#: crates/pdfs-gui/src/app/main.rs:1236 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -207,7 +189,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1262 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -227,7 +209,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +206,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +207,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +208,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +206,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +207,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,50 +60,50 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:760 +#: crates/pdfs-gui/src/app/main.rs:761 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 #: crates/pdfs-gui/src/app/pages/browser.rs:1217 -#: crates/pdfs-gui/src/app/pages/browser.rs:2090 -#: crates/pdfs-gui/src/app/pages/browser.rs:2167 +#: crates/pdfs-gui/src/app/pages/browser.rs:2083 +#: crates/pdfs-gui/src/app/pages/browser.rs:2160 #: crates/pdfs-gui/src/app/pages/locations.rs:1624 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:252 #: crates/pdfs-gui/src/app/pages/trash.rs:191 crates/pdfs-gui/src/prompt.rs:171 msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:763 crates/pdfs-gui/src/app/main.rs:1407 +#: crates/pdfs-gui/src/app/main.rs:764 crates/pdfs-gui/src/app/main.rs:1389 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:768 +#: crates/pdfs-gui/src/app/main.rs:769 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:773 +#: crates/pdfs-gui/src/app/main.rs:774 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:776 +#: crates/pdfs-gui/src/app/main.rs:777 #: crates/pdfs-gui/src/app/pages/devices.rs:140 -#: crates/pdfs-gui/src/app/pages/devices.rs:481 +#: crates/pdfs-gui/src/app/pages/devices.rs:485 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:777 +#: crates/pdfs-gui/src/app/main.rs:778 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:779 +#: crates/pdfs-gui/src/app/main.rs:780 msgid "Sync" msgstr "" @@ -135,29 +135,11 @@ msgstr "" msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1160 crates/pdfs-gui/src/app/main.rs:1389 -#: crates/pdfs-gui/src/app/pages/status.rs:385 -msgid "Preferences" -msgstr "" - -#: crates/pdfs-gui/src/app/main.rs:1161 -#: crates/pdfs-gui/src/app/widgets/compat.rs:262 -msgid "Keyboard Shortcuts" -msgstr "" - -#: crates/pdfs-gui/src/app/main.rs:1163 -msgid "About Proton Drive for Linux" -msgstr "" - -#: crates/pdfs-gui/src/app/main.rs:1168 -msgid "Main menu" -msgstr "" - -#: crates/pdfs-gui/src/app/main.rs:1224 +#: crates/pdfs-gui/src/app/main.rs:1206 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1232 +#: crates/pdfs-gui/src/app/main.rs:1214 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -165,23 +147,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1219 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1223 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1245 +#: crates/pdfs-gui/src/app/main.rs:1227 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1250 +#: crates/pdfs-gui/src/app/main.rs:1232 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1254 +#: crates/pdfs-gui/src/app/main.rs:1236 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -194,21 +176,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1262 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -228,7 +210,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1270 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +206,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1278 +#: crates/pdfs-gui/src/app/main.rs:1260 msgid "" "The street map, when turned on, comes from OpenFreeMap in the