mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07d4ee42d9 | ||
|
|
0ac5e2e4fe | ||
|
|
7eb6aa0640 | ||
|
|
c0d211238b | ||
|
|
07c9b675d3 | ||
|
|
f82343b844 | ||
|
|
3e3dd7db41 | ||
|
|
d09e10dc7d | ||
|
|
8d0351124c | ||
|
|
a888e0bb28 | ||
|
|
c7fc85e943 | ||
|
|
5bfae0fee1 |
No files matched your search
Generated
+22
-22
@@ -166,7 +166,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1254,7 +1254,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1896,7 +1896,7 @@ dependencies = [
|
|||||||
"gobject-sys",
|
"gobject-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"system-deps 9.0.0",
|
"system-deps 9.0.0",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3100,7 +3100,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.1.1"
|
version = "3.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.1.1"
|
version = "3.1.2"
|
||||||
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.1"
|
version = "3.1.2"
|
||||||
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.1"
|
version = "3.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3778,9 +3778,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5bde475139afb5fc2649621e1d283e243f9df2bb412f08802cdf3f4ffaf97c44"
|
checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3805,9 +3805,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.8.0"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "faf32d0a03f7d13bd9bdcc8c9503e35e9df9318ca9f2a100dcf06cf50cb7ac8c"
|
checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3926,7 +3926,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4374,7 +4374,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4431,7 +4431,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4764,7 +4764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4932,10 +4932,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5266,7 +5266,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"memoffset",
|
"memoffset",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5590,7 +5590,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+3
-3
@@ -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.1"
|
version = "3.1.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.8.0" }
|
proton-sdk = { version = "0.8.1" }
|
||||||
proton-drive-rs = { version = "0.8.0" }
|
proton-drive-rs = { version = "0.8.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3082,6 +3089,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 {
|
||||||
|
|||||||
@@ -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<()> {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -842,6 +964,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 +1246,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;
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+542
-99
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2154,7 +2328,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 +2352,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 +2665,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 +2926,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 +3028,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 +3056,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 +3108,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();
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -202,12 +202,20 @@ impl Ui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
tracing_subscriber::fmt()
|
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
||||||
.with_env_filter(
|
let logs = tracing_subscriber::fmt().with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
)
|
);
|
||||||
.init();
|
// The verification window's stdout carries its token back to the app.
|
||||||
|
if verify {
|
||||||
|
logs.with_writer(std::io::stderr).init();
|
||||||
|
} else {
|
||||||
|
logs.init();
|
||||||
|
}
|
||||||
i18n::init();
|
i18n::init();
|
||||||
|
if verify {
|
||||||
|
return run_verification_window();
|
||||||
|
}
|
||||||
|
|
||||||
// The tray launches the app with arguments ("--page locations",
|
// The tray launches the app with arguments ("--page locations",
|
||||||
// "--confirm-stop"), which a running instance must receive too.
|
// "--confirm-stop"), which a running instance must receive too.
|
||||||
@@ -303,6 +311,11 @@ fn build_window(app: &adw::Application) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// On a fresh install the daemon has not run yet to make these, and the
|
||||||
|
// Takeout page keeps its staged archives in the state dir (B194).
|
||||||
|
if let Err(e) = dirs.ensure() {
|
||||||
|
tracing::warn!("cannot create the app's directories: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
let stack = adw::ViewStack::new();
|
let stack = adw::ViewStack::new();
|
||||||
let (login_page, login_widgets) = build_login_page();
|
let (login_page, login_widgets) = build_login_page();
|
||||||
|
|||||||
@@ -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 || {
|
||||||
@@ -1096,6 +1100,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();
|
||||||
|
|||||||
@@ -150,15 +150,27 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Same shape for human verification: only fires when the API gates the
|
// Same shape for human verification: only fires when the API gates the
|
||||||
// sign-in, and the worker is blocked on the token until it does.
|
// sign-in, and the worker is blocked on the token until it does. A
|
||||||
|
// window that never came up is told apart from one the user closed, so
|
||||||
|
// the result below can say why the sign-in stopped.
|
||||||
let ui_hv = ui.clone();
|
let ui_hv = ui.clone();
|
||||||
|
let verification_failed = Rc::new(Cell::new(false));
|
||||||
|
let hv_failed = verification_failed.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||||
ui_hv
|
ui_hv
|
||||||
.login
|
.login
|
||||||
.login_status
|
.login_status
|
||||||
.set_text(&gettext("Complete the verification to continue…"));
|
.set_text(&gettext("Complete the verification to continue…"));
|
||||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
// Anything but a token drops the sender, which the worker reads
|
||||||
|
// as a cancelled sign-in.
|
||||||
|
match verify_human(url).await {
|
||||||
|
Verification::Solved(token) => {
|
||||||
|
let _ = token_tx.send(token);
|
||||||
|
}
|
||||||
|
Verification::Cancelled => {}
|
||||||
|
Verification::Failed => hv_failed.set(true),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,6 +206,9 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
}
|
}
|
||||||
|
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
|
||||||
|
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
|
||||||
|
)),
|
||||||
Err(e) => ui.login.login_status.set_text(&e),
|
Err(e) => ui.login.login_status.set_text(&e),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The human-verification (CAPTCHA) dialog.
|
//! The human-verification (CAPTCHA) window.
|
||||||
//!
|
//!
|
||||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||||
@@ -8,10 +8,26 @@
|
|||||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||||
//! browser because it reports completion by posting a message to its host, not
|
//! browser because it reports completion by posting a message to its host, not
|
||||||
//! by redirecting: a browser has nowhere to post it back to.
|
//! by redirecting: a browser has nowhere to post it back to.
|
||||||
|
//!
|
||||||
|
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
|
||||||
|
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
|
||||||
|
//! on hardened systems that keep bubblewrap from the journal socket
|
||||||
|
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
|
||||||
|
//! The URL goes to the child on stdin, so it never shows in a process list, and
|
||||||
|
//! the token comes back on stdout.
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
|
use std::process::{ExitStatus, Stdio};
|
||||||
use webkit6::prelude::*;
|
use webkit6::prelude::*;
|
||||||
|
|
||||||
|
/// The argument that makes `pdfs-app` the verification window instead of the app.
|
||||||
|
pub(crate) const VERIFY_ARG: &str = "--human-verification";
|
||||||
|
|
||||||
|
/// Marks the token among whatever else lands on the child's stdout: GLib and
|
||||||
|
/// WebKit write debug output there.
|
||||||
|
const TOKEN_PREFIX: &str = "hv-token ";
|
||||||
|
|
||||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||||
///
|
///
|
||||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||||
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
|
|||||||
});
|
});
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
/// How the verification window ended.
|
||||||
///
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
pub(crate) enum Verification {
|
||||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
Solved(String),
|
||||||
pub(crate) fn prompt_human_verification(
|
/// Closed without solving.
|
||||||
ui: &Rc<Ui>,
|
Cancelled,
|
||||||
url: &str,
|
/// Never came up, or died: most likely WebKit could not start its sandbox.
|
||||||
token_tx: std::sync::mpsc::Sender<String>,
|
Failed,
|
||||||
) {
|
}
|
||||||
|
|
||||||
|
/// Show the challenge in the verification window and wait until it closes.
|
||||||
|
pub(crate) async fn verify_human(url: String) -> Verification {
|
||||||
|
match gio::spawn_blocking(move || run_verification_child(&url)).await {
|
||||||
|
Ok(Ok(verification)) => verification,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::error!("cannot run the verification window: {e}");
|
||||||
|
Verification::Failed
|
||||||
|
}
|
||||||
|
Err(_) => Verification::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
|
||||||
|
let mut child = Command::new(own_exe())
|
||||||
|
.arg(VERIFY_ARG)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
// A failed write means the child is already gone; its exit status says
|
||||||
|
// how. Dropping `stdin` closes it.
|
||||||
|
let _ = writeln!(stdin, "{url}");
|
||||||
|
}
|
||||||
|
// Stops reading at the token rather than at EOF: WebKit's helper processes
|
||||||
|
// inherit the pipe and may hold it open a moment after the child quits.
|
||||||
|
let token = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.and_then(|out| find_token(BufReader::new(out)));
|
||||||
|
let status = child.wait()?;
|
||||||
|
Ok(outcome(token, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This very binary, so a development build opens its own window. After an
|
||||||
|
/// upgrade replaced the file, `current_exe` names a deleted path, and the
|
||||||
|
/// installed `pdfs-app` is the next best.
|
||||||
|
fn own_exe() -> PathBuf {
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.filter(|path| path.is_file())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_token(out: impl BufRead) -> Option<String> {
|
||||||
|
out.lines()
|
||||||
|
.map_while(Result::ok)
|
||||||
|
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token wins over the exit status: the user solved the page, even if
|
||||||
|
/// WebKit's teardown crashed after it.
|
||||||
|
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
|
||||||
|
match token {
|
||||||
|
Some(token) => Verification::Solved(token),
|
||||||
|
None if status.success() => Verification::Cancelled,
|
||||||
|
None => {
|
||||||
|
tracing::error!(%status, "the verification window failed");
|
||||||
|
Verification::Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run as the verification window: read the page's URL from stdin, show it, and
|
||||||
|
/// print the token once the user has solved it. Closing the window exits
|
||||||
|
/// without a token, which the app reads as a cancelled sign-in.
|
||||||
|
pub(crate) fn run_verification_window() -> glib::ExitCode {
|
||||||
|
let mut url = String::new();
|
||||||
|
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
|
||||||
|
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
let url = url.trim().to_owned();
|
||||||
|
|
||||||
|
// Not unique: the app that started it already holds APP_ID.
|
||||||
|
let app = adw::Application::builder()
|
||||||
|
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||||
|
.build();
|
||||||
|
app.connect_activate(move |app| build_verification_window(app, &url));
|
||||||
|
// GApplication would refuse VERIFY_ARG as an unknown option.
|
||||||
|
let argv0 = std::env::args().next().unwrap_or_default();
|
||||||
|
app.run_with_args(&[argv0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_verification_window(app: &adw::Application, url: &str) {
|
||||||
let content = webkit6::UserContentManager::new();
|
let content = webkit6::UserContentManager::new();
|
||||||
content.add_script(&webkit6::UserScript::new(
|
content.add_script(&webkit6::UserScript::new(
|
||||||
BRIDGE,
|
BRIDGE,
|
||||||
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
|
body.append(&adw::HeaderBar::new());
|
||||||
|
body.append(&webview);
|
||||||
|
let window = adw::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
.title(gettext("Verification"))
|
.title(gettext("Verification"))
|
||||||
.content_width(420)
|
.icon_name(APP_ID)
|
||||||
.content_height(560)
|
.default_width(420)
|
||||||
|
.default_height(560)
|
||||||
|
.content(&body)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
let app = app.downgrade();
|
||||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
||||||
body.append(&header);
|
|
||||||
body.append(&webview);
|
|
||||||
dialog.set_child(Some(&body));
|
|
||||||
|
|
||||||
// Taken on the first success *or* on close, so the token is sent at most
|
|
||||||
// once and a cancelled dialog always drops the sender.
|
|
||||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
|
||||||
|
|
||||||
let tx = token_tx.clone();
|
|
||||||
let dlg_weak = dialog.downgrade();
|
|
||||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||||
let Some(token) = extract_token(&value.to_str()) else {
|
let Some(token) = extract_token(&value.to_str()) else {
|
||||||
// Every `postMessage` on the page reaches here, most of them the
|
// Every `postMessage` on the page reaches here, most of them the
|
||||||
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
|
|||||||
// is simply not ours.
|
// is simply not ours.
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(tx) = tx.borrow_mut().take() {
|
let mut stdout = std::io::stdout().lock();
|
||||||
let _ = tx.send(token);
|
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
|
||||||
|
tracing::error!("cannot hand the verification token back: {e}");
|
||||||
}
|
}
|
||||||
if let Some(dlg) = dlg_weak.upgrade() {
|
if let Some(app) = app.upgrade() {
|
||||||
dlg.close();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Covers the close button, Escape, and the user giving up.
|
|
||||||
dialog.connect_closed(move |_| {
|
|
||||||
token_tx.borrow_mut().take();
|
|
||||||
});
|
|
||||||
|
|
||||||
webview.load_uri(url);
|
webview.load_uri(url);
|
||||||
|
window.present();
|
||||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
|
||||||
// Returns immediately; the login worker stays blocked on the channel until
|
|
||||||
// the handler above sends, or the dialog closes and drops the sender.
|
|
||||||
dialog.present(parent.as_ref());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the verification token out of a message posted by the verification page.
|
/// Pull the verification token out of a message posted by the verification page.
|
||||||
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::extract_token;
|
use super::{Verification, extract_token, find_token, outcome};
|
||||||
|
use std::os::unix::process::ExitStatusExt as _;
|
||||||
|
use std::process::ExitStatus;
|
||||||
|
|
||||||
|
/// The wait status of a process killed by SIGABRT.
|
||||||
|
const ABORTED: i32 = 6;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_completion_message_yields_its_token() {
|
fn a_completion_message_yields_its_token() {
|
||||||
@@ -163,4 +256,39 @@ mod tests {
|
|||||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_token_is_found_among_other_output() {
|
||||||
|
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
|
||||||
|
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
|
||||||
|
assert_eq!(find_token("only chatter\n".as_bytes()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_closed_without_a_token_cancels() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(None, ExitStatus::from_raw(0)),
|
||||||
|
Verification::Cancelled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
|
||||||
|
/// failure the app explains, not as the user giving up.
|
||||||
|
#[test]
|
||||||
|
fn a_window_that_died_fails() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(None, ExitStatus::from_raw(ABORTED)),
|
||||||
|
Verification::Failed
|
||||||
|
);
|
||||||
|
let exited_1 = ExitStatus::from_raw(1 << 8);
|
||||||
|
assert_eq!(outcome(None, exited_1), Verification::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_token_counts_even_if_the_window_died_after_it() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
|
||||||
|
Verification::Solved("tok-1".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -485,6 +485,10 @@ fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The socket lives in the state dir, which nothing has made yet when the app
|
||||||
|
// starts the tray on a fresh install (B194).
|
||||||
|
dirs.ensure().context("create the app's directories")?;
|
||||||
|
|
||||||
// Ensure only one instance of the tray runs.
|
// Ensure only one instance of the tray runs.
|
||||||
let tray_sock = dirs.tray_socket();
|
let tray_sock = dirs.tray_socket();
|
||||||
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
||||||
|
|||||||
+16
-5
@@ -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
|
||||||
@@ -542,7 +550,7 @@ sequenceDiagram
|
|||||||
actor User
|
actor User
|
||||||
participant UI as pdfs-app
|
participant UI as pdfs-app
|
||||||
participant Auth as pdfs-core auth
|
participant Auth as pdfs-core auth
|
||||||
participant Web as WebKitGTK dialog
|
participant Web as pdfs-app --human-verification
|
||||||
participant API as Proton API
|
participant API as Proton API
|
||||||
|
|
||||||
User->>UI: email and password
|
User->>UI: email and password
|
||||||
@@ -550,9 +558,9 @@ sequenceDiagram
|
|||||||
Auth->>API: SRP handshake
|
Auth->>API: SRP handshake
|
||||||
API-->>Auth: 9001, challenge URL
|
API-->>Auth: 9001, challenge URL
|
||||||
Auth-->>UI: HumanVerificationRequired
|
Auth-->>UI: HumanVerificationRequired
|
||||||
UI->>Web: load challenge
|
UI->>Web: challenge URL (stdin)
|
||||||
User->>Web: solve it
|
User->>Web: solve it
|
||||||
Web-->>UI: token (postMessage)
|
Web-->>UI: token (stdout)
|
||||||
UI->>Auth: login_verified(token)
|
UI->>Auth: login_verified(token)
|
||||||
Auth->>API: new SRP handshake with the token
|
Auth->>API: new SRP handshake with the token
|
||||||
API-->>Auth: session
|
API-->>Auth: session
|
||||||
@@ -561,6 +569,9 @@ sequenceDiagram
|
|||||||
- The challenge used up the first SRP handshake, so the retry starts a new one with the token
|
- The challenge used up the first SRP handshake, so the retry starts a new one with the token
|
||||||
attached.
|
attached.
|
||||||
- The page may post the token as an object or as a JSON string; both are accepted.
|
- The page may post the token as an object or as a JSON string; both are accepted.
|
||||||
|
- The page runs in a WebKitGTK view inside a child `pdfs-app`. WebKit aborts its process when its
|
||||||
|
sandbox cannot start, as on hardened systems such as secureblue (B195). In the child, that ends
|
||||||
|
the verification, and the app says why, instead of closing the app.
|
||||||
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
|
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
|
||||||
session then works for the CLI and the service.
|
session then works for the CLI and the service.
|
||||||
|
|
||||||
|
|||||||
+153
-1
@@ -47,7 +47,7 @@ original wording.
|
|||||||
it in the index in the same change.
|
it in the index in the same change.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
### B194 — What the user sees, in one line
|
### B196 — What the user sees, in one line
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Open
|
||||||
**Found:** 2026-10-06, how it was found
|
**Found:** 2026-10-06, how it was found
|
||||||
@@ -100,6 +100,10 @@ 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 |
|
||||||
| [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 |
|
||||||
| [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive |
|
| [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive |
|
||||||
@@ -214,6 +218,7 @@ a live account since.
|
|||||||
|
|
||||||
| Entry | Title |
|
| Entry | Title |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
|
||||||
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
|
||||||
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
|
||||||
| [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it |
|
| [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it |
|
||||||
@@ -296,6 +301,149 @@ a live account since.
|
|||||||
|
|
||||||
Newest first.
|
Newest first.
|
||||||
|
|
||||||
|
### B198 — Switching to online-only deletes local files that never reached Drive
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified).
|
||||||
|
**Found:** 2026-10-08, reading the switch while fixing B196.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`;
|
||||||
|
`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced`
|
||||||
|
|
||||||
|
**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**.
|
||||||
|
`.git/` is gone from the disk and was never on Drive.
|
||||||
|
|
||||||
|
**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the
|
||||||
|
last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored
|
||||||
|
by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for
|
||||||
|
writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted
|
||||||
|
all of those.
|
||||||
|
|
||||||
|
**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against
|
||||||
|
the baseline, as a move out of a mirror folder already does, and refuses when something is only on
|
||||||
|
this disk. The message names the path; an ignored one is called excluded from sync. A tracked file
|
||||||
|
deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row
|
||||||
|
stays for good and would hold the switch forever.
|
||||||
|
|
||||||
|
**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`,
|
||||||
|
`an_ignored_folder_only_this_disk_has_holds_the_switch_back`,
|
||||||
|
`a_deleted_ignored_path_does_not_hold_the_switch_back`,
|
||||||
|
`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`,
|
||||||
|
`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch
|
||||||
|
itself has not been driven against a live account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B197 — Switching to online-only trashes synced files that became ignored
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified).
|
||||||
|
**Found:** 2026-10-08, reading the push pass while fixing B196.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||||
|
`local_deletions`
|
||||||
|
|
||||||
|
**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch
|
||||||
|
the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the
|
||||||
|
local copy.
|
||||||
|
|
||||||
|
**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in
|
||||||
|
the local walk". The walk skips ignored paths, so every synced path that later became ignored read
|
||||||
|
as deleted. A full pass does not do this: it drops ignored paths from classification, which is
|
||||||
|
what the user guide promises ("Ignoring never deletes anything").
|
||||||
|
|
||||||
|
**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks:
|
||||||
|
without ignored paths, and without paths the scan could not read (B196).
|
||||||
|
|
||||||
|
**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and
|
||||||
|
`local_deletions_come_out_shallowest_first`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B196 — One unreadable folder stops the whole synced folder from syncing
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified). The reporter has not run the fix yet.
|
||||||
|
**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced
|
||||||
|
`~/Documents` held a restic cache a Docker container had created, which the user may not read.
|
||||||
|
Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission
|
||||||
|
denied`, and nothing else in `Documents` synced.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`,
|
||||||
|
`Unscanned`
|
||||||
|
|
||||||
|
**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync.
|
||||||
|
|
||||||
|
**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read
|
||||||
|
as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is
|
||||||
|
not transient: the pass failed on every poll, for good. The same held for a file deleted between
|
||||||
|
the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8.
|
||||||
|
|
||||||
|
**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it
|
||||||
|
from classification, the wipe guard and the push pass's deletions, as it already did for ignored
|
||||||
|
paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists
|
||||||
|
but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O
|
||||||
|
error, still fails the pass. The activity feed names the unreadable paths once each time the set
|
||||||
|
changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround
|
||||||
|
and still silences the report.
|
||||||
|
|
||||||
|
**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`,
|
||||||
|
`a_listable_but_unsearchable_folder_is_left_out_whole`,
|
||||||
|
`a_path_the_scan_left_out_is_never_a_local_deletion`,
|
||||||
|
`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and
|
||||||
|
`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and
|
||||||
|
`an_absent_root_fails_the_scan` still hold.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified). Nobody has run the fix on secureblue yet.
|
||||||
|
**Found:** 2026-10-07, [#29](https://github.com/narrrl/proton-drive-linux/issues/29). On
|
||||||
|
secureblue 44 (a hardened Fedora Atomic), `pdfs-app` aborted a few seconds after the sign-in, with
|
||||||
|
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
|
||||||
|
`Failed to fully launch dbus-proxy`.
|
||||||
|
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`
|
||||||
|
|
||||||
|
**Repro:** Sign in on secureblue from a network Proton asks a CAPTCHA for, such as a VPN.
|
||||||
|
|
||||||
|
**Cause.** The CAPTCHA page runs in a WebKitGTK view (B8). Before it loads anything, WebKit starts
|
||||||
|
its sandbox: bubblewrap, with the journal socket bound in, and `xdg-dbus-proxy`. secureblue keeps
|
||||||
|
bubblewrap from the socket, the proxy does not start, and WebKit ends its process with `g_error`.
|
||||||
|
The view was in the app's own process, so the whole app went down, and nothing in our code can
|
||||||
|
catch that.
|
||||||
|
|
||||||
|
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
|
||||||
|
and prints the token on stdout. A child that exits without a token after a crash or a non-zero
|
||||||
|
status ends the sign-in with a message that says the page could not be opened. TROUBLESHOOTING.md
|
||||||
|
gives the cause and a one-time workaround. The sandbox is not switched off by the app: users choose
|
||||||
|
a hardened system for that isolation.
|
||||||
|
|
||||||
|
**Test:** unit tests `a_window_that_died_fails`, `a_window_closed_without_a_token_cancels`,
|
||||||
|
`a_token_counts_even_if_the_window_died_after_it` and `the_token_is_found_among_other_output`.
|
||||||
|
On 2026-10-08 the child ran under real WebKit on Arch against a local page that posts a completion,
|
||||||
|
and handed back its token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B194 — On a fresh install the tray exits and the Takeout page cannot remember its archives
|
||||||
|
|
||||||
|
**Status:** Fixed.
|
||||||
|
**Found:** 2026-10-07, in the log of [#29](https://github.com/narrrl/proton-drive-linux/issues/29):
|
||||||
|
`failed to bind tray single-instance socket: No such file or directory` and
|
||||||
|
`cannot remember staged Takeout archives: No such file or directory`.
|
||||||
|
**Where:** `crates/pdfs-gui/src/main.rs`, `build_window` in `crates/pdfs-gui/src/app/main.rs`
|
||||||
|
|
||||||
|
**Repro:** Start `pdfs-app` with no `~/.local/state/proton-drive-linux`, as on a fresh install. The
|
||||||
|
tray is gone, and staging a Takeout archive logs the warning.
|
||||||
|
|
||||||
|
**Cause.** Both write to the state directory, and only the daemon and the CLI made it
|
||||||
|
(`AppDirs::ensure`). The app starts the tray before the first sign-in, so before the daemon has
|
||||||
|
ever run.
|
||||||
|
|
||||||
|
**Fix.** `pdfs-tray` and `pdfs-app` call `AppDirs::ensure` at start, which also makes the
|
||||||
|
directories owner-only (B6).
|
||||||
|
|
||||||
|
**Verified:** 2026-10-08, `pdfs-tray` with `XDG_STATE_HOME`, `XDG_CONFIG_HOME` and
|
||||||
|
`XDG_CACHE_HOME` pointing at an empty directory. It exited with the error before the fix, and
|
||||||
|
afterwards ran with `tray.sock` in a `0700` state directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### B193 — Deleting a file whose create failed can trash a file of the user's on Drive
|
### B193 — Deleting a file whose create failed can trash a file of the user's on Drive
|
||||||
|
|
||||||
**Status:** Fixed (unverified).
|
**Status:** Fixed (unverified).
|
||||||
@@ -4766,6 +4914,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.
|
||||||
|
|||||||
@@ -11,6 +11,57 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
|
||||||
|
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
|
||||||
|
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
|
||||||
|
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
|
||||||
|
account, without those folders.
|
||||||
|
|
||||||
|
## [3.1.2] — 2026-10-08
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
|
||||||
|
cannot start the sandbox for the verification page and ends its process, which took the app
|
||||||
|
down with it. The page now opens in a separate window process: if that process fails, the
|
||||||
|
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
|
||||||
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
|
||||||
|
directory themselves instead of waiting for the service to do it. This also lets the Takeout
|
||||||
|
page remember staged archives before the first mount.
|
||||||
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
|
||||||
|
## [3.1.1] — 2026-10-07
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The client identifies itself with its release.** Sign-in and every API call used to send the
|
||||||
|
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
|
||||||
|
version, every release would fail to sign in at once and no update could fix it. The client now
|
||||||
|
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
|
||||||
|
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
|
||||||
|
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
|
||||||
|
kept.
|
||||||
|
- The About dialog shows the release version, and its debug info shows the identity that is
|
||||||
|
actually sent.
|
||||||
|
|
||||||
## [3.1.0] — 2026-10-07
|
## [3.1.0] — 2026-10-07
|
||||||
|
|
||||||
Schema: **42**, unchanged.
|
Schema: **42**, unchanged.
|
||||||
@@ -1635,6 +1686,12 @@ 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.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.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
|
||||||
|
|
||||||
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
||||||
|
|
||||||
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ pdfs status
|
|||||||
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
|
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
|
||||||
the stored session then works for the CLI too.
|
the stored session then works for the CLI too.
|
||||||
|
|
||||||
|
**"The verification page couldn't be opened"** — WebKit, which shows the CAPTCHA page, could not
|
||||||
|
start its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket,
|
||||||
|
and the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
|
||||||
|
denied` and `Failed to fully launch dbus-proxy`. Check that `bwrap` and `xdg-dbus-proxy` are
|
||||||
|
installed. If they are, sign in once with the sandbox switched off for this one run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
|
||||||
|
so use this only for the sign-in. The stored session keeps working without it. Signing in from
|
||||||
|
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
|
||||||
|
|
||||||
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
||||||
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
||||||
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
enabled). `pdfs diagnose` shows `keyring session` as failing in that case.
|
||||||
|
|||||||
+9
-1
@@ -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
@@ -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.1
|
pkgver=3.1.2
|
||||||
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.0
|
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.0
|
provides = proton-drive-for-linux=3.1.2
|
||||||
provides = proton-drive-linux=3.1.0
|
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.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.1.0/proton-drive-linux-3.1.0-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.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.0/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.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.0/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.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.0/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.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.0/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.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.0/LICENSE
|
source = LICENSE-3.1.2::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.1.2/LICENSE
|
||||||
sha256sums = bd124e249089d2817101e4f8b705ac511decb79a410ab26b0eac9125888f7300
|
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.0
|
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=('bd124e249089d2817101e4f8b705ac511decb79a410ab26b0eac9125888f7300'
|
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.0.r0.g274bfd0
|
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.0.r0.g274bfd0
|
provides = proton-drive-for-linux=3.1.2.r0.g07c9b67
|
||||||
provides = proton-drive-linux=3.1.0.r0.g274bfd0
|
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.0.r0.g274bfd0
|
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.0
|
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.0
|
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.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.1.0.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 = c7c6710e6f55d2ae3dbc29dd4a552a3e676bf4ecb2ff47ac2486dd90bd354ce2
|
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.0
|
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=('c7c6710e6f55d2ae3dbc29dd4a552a3e676bf4ecb2ff47ac2486dd90bd354ce2')
|
sha256sums=('260ed05c8b041cf38c5904f7543780de0d0cb06d9695a427a98250d2a9ec0eb1')
|
||||||
|
|
||||||
_srcname="proton-drive-linux-$pkgver"
|
_srcname="proton-drive-linux-$pkgver"
|
||||||
|
|
||||||
|
|||||||
+220
-171
File diff suppressed because it is too large.
Load diff
+208
-170
File diff suppressed because it is too large.
Load diff
+239
-184
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user