Merge remote-tracking branch 'origin/main' into pr32-merge

# Conflicts:
#	docs/CHANGELOG.md
#	po/de.po
#	po/en_GB.po
#	po/es.po
#	po/fr.po
#	po/it.po
#	po/nl.po
#	po/pdfs.pot
#	po/pl.po
#	po/pt_BR.po
This commit is contained in:
nvrl committed 2026-10-09 03:03:31 +02:00
commit 7fd3439b60
55 files changed
+6213 -4610

No files matched your search

Generated
+4 -4
View File
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.1.2" version = "3.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3490,7 +3490,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.1.2" version = "3.2.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3516,7 +3516,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.1.2" version = "3.2.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3538,7 +3538,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.1.2" version = "3.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.1.2" version = "3.2.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
+27
View File
@@ -628,6 +628,15 @@ enum DeviceCmd {
#[arg(long, conflicts_with = "uid")] #[arg(long, conflicts_with = "uid")]
clear: bool, 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)] #[derive(Subcommand)]
@@ -967,6 +976,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
let uid = if clear { None } else { uid }; let uid = if clear { None } else { uid };
ok_or_bail(control_request(CtlRequest::AdoptDevice { 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(()) Ok(())
} }
+12 -28
View File
@@ -2309,42 +2309,26 @@ mod tests {
/// call also needs. /// call also needs.
#[test] #[test]
fn budget_check_is_free_when_under_budget() { fn budget_check_is_free_when_under_budget() {
use std::time::Instant;
// Cap far above what we store, so every store stays under budget. // Cap far above what we store, so every store stays under budget.
let (c, _d) = cache_capped(1 << 30); let (c, _d) = cache_capped(1 << 30);
let payload = vec![0u8; 4096]; let payload = vec![0u8; 4096];
for i in 0..8u64 {
// Cost of a store against a near-empty index...
let t = Instant::now();
for i in 0..500u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload) c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap(); .unwrap();
} }
let empty = t.elapsed() / 500;
// ...and against one with a few thousand entries in it. // Plant an index row far over the cap behind the running total's back,
for i in 500..2500u64 { // and older than every real block. Any budget check that consults the
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload) // index would see the pool over budget, evict, and reseed the total.
.unwrap(); c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).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;
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500"); c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
// A ratio, not a wall-clock bound: the claim is that the under-budget .unwrap();
// 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 // Not a timing ratio: that measured the CI runner's load and flaked.
// runs alongside the rest of the suite. assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
assert!( let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
full < empty * 3, assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
"under-budget store should not scale with the index: \
{empty:?} at ~0 entries vs {full:?} at 2500"
);
} }
/// The running totals are a fast path, not a second source of truth: after /// The running totals are a fast path, not a second source of truth: after
+16
View File
@@ -446,6 +446,13 @@ pub enum Request {
/// of registering a duplicate. `uid: None` clears the pin. /// of registering a duplicate. `uid: None` clears the pin.
/// Replies with [`Response::Ok`]. /// Replies with [`Response::Ok`].
AdoptDevice { uid: Option<String> }, AdoptDevice { uid: Option<String> },
/// 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 ---------------------------------- // ---- device folder sync ----------------------------------
/// Add a local folder to this machine's device, uploading its tree and /// 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). /// Steps known to need doing, or `0` when unknown (indeterminate progress).
/// May *grow* mid-job as more work is discovered. /// May *grow* mid-job as more work is discovered.
pub total: u64, 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. /// One in-flight transfer in a [`Response::Transfers`] snapshot.
@@ -3082,6 +3095,9 @@ mod tests {
uid: Some("dev-1".into()), uid: Some("dev-1".into()),
}, },
Request::AdoptDevice { uid: None }, Request::AdoptDevice { uid: None },
Request::MigrateDevice {
uid: "dev-2".into(),
},
Request::ListRestorableFolders, Request::ListRestorableFolders,
Request::RestoreSyncFolders { Request::RestoreSyncFolders {
items: vec![RestoreItem { items: vec![RestoreItem {
+11
View File
@@ -139,6 +139,17 @@ impl Db {
Ok(()) 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 /// Queue (or, with `None`, withdraw) a mode the folder should move to once it
/// is safe to switch. /// is safe to switch.
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> { pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
+22
View File
@@ -4940,6 +4940,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
assert_eq!(folder.pending_mode, None); 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] #[test]
fn mode_commit_clears_only_the_intent_it_satisfies() { fn mode_commit_clears_only_the_intent_it_satisfies() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
+1 -1
View File
@@ -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 // The walk has no idea how many files it will find, so the job counts what it
// has seen and stays indeterminate. // 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"); job.detail("Scanning your files");
let walked = localindex::scan(&[home], &excludes, |batch| { let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) { if let Err(e) = db.local_upsert_batch(generation, &batch) {
+5 -1
View File
@@ -369,7 +369,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
RefreshScope::Photos { .. } => &[], RefreshScope::Photos { .. } => &[],
}, },
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices], R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations], R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AddSyncFolder { .. } R::AddSyncFolder { .. }
| R::RemoveSyncFolder { .. } | R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. } | R::SetSyncFolderMode { .. }
@@ -1297,6 +1297,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(message) => CtlResponse::Ok { message }, Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e), 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 }) => { Ok(CtlRequest::AddSyncFolder { local_path }) => {
// Registering the device and uploading a folder tree far outlasts the // Registering the device and uploading a folder tree far outlasts the
// socket read timeout, so ack immediately and work on a background // socket read timeout, so ack immediately and work on a background
+180 -9
View File
@@ -19,6 +19,7 @@ use pdfs_core::control::{
}; };
use pdfs_core::db::{StoredDevice, StoredSyncFolder}; use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::mounts::MountMode; use pdfs_core::mounts::MountMode;
use pdfs_core::profile::PROFILE_DIR_NAME;
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid}; use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node}; 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}"))) .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<String> {
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 { impl Core {
// ---- devices ---------------------------------------------------------- // ---- 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
/// "<name> (<device>)". 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<String> {
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<String> = 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<String> = Vec::new();
let mut left: Vec<String> = Vec::new();
let mut failed: Vec<String> = 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 ------------------------- // ---- device folder sync -------------------------
/// Auto-register (or recover) this machine as a Proton Drive Device, caching /// Auto-register (or recover) this machine as a Proton Drive Device, caching
@@ -360,21 +478,25 @@ impl Core {
root_uid: &NodeUid, root_uid: &NodeUid,
name: &str, name: &str,
) -> CoreResult<Option<NodeUid>> { ) -> CoreResult<Option<NodeUid>> {
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<Vec<Node>> {
let uids = self let uids = self
.rt .rt
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid)) .block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?; .map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() { if uids.is_empty() {
return Ok(None); return Ok(Vec::new());
} }
let nodes = self self.rt
.rt
.block_on(self.drive.enumerate_nodes(&uids)) .block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?; .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))
} }
/// Add a local folder to this device's sync set: register the device if /// Add a local folder to this device's sync set: register the device if
@@ -535,12 +657,16 @@ impl Core {
} else { } else {
p.total.max(p.done) as u64 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 { SyncPhase::Applying => JobItem {
title: format!("Syncing {folder}"), title: format!("Syncing {folder}"),
detail: p.current.clone(), detail: p.current.clone(),
done: p.done as u64, done: p.done as u64,
total: p.total.max(p.done) as u64, total: p.total.max(p.done) as u64,
background: false,
}, },
}); });
} }
@@ -842,6 +968,23 @@ impl Core {
if folder.state != "idle" { if folder.state != "idle" {
return Err(SwitchBlocked::NotNow); 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(|| { let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid)) SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
})?; })?;
@@ -1107,10 +1250,38 @@ mod tests {
use pdfs_core::db::StoredSyncFolder; use pdfs_core::db::StoredSyncFolder;
use super::{ use super::{
ApplicableMountMode, commit_after_teardown, pending_intent_matches, ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
restore_snapshot_matches, select_mode_transition, 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] #[test]
fn unknown_mode_cannot_select_a_destructive_transition() { fn unknown_mode_cannot_select_a_destructive_transition() {
let mut unmounts = 0; let mut unmounts = 0;
+6 -1
View File
@@ -744,6 +744,9 @@ struct Core {
/// uploading that same tree — the engine would upload files as they vanish /// uploading that same tree — the engine would upload files as they vanish
/// and then walk the FUSE mount as if it were local. /// and then walk the FUSE mount as if it were local.
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>, sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
/// 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<Mutex<HashMap<i64, Vec<String>>>>,
/// Nodes this daemon changed on the remote itself, and how many echoes of /// Nodes this daemon changed on the remote itself, and how many echoes of
/// those changes the event feed still owes us. /// those changes the event feed still owes us.
/// ///
@@ -1031,7 +1034,9 @@ impl Core {
let core = self.clone(); let core = self.clone();
std::thread::spawn(move || { 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"); job.detail("Verifying every page");
let problems = match core.db.integrity_check() { let problems = match core.db.integrity_check() {
Ok(problems) => problems, Ok(problems) => problems,
+1
View File
@@ -475,6 +475,7 @@ pub(crate) fn mount_with(
sync_tx, sync_tx,
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())), mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())), sync_locks: Arc::new(Mutex::new(HashMap::new())),
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
states: Arc::new(StateRegistry::default()), states: Arc::new(StateRegistry::default()),
}; };
// Before anything can queue work against it: the drain thread below reaches // Before anything can queue work against it: the drain thread below reaches
+20 -12
View File
@@ -660,6 +660,12 @@ impl Core {
/// the shared uids; the per-node detail is then gathered best-effort — a single /// 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 /// node racing with an unshare drops from the list rather than failing the whole
/// request. /// 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<Vec<SharedItem>> { pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self let uids = self
.rt .rt
@@ -678,24 +684,26 @@ impl Core {
let members = self let members = self
.rt .rt
.block_on(self.client.list_share_members(&uid)) .block_on(self.client.list_share_members(&uid))
.map(|m| m.len()) .map(|m| m.len());
.unwrap_or(0);
let proton_invites = self let proton_invites = self
.rt .rt
.block_on(self.client.list_share_invitations(&uid)) .block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len()) .map(|i| i.len());
.unwrap_or(0);
let external_invites = self let external_invites = self
.rt .rt
.block_on(self.client.list_external_invitations(&uid)) .block_on(self.client.list_external_invitations(&uid))
.map(|i| i.len()) .map(|i| i.len());
.unwrap_or(0); let link = self.rt.block_on(self.client.get_public_link(&uid));
let link = self if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
.rt (&members, &proton_invites, &external_invites, &link)
.block_on(self.client.get_public_link(&uid)) {
.ok() debug!(%uid, "shared by me: skipping an emptied share");
.flatten() continue;
.map(public_link_info); }
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 { items.push(SharedItem {
uid: uid.to_string(), uid: uid.to_string(),
is_dir: n.is_folder(), is_dir: n.is_folder(),
+569 -100
View File
@@ -50,7 +50,7 @@ mod planner;
use engine::SYNC_CONCURRENCY; use engine::SYNC_CONCURRENCY;
pub(crate) use engine::{SyncMsg, spawn}; pub(crate) use engine::{SyncMsg, spawn};
#[cfg(test)] #[cfg(test)]
use engine::{classify, settle_with}; use engine::{classify, event_target, settle_with};
#[cfg(test)] #[cfg(test)]
use local_scan::is_write_mode; use local_scan::is_write_mode;
use local_scan::open_for_write_set; use local_scan::open_for_write_set;
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
#[cfg(test)] #[cfg(test)]
use planner::conflict_path; use planner::conflict_path;
use planner::{ use planner::{
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe, FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size, 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 /// What a [`Pending`] op did, so the reconcile can update shared state on the
/// engine thread (never inside a task). /// engine thread (never inside a task).
@@ -187,6 +188,7 @@ impl Core {
"idle" "idle"
}; };
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs()); 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 // Summarise a pass that actually moved something. A poll over an
// unchanged folder does nothing and says nothing — otherwise the // unchanged folder does nothing and says nothing — otherwise the
// feed would fill with "synced, 0 changes" every two minutes. // 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 /// Compile the ignore rules for a synced folder: the global patterns from
/// the config, plus any `.pdfsignore` at `local_root`. /// 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 /// than to "ignore nothing" — the defaults exclude build and VCS trees, and
/// silently uploading those because a config read failed is the outcome this /// silently uploading those because a config read failed is the outcome this
/// feature exists to prevent. /// 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() { let globals = match pdfs_core::config::AppDirs::new() {
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(), Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
Err(e) => { Err(e) => {
@@ -313,15 +346,23 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len()); self.progress_scan_total(folder_id, baseline.len());
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root); let writing = open_for_write_set(local_root);
self.walk_local( 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 // 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 // ignored or unreadable path is absent from `local` by rule or by
// those as missing would trip the guard on every pass — wedging the // failure, not by loss, and counting those as missing would trip the
// folder's sync permanently — the moment a rule covers the whole tree. // guard on every pass — wedging the folder's sync permanently — the
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; // 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 // 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 // directory this device has synced recorded its uid there when it was
@@ -391,6 +432,7 @@ impl Core {
let mut outcome = Outcome { let mut outcome = Outcome {
deferred: local.values().filter(|item| item.open_for_write).count(), deferred: local.values().filter(|item| item.open_for_write).count(),
unscanned: unscanned.report(),
..Default::default() ..Default::default()
}; };
let mut order: Vec<&String> = local.keys().collect(); let mut order: Vec<&String> = local.keys().collect();
@@ -515,13 +557,8 @@ impl Core {
// showing the folder's remote contents — otherwise everything they deleted // showing the folder's remote contents — otherwise everything they deleted
// comes back the moment the switch lands. Shallowest first, skipping anything // comes back the moment the switch lands. Shallowest first, skipping anything
// under a folder already trashed (its children went with it). // 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<String> = Vec::new(); let mut trashed: Vec<String> = Vec::new();
for rel in missing { for rel in &local_deletions(&tracked, &local) {
if trashed if trashed
.iter() .iter()
.any(|dir| rel.starts_with(&format!("{dir}/"))) .any(|dir| rel.starts_with(&format!("{dir}/")))
@@ -586,12 +623,18 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len() * 2); self.progress_scan_total(folder_id, baseline.len() * 2);
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root); let writing = open_for_write_set(local_root);
self.walk_local( 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. // See `push_pass`: the guard must not count excluded paths as lost.
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?; guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
let mut remote: HashMap<String, RemoteItem> = HashMap::new(); let mut remote: HashMap<String, RemoteItem> = HashMap::new();
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new(); let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
@@ -605,9 +648,15 @@ impl Core {
&baseline, &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 // Folders to delete, collected here and removed deepest-first at the end
// so a parent is never removed before its children. // so a parent is never removed before its children.
let mut delete_local_dirs: Vec<String> = Vec::new(); let mut delete_local_dirs: Vec<String> = Vec::new();
@@ -873,19 +922,19 @@ impl Core {
Ok(outcome) Ok(outcome)
} }
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative /// Recursively walk a local tree into `out`, keyed by `/`-joined relative
/// path. Symlinks and other special files are skipped. Reports each entry to the /// path. Symlinks and other special files are skipped, and paths that cannot
/// pass's scan progress. /// be read go to `unscanned`. Reports each entry to the pass's scan progress.
fn walk_local( fn walk_local(
&self, &self,
folder_id: i64, folder_id: i64,
root: &Path, root: &Path,
dir: &Path,
rules: &IgnoreRules, rules: &IgnoreRules,
writing: &HashSet<PathBuf>, writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>, out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
) -> Result<(), String> { ) -> 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)) 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 /// 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 /// **A path the scan cannot read is left out and recorded in `unscanned`, never
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of /// silently dropped.** Every pass drops what `unscanned` covers from
/// them would, if skipped, leave `out` describing a *smaller* tree than the one /// classification, so a baseline row under it is neither a local deletion nor a
/// on disk. Reconciliation reads a path present in the baseline and absent from /// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
/// the scan as "the user deleted this", and propagates that to Drive. So an /// permission for, an entry that vanished between the listing and its `stat`,
/// unreadable subdirectory would trash its remote counterpart, and the deeper /// and a name that is not UTF-8. Failing the whole pass on any of them, as this
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the /// did before, left one root-owned folder stopping everything else in the tree
/// whole pass keeps the previous baseline and leaves the remote alone; the next /// from syncing, on every pass, for good (B196).
/// pass retries once the transient condition clears. ///
/// 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 /// 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. /// with a real unreadable directory in it, which needs no `Core` at all.
fn walk_local_tree( fn walk_local_tree(
root: &Path,
rules: &IgnoreRules,
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
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, root: &Path,
dir: &Path, dir: &Path,
rules: &IgnoreRules, rules: &IgnoreRules,
writing: &HashSet<PathBuf>, writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>, out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
scanned: &mut dyn FnMut(&str), scanned: &mut dyn FnMut(&str),
) -> Result<(), String> { ) -> Result<(), ScanError> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?; 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 { 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 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| { let stripped = path.strip_prefix(root).map_err(|e| {
format!( ScanError::Failed(format!(
"local path {} escaped sync root {}: {e}", "local path {} escaped sync root {}: {e}",
path.display(), path.display(),
root.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. // Ignore our own in-flight download temp files.
if rel.contains(".pdfs-tmp-") { if rel.contains(".pdfs-tmp-") {
continue; 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 // Ignored paths are dropped here as well as from the classification
// union — not for correctness (the union filter is what makes this // union — not for correctness (the union filter is what makes this
// safe) but so an ignored `node_modules/` is never descended into or // safe) but so an ignored `node_modules/` is never descended into or
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
}, },
); );
scanned(rel); 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() { } else if meta.is_file() {
// Check whether any process holds this file open for writing. // Check whether any process holds this file open for writing.
// The canonical path is used because /proc/*/fd links resolve // The canonical path is used because /proc/*/fd links resolve
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
baseline: &HashMap<String, StoredSyncEntry>, baseline: &HashMap<String, StoredSyncEntry>,
) -> Option<String> { ) -> Option<String> {
let mut seen = HashSet::new(); 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); return Some(reason);
} }
let prefix = format!("{rel}/"); 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")) .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<String, StoredSyncEntry>,
rules: &IgnoreRules,
) -> Option<String> {
let mut seen = HashSet::new();
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
return Some(reason);
}
let mut missing: Vec<String> = 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( fn unsynced_local(
root: &Path, root: &Path,
rel: &str, rel: &str,
baseline: &HashMap<String, StoredSyncEntry>, baseline: &HashMap<String, StoredSyncEntry>,
rules: Option<&IgnoreRules>,
seen: &mut HashSet<String>, seen: &mut HashSet<String>,
) -> Option<String> { ) -> Option<String> {
let path = root.join(rel_to_path(rel)); let path = if rel.is_empty() {
let meta = match std::fs::symlink_metadata(&path) { root.to_path_buf()
Ok(meta) => meta, } else {
Err(e) => return Some(format!("cannot read {rel}: {e}")), root.join(rel_to_path(rel))
}; };
let Some(base) = baseline.get(rel) else { // The folder itself has no baseline row; only what is in it does.
return Some(format!("{rel} is not on Proton Drive yet")); if !rel.is_empty() {
}; let meta = match std::fs::symlink_metadata(&path) {
seen.insert(rel.to_string()); Ok(meta) => meta,
if meta.is_file() { Err(e) => return Some(format!("cannot read {rel}: {e}")),
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base))) };
.then(|| format!("{rel} has changes that are not on Proton Drive yet")); let Some(base) = baseline.get(rel) else {
} if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
if !meta.is_dir() { return Some(format!(
return Some(format!("{rel} is not a regular file or folder")); "{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) { let entries = match std::fs::read_dir(&path) {
Ok(entries) => entries, 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 { for entry in entries {
let name = match entry { let name = match entry {
Ok(entry) => entry.file_name(), 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 { 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. // A download the engine left half done; it is the engine's to clean up.
if name.contains(".pdfs-tmp-") { if name.contains(".pdfs-tmp-") {
continue; 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); return Some(reason);
} }
} }
@@ -1923,6 +2097,32 @@ mod tests {
(ids, started.elapsed()) (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<PathBuf> = 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 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 /// 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 /// real revision. The window has to restart on every event, so settling
@@ -2154,7 +2354,15 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut progress = no_progress(); 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"); assert!(local.is_empty(), "the replacement root is genuinely empty");
for size in 1..=3 { for size in 1..=3 {
@@ -2170,7 +2378,15 @@ mod tests {
// remounted folder resumes rather than staying wedged. // remounted folder resumes rather than staying wedged.
std::fs::write(root.join("file0.txt"), b"back").unwrap(); std::fs::write(root.join("file0.txt"), b"back").unwrap();
let mut local: HashMap<String, LocalItem> = HashMap::new(); let mut local: HashMap<String, LocalItem> = 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<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect(); let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok()); assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
@@ -2475,33 +2691,176 @@ mod tests {
|_: &str| {} |_: &str| {}
} }
/// A directory the pass cannot read must fail the pass. Omitting it would /// Scan `root` with no rules, returning what it found and left out.
/// hand reconciliation a baseline-minus-subtree, which it reads as the user fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
/// having deleted that subtree and propagates to Drive (B55). 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<V>(map: &HashMap<String, V>) -> 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] #[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 _; use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-eacces"); let root = sync_test_dir("scan-eacces");
std::fs::write(root.join("keep.txt"), b"a").unwrap(); 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::create_dir(&locked).unwrap();
std::fs::write(locked.join("inner.txt"), b"b").unwrap(); std::fs::write(locked.join("inner.txt"), b"b").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let rules = rules_for(&[]); let result = scan(&root);
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);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap(); std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("an unreadable directory must abort the pass"); let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
assert!( assert_eq!(
error.contains("locked"), sorted_keys(&out),
"the error should name the subtree it could not read: {error}" 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<String, LocalItem> = [("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<String, RemoteItem> = 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<String, LocalItem> = [("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<String, LocalItem> = 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); 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 /// 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". /// pass sees nothing, and "nothing" must never mean "delete everything".
#[test] #[test]
@@ -2607,7 +3054,14 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut progress = no_progress(); 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::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
@@ -2628,30 +3082,40 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut progress = no_progress(); 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()); assert!(out.is_empty());
} }
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it /// A name that is not valid UTF-8 cannot be keyed into the scan, but it
/// cannot be compared against the baseline either. Refusing the pass is the /// cannot be in the baseline or on Drive either, so leaving it out reads as
/// only safe reading: skipping it would classify the file as deleted. /// the deletion of nothing. It used to fail the pass (B196).
#[test] #[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::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _; use std::os::unix::ffi::OsStrExt as _;
let root = sync_test_dir("scan-non-utf8"); 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(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
std::fs::write(root.join("good.txt"), b"y").unwrap();
let rules = rules_for(&[]); let result = scan(&root);
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);
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("a name the scan cannot key must abort the pass"); let (out, unscanned) = result.unwrap();
assert!(error.contains("UTF-8"), "{error}"); 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 /// The complement: a scan that *can* read everything reports everything, so
@@ -2670,9 +3134,14 @@ mod tests {
let writing = HashSet::new(); let writing = HashSet::new();
let mut out = HashMap::new(); let mut out = HashMap::new();
let mut seen: Vec<String> = Vec::new(); let mut seen: Vec<String> = Vec::new();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| { let result = walk_local_tree(
seen.push(rel.to_string()) &root,
}); &rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut |rel| seen.push(rel.to_string()),
);
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
result.unwrap(); result.unwrap();
+29 -3
View File
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) { fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
// Paths the watcher currently covers, mapped to their folder id, so an event // Paths the watcher currently covers, mapped to their folder id, so an event
// path can be resolved back to the folder it belongs to. // path can be resolved back to the folder it belongs to.
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new()); let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
let tx_events = core.sync_tx.clone(); let tx_events = core.sync_tx.clone();
let watched_events = watched.clone();
let mut watcher = let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| { match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return }; let Ok(event) = res else { return };
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
if matches!(event.kind, notify::EventKind::Access(_)) { if matches!(event.kind, notify::EventKind::Access(_)) {
return; return;
} }
let _ = tx_events.send(SyncMsg::ReconcileAll); let msg = event_target(&watched_events.lock(), &event.paths);
let _ = tx_events.send(msg);
}) { }) {
Ok(w) => w, Ok(w) => w,
Err(e) => { 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: /// Bring the filesystem watches in line with the current mirror-folder set:
/// watch newly-added folders, drop removed ones. /// 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( fn rewatch(
core: &Core, core: &Core,
watcher: &mut notify::RecommendedWatcher, watcher: &mut notify::RecommendedWatcher,
@@ -274,7 +299,7 @@ fn rewatch(
return; return;
} }
}; };
let mut have = watched.lock(); let mut have = watched.lock().clone();
// Drop watches no longer wanted. // Drop watches no longer wanted.
have.retain(|(path, _)| { have.retain(|(path, _)| {
if want.iter().any(|(p, _)| p == 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"), Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
} }
} }
*watched.lock() = have;
} }
// ---- reconcile ------------------------------------------------------------ // ---- reconcile ------------------------------------------------------------
+5
View File
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
pub(super) errors: usize, pub(super) errors: usize,
/// Files skipped because another process held them open for writing. /// Files skipped because another process held them open for writing.
pub(super) deferred: usize, 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<String>,
} }
impl Outcome { impl Outcome {
+90
View File
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
.collect() .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<String>,
/// 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<String>,
}
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<String> {
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<String, B>,
rules: &IgnoreRules,
unscanned: &Unscanned,
) -> HashMap<String, &'a B> {
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<B, L>(
tracked: &HashMap<String, &B>,
local: &HashMap<String, L>,
) -> Vec<String> {
let mut missing: Vec<String> = 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. /// 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 /// When every baseline path is absent locally, the likely cause is an unavailable
+17 -1
View File
@@ -46,6 +46,8 @@ struct JobEntry {
detail: Mutex<String>, detail: Mutex<String>,
done: AtomicU64, done: AtomicU64,
total: 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 /// 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 /// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known. /// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard { pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> 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<Self>, title: impl Into<String>) -> JobGuard {
self.register_job(title.into(), true)
}
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed); let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry { let entry = Arc::new(JobEntry {
title: title.into(), title,
detail: Mutex::new(String::new()), detail: Mutex::new(String::new()),
done: AtomicU64::new(0), done: AtomicU64::new(0),
total: AtomicU64::new(0), total: AtomicU64::new(0),
background,
}); });
self.jobs.lock().insert(id, entry.clone()); self.jobs.lock().insert(id, entry.clone());
JobGuard { JobGuard {
@@ -191,6 +206,7 @@ impl TransferRegistry {
detail: e.detail.lock().clone(), detail: e.detail.lock().clone(),
done: e.done.load(Ordering::Relaxed), done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed), total: e.total.load(Ordering::Relaxed),
background: e.background,
} }
}) })
.collect() .collect()
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 441 B

@@ -14,7 +14,6 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
+6 -1
View File
@@ -18,7 +18,9 @@
.sidebar-status image.warning { color: var(--warning-color); } .sidebar-status image.warning { color: var(--warning-color); }
.sidebar-status image.error { color: var(--error-color); } .sidebar-status image.error { color: var(--error-color); }
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; } .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 */ /* A count of what waits on a page, at the end of its sidebar row */
.sidebar-badge { .sidebar-badge {
min-width: 10px; min-width: 10px;
@@ -48,6 +50,9 @@
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); } .badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.badge-issue { color: var(--error-color); } .badge-issue { color: var(--error-color); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); } .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; } 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); } .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; } .dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
+2 -20
View File
@@ -34,6 +34,7 @@ use pages::verify::*;
use reload::{LoadTicket, Loader, replace_items}; use reload::{LoadTicket, Loader, replace_items};
use theme::{load_resources, proton_theme_active, set_proton_theme}; use theme::{load_resources, proton_theme_active, set_proton_theme};
use widgets::compat::*; use widgets::compat::*;
use widgets::crumb::*;
use widgets::details::*; use widgets::details::*;
use widgets::file_list::*; use widgets::file_list::*;
use widgets::menu::*; use widgets::menu::*;
@@ -830,7 +831,6 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
header.set_title_widget(Some(&brand)); header.set_title_widget(Some(&brand));
header.pack_end(&build_primary_menu());
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
@@ -1153,25 +1153,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
} }
} }
/// The sidebar's primary (hamburger) menu: the app-level entries that don't /// Back the main menu's entries with window actions.
/// 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.
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) { fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let preferences = gio::SimpleAction::new("preferences", None); let preferences = gio::SimpleAction::new("preferences", None);
let ui_prefs = ui.clone(); let ui_prefs = ui.clone();
+2 -9
View File
@@ -1220,9 +1220,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
)); ));
let mut acc = String::new(); let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() { for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›")); ui.browser.crumb.append(&crumb_separator());
sep.add_css_class("dim-label");
ui.browser.crumb.append(&sep);
acc = if acc.is_empty() { acc = if acc.is_empty() {
seg.to_string() seg.to_string()
} else { } else {
@@ -1237,12 +1235,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
/// flat button that navigates to `target` (clearing any active search first). /// flat button that navigates to `target` (clearing any active search first).
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget { pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
if current { if current {
let l = gtk4::Label::builder() return crumb_current(label);
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
} }
let button = gtk4::Button::builder().label(label).build(); let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat"); button.add_css_class("flat");
+67 -9
View File
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
menu.item(&gettext("Restore to This Computer…"), move || { menu.item(&gettext("Restore to This Computer…"), move || {
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone()))) 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(); menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone()); let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Rename…"), move || { menu.item(&gettext("Rename…"), move || {
@@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(), nav.is_empty(),
)); ));
for (i, (_, name)) in nav.iter().enumerate() { for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›")); crumb.append(&crumb_separator());
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len())); crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
} }
} }
@@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` levels below the device list. /// that goes back up to `depth` levels below the device list.
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget { fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current { if current {
let l = gtk4::Label::builder() return crumb_current(label);
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
} }
let button = gtk4::Button::builder().label(label).build(); let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat"); button.add_css_class("flat");
@@ -1096,6 +1093,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
dialog.present(win.as_ref()); 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<Ui>, 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. /// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) { pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
ui.busy_begin(); ui.busy_begin();
+2 -9
View File
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(), nav.is_empty(),
)); ));
for (i, (_, name)) in nav.iter().enumerate() { for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›")); crumb.append(&crumb_separator());
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len())); crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
} }
} }
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` folders below the top level. /// that goes back up to `depth` folders below the top level.
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget { fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current { if current {
let l = gtk4::Label::builder() return crumb_current(label);
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
} }
let button = gtk4::Button::builder().label(label).build(); let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat"); button.add_css_class("flat");
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect(); let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
dialog.connect_response(Some("stop"), move |_, _| { dialog.connect_response(Some("stop"), move |_, _| {
let done = done.clone(); let done = done.clone();
let stopped = uids.clone();
run_requests( run_requests(
&ui_c, &ui_c,
uids.iter() uids.iter()
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
.collect(), .collect(),
gettext_noop("Couldn't stop sharing"), gettext_noop("Couldn't stop sharing"),
move |ui| { move |ui| {
forget_shared_by_me(ui, &stopped);
reload_listing(ui); reload_listing(ui);
toast(ui, &done); toast(ui, &done);
}, },
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
dialog.present(ui_window(ui).as_ref()); 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<Ui>, 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. /// Open a shared item: a folder in My Files, a file the way My Files would.
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) { fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir { if entry.is_dir {
+24 -13
View File
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
.hexpand(true) .hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::Middle) .ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build(); .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(); let account_menu = gio::Menu::new();
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences")); account_menu.append_section(None, &app_section);
let sign_out = gio::Menu::new(); account_menu.append_section(None, &account_section);
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out")); let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account_menu.append_section(None, &sign_out); row.append(&avatar);
let account_button = gtk4::MenuButton::builder() row.append(&account_name);
.icon_name("view-more-symbolic") row.append(&gtk4::Image::from_icon_name("view-more-symbolic"));
.tooltip_text(gettext("Account")) let account = gtk4::MenuButton::builder()
.child(&row)
.tooltip_text(gettext("Main menu"))
.menu_model(&account_menu) .menu_model(&account_menu)
.valign(gtk4::Align::Center) .direction(gtk4::ArrowType::Up)
.primary(true)
.build(); .build();
account_button.add_css_class("flat"); account.add_css_class("flat");
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account.add_css_class("sidebar-account"); 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); let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
footer.add_css_class("sidebar-footer"); footer.add_css_class("sidebar-footer");
+21
View File
@@ -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()
}
+6 -1
View File
@@ -132,8 +132,13 @@ impl FileList {
// and the grid collapses to a single column. Allowing a mid-word // and the grid collapses to a single column. Allowing a mid-word
// break is what keeps the two-line-then-ellipsis budget below // break is what keeps the two-line-then-ellipsis budget below
// enforceable for *every* name rather than only the ones that // 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() let label = gtk4::Label::builder()
.attributes(&no_hyphens)
.ellipsize(gtk4::pango::EllipsizeMode::End) .ellipsize(gtk4::pango::EllipsizeMode::End)
.justify(gtk4::Justification::Center) .justify(gtk4::Justification::Center)
.max_width_chars(13) .max_width_chars(13)
+23 -9
View File
@@ -128,20 +128,34 @@ impl ActionMenu {
} }
} }
/// Where a menu opened over `anchor` should live in the widget tree: the /// Where a menu opened over `anchor` should live in the widget tree.
/// outermost grid or list view around it, or `anchor` itself. CSS descendant ///
/// selectors reach through a popover to its parent, and themes style the /// Not inside a list item: CSS descendant selectors reach through a popover to
/// insides of list items (`gridview > child box { margin: 12px }` in /// its parent, and themes style the insides of list items (`gridview > child
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide /// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
/// margin around every section. Under the view itself it is no item's child. /// 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: &gtk4::Widget) -> gtk4::Widget { fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
let mut host = anchor.clone(); let mut start = anchor.clone();
let mut widget = anchor.parent(); let mut widget = anchor.parent();
while let Some(w) = widget { while let Some(w) = widget {
widget = w.parent(); widget = w.parent();
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() { if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
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
} }
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod compat; pub(crate) mod compat;
pub(crate) mod crumb;
pub(crate) mod details; pub(crate) mod details;
pub(crate) mod file_list; pub(crate) mod file_list;
pub(crate) mod menu; pub(crate) mod menu;
+71 -51
View File
@@ -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. /// How an open Share dialog addresses its node.
/// ///
/// A node the browser is showing has a mountpoint-relative path. A node reached /// 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<Ui>, entry: &DirEntry) {
.selected(0) .selected(0)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build(); // The role goes with the addresses it applies to, at the end of their row.
role_wrap.add_suffix(&role_drop); role_drop.set_tooltip_text(Some(&gettext("Role")));
email_row.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder() let message_row = adw::EntryRow::builder()
.title(gettext("Message (optional)")) .title(gettext("Message (optional)"))
.build(); .build();
let invite_btn = gtk4::Button::builder() let invite_btn = group_button(&gettext("Send Invitations"));
.label(gettext("Send Invitations"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
invite_btn.set_sensitive(false); 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(&email_row);
invite_group.add(&role_wrap);
invite_group.add(&message_row); invite_group.add(&message_row);
invite_group.add(&invite_wrap); invite_group.add(&invite_btn);
let people = adw::PreferencesGroup::builder() let people = adw::PreferencesGroup::builder()
.title(gettext("People with access")) .title(gettext("People with access"))
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.title(gettext("Public link")) .title(gettext("Public link"))
.build(); .build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); // Until the share is read, each section says so rather than standing
content.set_margin_top(12); // empty under its title.
content.set_margin_bottom(12); let people_loading = loading_row();
content.set_margin_start(12); people.add(&people_loading);
content.set_margin_end(12); let link_loading = loading_row();
content.append(&invite_group); link_group.add(&link_loading);
content.append(&people);
content.append(&link_group); let content = adw::PreferencesPage::new();
let clamp = adw::Clamp::builder().child(&content).build(); content.add(&invite_group);
let scroll = gtk4::ScrolledWindow::builder() content.add(&people);
.vexpand(true) content.add(&link_group);
.hscrollbar_policy(gtk4::PolicyType::Never) toolbar.set_content(Some(&content));
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
let dialog = adw::Dialog::builder() let dialog = adw::Dialog::builder()
.title(pgettext("verb", "Share")) .title(pgettext("verb", "Share"))
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
target, target,
people, people,
link_group, link_group,
people_rows: RefCell::new(Vec::new()), people_rows: RefCell::new(vec![people_loading.upcast()]),
link_rows: RefCell::new(Vec::new()), link_rows: RefCell::new(vec![link_loading.upcast()]),
}); });
// Check the addresses as they are typed: a malformed one marks the row as // 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<ShareDialog>) {
repaint_share_link(&state, link.as_ref()); repaint_share_link(&state, link.as_ref());
} }
Ok(Ok(Response::Error { message, .. })) => { 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"), &message)
} }
_ => toast_error( _ => {
&state.ui, share_dialog_unread(&state);
&gettext("Couldn't load sharing"), toast_error(
&gettext("The Proton Drive service didn't respond."), &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<ShareDialog>) {
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. /// Rebuild the "People with access" rows from a fresh share listing.
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) { pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
for row in state.people_rows.borrow_mut().drain(..) { for row in state.people_rows.borrow_mut().drain(..) {
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
return; return;
} }
for entry in entries { 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 { let subtitle = match entry.kind {
ShareEntryKind::Member => gettext("Member"), ShareEntryKind::Member => String::new(),
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"), ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"), ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
}; };
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&entry.email) .title(&entry.email)
.subtitle(&subtitle) .subtitle(&subtitle)
.build(); .build();
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
// External invites can't have their role changed; show it read-only. // External invites can't have their role changed; show it read-only.
// Members and Proton invites get a role dropdown. // Members and Proton invites get a role dropdown.
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.build(); .build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build(); let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop); expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder() let create = group_button(&gettext("Create Public Link"));
.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();
// Enter in the password field creates the link. // Enter in the password field creates the link.
let btn = create.clone(); let btn = create.clone();
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&role_row); state.link_group.add(&role_row);
state.link_group.add(&pw_row); state.link_group.add(&pw_row);
state.link_group.add(&expiry_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(role_row.upcast());
rows.push(pw_row.upcast()); rows.push(pw_row.upcast());
rows.push(expiry_row.upcast()); rows.push(expiry_row.upcast());
rows.push(create_wrap.upcast()); rows.push(create.upcast());
} }
} }
*state.link_rows.borrow_mut() = rows; *state.link_rows.borrow_mut() = rows;
+60 -17
View File
@@ -88,8 +88,9 @@ struct DriveState {
/// Summarise a work snapshot into one menu line, or empty when idle. A single /// 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 /// 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 /// 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", /// running job speaks for itself — a scan or an index rebuild is still work
/// and the tray saying nothing there reads as "finished". /// 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 { fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items { match items {
[] => match jobs.first() { [] => match jobs.first() {
@@ -143,12 +144,12 @@ struct DriveTray {
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) { match send(socket, &Request::Status) {
Ok(status @ Response::Status { .. }) => { Ok(status @ Response::Status { .. }) => {
// Same daemon is up, so a cheap follow-up request gives the sync line. // Same daemon is up, so a cheap follow-up request says what moves.
let sync = match send(socket, &Request::GetQueueStatus) { let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs), Ok(Response::Transfers { items, jobs }) => (items, jobs),
_ => String::new(), _ => (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. // Socket answered but with something unexpected — treat as up but odd.
Ok(_) => unexpected_reply(default_mountpoint), 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` /// The state a running daemon's [`Response::Status`] describes, with `items`
/// saying what it is moving right now. /// and `jobs` saying what it is working on right now.
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState { fn status_state(
status: &Response,
items: &[TransferItem],
jobs: &[JobItem],
default_mountpoint: &Path,
) -> DriveState {
let Response::Status { let Response::Status {
mountpoint, mountpoint,
pinned, pinned,
@@ -239,7 +245,7 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
paused, paused,
failing_ops, failing_ops,
online, online,
queued.is_some() || !sync.is_empty(), queued.is_some() || busy(items, jobs),
); );
DriveState { DriveState {
line: match (online, queued) { line: match (online, queued) {
@@ -278,11 +284,19 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
mounted: true, mounted: true,
paused, paused,
mountpoint: PathBuf::from(mountpoint), mountpoint: PathBuf::from(mountpoint),
sync, sync: sync_line(items, jobs),
failing: failing_ops, 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 /// 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. /// so first; failures outrank everything else the daemon reports.
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase { fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
@@ -547,11 +561,7 @@ fn main() -> Result<()> {
heard.take(feed); heard.take(feed);
} }
let st = match &heard.status { let st = match &heard.status {
Some(status) => status_state( Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
status,
sync_line(&heard.items, &heard.jobs),
&default_mountpoint,
),
// Lost the daemon: ask once, which describes the sign-in instead. // Lost the daemon: ask once, which describes the sign-in instead.
None => poll_state(&socket, &default_mountpoint), None => poll_state(&socket, &default_mountpoint),
}; };
@@ -596,6 +606,39 @@ mod tests {
assert_eq!(phase_of(false, 0, true, false), Phase::Synced); 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] #[test]
fn the_issues_item_counts_in_the_singular_and_plural() { fn the_issues_item_counts_in_the_singular_and_plural() {
assert_eq!(issues_label(1), "1 issue — View"); assert_eq!(issues_label(1), "1 issue — View");
+10 -2
View File
@@ -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 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: 30 s). Passes are serialized per daemon. A pass:
1. **Walks the local tree.** Any `readdir`, metadata or permission failure marks the scan 1. **Walks the local tree.** A path the user may not read, an entry that vanishes mid-scan and a
incomplete, and an incomplete scan cannot delete anything. 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.** 2. **Walks the remote tree.**
3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass. 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 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 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). 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 ## 8. Control protocol
+123
View File
@@ -81,6 +81,7 @@ original wording.
| Entry | Title | What is left | | 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 | | [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 | | [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 | | [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 | | 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 | | [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 | | [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 | | [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. 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 ### 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 **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 strictness must not cost a working pass, so a healthy tree still reports every entry and still
skips symlinks by design. 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`. 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 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. `symlink_metadata` failure the permission test drives.
+43
View File
@@ -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. 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)) ([#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 ## [3.1.2] — 2026-10-08
Schema: **42**, unchanged. 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 - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. 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.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 [3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
+1
View File
@@ -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 rename UID NAME` | Rename a computer. |
| `pdfs devices rm UID` | Remove a computer and its backup from the account. | | `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 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 ## Trash
+23
View File
@@ -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 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. 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 ## 3. How the restore works
This section is for the curious and for anyone debugging a restore. This section is for the curious and for anyone debugging a restore.
+9 -1
View File
@@ -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 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 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 <id> ondemand` and back **Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies. with `pdfs sync mode <id> 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 ### 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 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. 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 ## Changes waiting for Proton Drive
A change you made in the Proton Drive folder exists only on this computer until it reaches 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. **Restore to This Computer…** downloads its folders here.
- **Continue This Backup Here…** makes this computer take over another computer's backup, for - **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). 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 ## Search launcher
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=3.1.2 pkgver=3.2.0
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries) 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 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
arch = x86_64 arch = x86_64
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.1.1 provides = proton-drive-for-linux=3.1.2
provides = proton-drive-linux=3.1.1 provides = proton-drive-linux=3.1.2
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !strip options = !strip
options = !debug 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 = 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.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-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.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-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.1.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/io.narl.proton-drive-linux.svg 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.1.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/packaging/proton-drive.service 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.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.1/LICENSE source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
sha256sums = e8a2ca20dc5a4b050a85056196eb7f3f4a428db2a8d41aa09b813e007609c0b6 sha256sums = 71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123 sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0 sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828 sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin pkgname=proton-drive-for-linux-bin
pkgver=3.1.1 pkgver=3.1.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64') 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" "io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service" "proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE") "LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('e8a2ca20dc5a4b050a85056196eb7f3f4a428db2a8d41aa09b813e007609c0b6' sha256sums=('71fc50775cbd795717ed17c4a3994d8fdfb5ab30a223dcbc7f0e313f9ab615f7'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123' 'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0' '1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828' 'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (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 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
arch = x86_64 arch = x86_64
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding 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-for-linux=3.1.2.r0.g07c9b67
provides = proton-drive-linux=3.1.1.r0.gedd62c5 provides = proton-drive-linux=3.1.2.r0.g07c9b67
provides = pdfs provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git pkgname=proton-drive-for-linux-git
pkgver=3.1.1.r0.gedd62c5 pkgver=3.1.2.r0.g07c9b67
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)" pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64') arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray 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 pkgrel = 1
url = https://proton-drive.narl.io url = https://proton-drive.narl.io
arch = x86_64 arch = x86_64
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf) optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding 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 provides = pdfs
conflicts = proton-drive-linux conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git conflicts = proton-drive-for-linux-git
options = !lto 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 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 = c16019c75959d5056c8ae486e0a4dc1f6346828ea93f346dfeae7da717629256 sha256sums = 260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1
pkgname = proton-drive-for-linux pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io> # Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux pkgname=proton-drive-for-linux
pkgver=3.1.1 pkgver=3.1.2
pkgrel=1 pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray" pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64') 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. # LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto') options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz") source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('c16019c75959d5056c8ae486e0a4dc1f6346828ea93f346dfeae7da717629256') sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
_srcname="proton-drive-linux-$pkgver" _srcname="proton-drive-linux-$pkgver"
+522 -476
View File
File diff suppressed because it is too large. Load diff
+520 -476
View File
File diff suppressed because it is too large. Load diff
+522 -476
View File
File diff suppressed because it is too large. Load diff
+522 -476
View File
File diff suppressed because it is too large. Load diff
+521 -476
View File
File diff suppressed because it is too large. Load diff
+520 -476
View File
File diff suppressed because it is too large. Load diff
+495 -470
View File
File diff suppressed because it is too large. Load diff
+523 -476
View File
File diff suppressed because it is too large. Load diff
+521 -476
View File
File diff suppressed because it is too large. Load diff