mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
59 files changed
+22023
-12877
No files matched your search
Generated
+77
-7
@@ -127,6 +127,12 @@ dependencies = [
|
||||
"equator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -270,6 +276,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
@@ -923,6 +941,23 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1656,6 +1691,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -2178,6 +2219,17 @@ dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
@@ -2925,6 +2977,15 @@ dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -3434,7 +3495,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.1",
|
||||
@@ -3449,11 +3510,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"ignore",
|
||||
"keyring",
|
||||
"parking_lot",
|
||||
"proton-drive-rs",
|
||||
"proton-sdk",
|
||||
"rusqlite",
|
||||
@@ -3467,12 +3529,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"image",
|
||||
"libc",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"pdfs-core",
|
||||
"proton-drive-rs",
|
||||
"serde_json",
|
||||
@@ -3482,7 +3545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3742,7 +3805,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.1.7"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@@ -3751,6 +3814,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"lru",
|
||||
"pgp",
|
||||
"proton-sdk",
|
||||
"reqwest",
|
||||
@@ -3766,7 +3830,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.1.7"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
@@ -3779,6 +3843,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hkdf 0.13.0",
|
||||
"hmac 0.13.0",
|
||||
"lru",
|
||||
"num-bigint 0.5.1",
|
||||
"num-traits",
|
||||
"pgp",
|
||||
@@ -3882,7 +3947,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5043,12 +5108,17 @@ version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
||||
+8
-9
@@ -3,15 +3,15 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = "0.1.6"
|
||||
proton-drive-rs = "0.1.6"
|
||||
proton-sdk = { version = "0.1", path = "../proton-sdk-rs/crates/proton-sdk" }
|
||||
proton-drive-rs = { version = "0.1", path = "../proton-sdk-rs/crates/proton-drive-rs" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -22,6 +22,11 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
||||
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
||||
# the recovered worker needs.
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
@@ -37,9 +42,3 @@ clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
fuser = "0.17"
|
||||
libc = "0.2"
|
||||
|
||||
# Development override: build against the local SDK checkout until 0.1.6 is
|
||||
# published to crates.io. Remove this block to consume the released crate.
|
||||
[patch.crates-io]
|
||||
proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" }
|
||||
proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" }
|
||||
+104
-34
@@ -11,7 +11,8 @@ use pdfs_core::auth;
|
||||
use pdfs_core::cache::ContentCache;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
Request as CtlRequest, Response as CtlResponse, ShareEntryKind, SyncPhase,
|
||||
ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind,
|
||||
SyncPhase, pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
|
||||
@@ -146,6 +147,15 @@ enum Command {
|
||||
/// Permanently delete everything in the trash. This cannot be undone.
|
||||
EmptyTrash,
|
||||
|
||||
/// Drop a cached listing so the next read re-fetches it from the server.
|
||||
/// Use it when another client changed something and the daemon hasn't
|
||||
/// noticed yet.
|
||||
Refresh {
|
||||
/// What to refresh: a folder path (inside the mountpoint or relative to
|
||||
/// it), `trash`, or `photos`. The mount root if omitted.
|
||||
target: Option<String>,
|
||||
},
|
||||
|
||||
/// Manage the account's registered devices.
|
||||
Devices {
|
||||
#[command(subcommand)]
|
||||
@@ -341,6 +351,22 @@ enum BookmarkCmd {
|
||||
Rm { token: String },
|
||||
}
|
||||
|
||||
/// Render a daemon failure for a terminal.
|
||||
///
|
||||
/// Offline is the one class worth rewording: the daemon's prose names whichever
|
||||
/// call happened to hit the network, which tells someone at a shell nothing they
|
||||
/// can act on. Every other class already says something specific, so it is
|
||||
/// printed as sent.
|
||||
fn cli_error(kind: ErrorKind, message: &str) -> String {
|
||||
match kind {
|
||||
ErrorKind::Offline => "offline: the Proton Drive API is unreachable".to_string(),
|
||||
// The daemon's wording names the failed call ("upload x: ..."), which
|
||||
// does not say the thing the user has to act on.
|
||||
ErrorKind::Quota => format!("out of storage: {message}"),
|
||||
_ => message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -376,6 +402,7 @@ fn main() -> Result<()> {
|
||||
Command::Restore { uids } => cmd_restore(uids),
|
||||
Command::DeleteForever { uids } => cmd_delete_forever(uids),
|
||||
Command::EmptyTrash => cmd_empty_trash(),
|
||||
Command::Refresh { target } => cmd_refresh(target),
|
||||
Command::Devices { action } => cmd_devices(action),
|
||||
Command::Sync { action } => cmd_sync(action),
|
||||
Command::Share {
|
||||
@@ -418,7 +445,7 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
DeviceCmd::Rename { uid, name } => {
|
||||
@@ -451,14 +478,23 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
} else {
|
||||
f.last_sync.to_string()
|
||||
};
|
||||
// A queued switch is reported as the mode the folder is moving
|
||||
// to, so `sync list` never reads as if the request was dropped.
|
||||
let mode = match &f.pending_mode {
|
||||
Some(pending) => format!("{} → {pending}", f.mode),
|
||||
None => f.mode.clone(),
|
||||
};
|
||||
println!(
|
||||
"[{}] {} ({}, {}, synced: {sync})",
|
||||
f.id, f.local_path, f.mode, f.state
|
||||
"[{}] {} ({mode}, {}, synced: {sync})",
|
||||
f.id, f.local_path, f.state
|
||||
);
|
||||
// A pass in flight says what it is doing, indented under it.
|
||||
if let Some(p) = &f.progress {
|
||||
match p.phase {
|
||||
SyncPhase::Scanning => println!(" scanning…"),
|
||||
SyncPhase::Scanning if p.total == 0 => println!(" scanning…"),
|
||||
SyncPhase::Scanning => {
|
||||
println!(" scanning: {} of {}", p.done, p.total.max(p.done))
|
||||
}
|
||||
SyncPhase::Applying => println!(
|
||||
" {} of {} {}",
|
||||
p.done + 1,
|
||||
@@ -469,7 +505,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
SyncCmd::Rm { id, delete_remote } => {
|
||||
@@ -525,7 +561,7 @@ fn cmd_members(path: PathBuf) -> Result<()> {
|
||||
None => println!("public link: none"),
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -564,7 +600,7 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||
CtlResponse::PublicLink { link } => {
|
||||
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
PublicLinkCmd::Remove { path, id } => {
|
||||
@@ -606,7 +642,7 @@ fn cmd_shared() -> Result<()> {
|
||||
println!("{kind} {}{tags} [{}]", it.name, it.uid);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -623,7 +659,7 @@ fn cmd_shared_with_me() -> Result<()> {
|
||||
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -645,7 +681,7 @@ fn cmd_activity(limit: usize) -> Result<()> {
|
||||
println!("{mark} {when} {verb:<8} {}{detail}", a.target);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -709,7 +745,7 @@ fn cmd_invitations(action: InvitationCmd) -> Result<()> {
|
||||
println!("{} shared {kind} \"{name}\" [{}]", i.inviter_email, i.id);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
InvitationCmd::Accept { id } => {
|
||||
@@ -732,7 +768,7 @@ fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> {
|
||||
println!("{name} {} [{}]", b.url, b.token);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
},
|
||||
BookmarkCmd::Add { url, password } => {
|
||||
@@ -753,7 +789,7 @@ fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> {
|
||||
fn ok_or_bail(resp: CtlResponse) -> Result<()> {
|
||||
match resp {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -809,9 +845,19 @@ fn cmd_status() -> Result<()> {
|
||||
// If a mount daemon is running, report live status from its control socket.
|
||||
match control_request(CtlRequest::Status) {
|
||||
Ok(CtlResponse::Status {
|
||||
mountpoint, pinned, ..
|
||||
mountpoint,
|
||||
pinned,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
}) => {
|
||||
println!("Mounted at {mountpoint} ({pinned} pinned)");
|
||||
let state = if online { "" } else { ", offline" };
|
||||
let queued = match pending_summary(pending_uploads, pending_changes) {
|
||||
Some(s) => format!(", {s}"),
|
||||
None => String::new(),
|
||||
};
|
||||
println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})");
|
||||
}
|
||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||
Err(_) => println!("Mount: not running."),
|
||||
@@ -926,7 +972,7 @@ fn cmd_pin(path: PathBuf) -> Result<()> {
|
||||
path: path_arg(&path)?,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -937,7 +983,7 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
|
||||
path: path_arg(&path)?,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -985,7 +1031,7 @@ fn cmd_transfers() -> Result<()> {
|
||||
);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -999,7 +1045,7 @@ fn cmd_pins() -> Result<()> {
|
||||
println!("{}", p.path);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1019,14 +1065,19 @@ fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
|
||||
println!("{kind}{pin} {:>12} {} [{}]", e.size, e.name, e.uid);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||
match control_request(CtlRequest::PhotosTimeline { offset, limit })? {
|
||||
match control_request(CtlRequest::PhotosTimeline {
|
||||
offset,
|
||||
limit,
|
||||
kind: None,
|
||||
range: None,
|
||||
})? {
|
||||
CtlResponse::Photos {
|
||||
available: false, ..
|
||||
} => {
|
||||
@@ -1053,7 +1104,7 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||
println!("{} {} {thumb}", p.capture_time, p.uid);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1062,7 +1113,7 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
||||
fn cmd_open_photo(uid: String) -> Result<()> {
|
||||
match control_request(CtlRequest::OpenPhoto { uid })? {
|
||||
CtlResponse::FilePath { path } => println!("{path}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1078,7 +1129,7 @@ fn cmd_search(query: String, limit: usize) -> Result<()> {
|
||||
println!("{kind}{pin} {:>12} {}", h.size, h.path);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1090,7 +1141,7 @@ fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> {
|
||||
new_name,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1102,7 +1153,7 @@ fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> {
|
||||
new_parent: path_arg(&new_parent)?,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1113,7 +1164,7 @@ fn cmd_rm(path: PathBuf) -> Result<()> {
|
||||
path: path_arg(&path)?,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1128,7 +1179,7 @@ fn cmd_trash() -> Result<()> {
|
||||
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1137,7 +1188,7 @@ fn cmd_trash() -> Result<()> {
|
||||
fn cmd_restore(uids: Vec<String>) -> Result<()> {
|
||||
match control_request(CtlRequest::Restore { uids })? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1150,7 +1201,7 @@ fn cmd_delete_forever(uids: Vec<String>) -> Result<()> {
|
||||
))?;
|
||||
match control_request(CtlRequest::DeleteForever { uids })? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1160,7 +1211,26 @@ fn cmd_empty_trash() -> Result<()> {
|
||||
confirm("Permanently delete everything in the trash? This cannot be undone.")?;
|
||||
match control_request(CtlRequest::EmptyTrash)? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a cached listing. `trash` and `photos` name those two listings; anything
|
||||
/// else is read as a folder path, so a folder actually called "trash" is still
|
||||
/// reachable as `./trash`.
|
||||
fn cmd_refresh(target: Option<String>) -> Result<()> {
|
||||
let scope = match target.as_deref() {
|
||||
Some("trash") => RefreshScope::Trash,
|
||||
Some("photos") => RefreshScope::Photos,
|
||||
path => RefreshScope::Dir {
|
||||
path: path.unwrap_or("").to_string(),
|
||||
},
|
||||
};
|
||||
match control_request(CtlRequest::Refresh { scope })? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1186,7 +1256,7 @@ fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
|
||||
name,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
@@ -1209,7 +1279,7 @@ fn cmd_upload(sources: Vec<PathBuf>, parent: PathBuf) -> Result<()> {
|
||||
sources: abs,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -20,3 +20,4 @@ ignore.workspace = true
|
||||
sha2.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
parking_lot.workspace = true
|
||||
+970
-92
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
//! Static client identity and per-user filesystem paths.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -165,18 +165,104 @@ impl AppDirs {
|
||||
.unwrap_or_else(|| self.default_mountpoint())
|
||||
}
|
||||
|
||||
/// Create state + cache dirs if missing.
|
||||
/// Create state + cache dirs if missing, owner-only.
|
||||
///
|
||||
/// The mode is set explicitly rather than left to the umask. These
|
||||
/// directories hold decrypted file content, a plaintext index of every node
|
||||
/// name in the Drive, and the control socket — and `create_dir_all` under a
|
||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||
/// happening to be `0700`, which is a convention of the user's system and
|
||||
/// not something this client established (bugs.md B6).
|
||||
///
|
||||
/// Applied on every start, not just at creation: a directory that already
|
||||
/// exists with a permissive mode — restored from a backup that flattened
|
||||
/// modes, or created by an older build — is tightened here.
|
||||
pub fn ensure(&self) -> Result<()> {
|
||||
std::fs::create_dir_all(self.state_dir())?;
|
||||
std::fs::create_dir_all(self.cache_dir())?;
|
||||
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
||||
for dir in [self.state_dir(), self.cache_dir()] {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
restrict_dir(&dir);
|
||||
}
|
||||
let config_dir = self.dirs.config_dir().to_path_buf();
|
||||
if std::fs::create_dir_all(&config_dir).is_ok() {
|
||||
restrict_dir(&config_dir);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `dir` owner-only (`0700`). Best effort: a mode we cannot set (an
|
||||
/// exotic filesystem, a directory we do not own) is not worth refusing to
|
||||
/// start over, and the caller has no better answer than continuing.
|
||||
fn restrict_dir(dir: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
|
||||
tracing::warn!(path = %dir.display(), error = %e, "could not restrict directory to 0700");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a just-bound Unix socket owner-only (`0600`).
|
||||
///
|
||||
/// Connecting to the control socket drives the daemon with its authenticated
|
||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||
/// other file (bugs.md B6).
|
||||
///
|
||||
/// There is a window between `bind` and this call during which the socket
|
||||
/// carries the umask's mode. It is closed in practice by the containing
|
||||
/// directory: [`Dirs::ensure`] has already made the state directory `0700`, so
|
||||
/// nothing else can reach the socket to exploit the window.
|
||||
pub fn restrict_socket(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn mode_of(p: &Path) -> u32 {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// directories hold decrypted content, a plaintext index of every node name,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
#[test]
|
||||
fn restrict_dir_makes_a_directory_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-cfg-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Start deliberately world-readable, as `create_dir_all` under a typical
|
||||
// umask would leave it.
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
restrict_dir(&dir);
|
||||
|
||||
assert_eq!(mode_of(&dir), 0o700, "group and other must have no access");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Connecting to the control socket confers the daemon's authenticated
|
||||
/// session with no credential, so it is an authority boundary rather than
|
||||
/// merely private data.
|
||||
#[test]
|
||||
fn restrict_socket_makes_a_socket_owner_only() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-sock-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let sock = dir.join("control.sock");
|
||||
let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
|
||||
|
||||
restrict_socket(&sock).unwrap();
|
||||
|
||||
assert_eq!(mode_of(&sock), 0o600, "no other local user may connect");
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_config_serialization() {
|
||||
|
||||
+449
-22
@@ -48,18 +48,46 @@ pub enum Request {
|
||||
/// a thumbnail path comes back only for photos already in the cache, so the
|
||||
/// reply never waits on the network. Front-ends ask for the thumbnails they
|
||||
/// actually display with [`Request::PhotoThumbs`].
|
||||
PhotosTimeline { offset: usize, limit: usize },
|
||||
PhotosTimeline {
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
/// Restrict the page to one kind (Photos / Videos / Raw). `None` (the
|
||||
/// default, and what older front-ends send) returns everything. The
|
||||
/// offset is relative to the filtered timeline, so paging a single tab
|
||||
/// doesn't have to walk past the other kinds.
|
||||
#[serde(default)]
|
||||
kind: Option<PhotoKind>,
|
||||
/// Restrict the page to a `[from, to)` capture-time window (epoch
|
||||
/// seconds) — the date scrubber's jump to a month. `None` spans the whole
|
||||
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
||||
#[serde(default)]
|
||||
range: Option<(i64, i64)>,
|
||||
},
|
||||
/// The months the timeline spans (newest first, with per-month counts) so a
|
||||
/// front-end can build a date scrubber without paging the whole library.
|
||||
/// `kind` scopes the counts to one tab when set.
|
||||
PhotoMonths {
|
||||
#[serde(default)]
|
||||
kind: Option<PhotoKind>,
|
||||
},
|
||||
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
||||
/// already cached (one batched round-trip) and replying with their on-disk
|
||||
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||
PhotoThumbs { uids: Vec<String> },
|
||||
/// Download a photo's full content into the cache; replies with its path.
|
||||
OpenPhoto { uid: String },
|
||||
/// Upload a photo with the given name, media type, and content bytes.
|
||||
/// Upload the photo at `source_path` under the given name and media type.
|
||||
///
|
||||
/// A path, not the bytes: this protocol is line-delimited JSON, and
|
||||
/// `serde_json` writes a `Vec<u8>` as an array of decimal integers — a ~5-6x
|
||||
/// inflation that both peers then hold in memory at once, which turned a
|
||||
/// large photo or a video into an OOM of the GUI, the daemon, or both. The
|
||||
/// daemon shares a filesystem with every front-end it serves (the socket is
|
||||
/// a Unix socket), so it opens and streams the file itself.
|
||||
UploadPhoto {
|
||||
name: String,
|
||||
media_type: String,
|
||||
bytes: Vec<u8>,
|
||||
source_path: String,
|
||||
capture_time: Option<i64>,
|
||||
},
|
||||
/// Download a Drive file's full content into the cache; replies with the
|
||||
@@ -85,13 +113,10 @@ pub enum Request {
|
||||
/// Create a new folder named `name` under the mountpoint-relative `parent`.
|
||||
/// Replies with [`Response::Ok`].
|
||||
CreateFolder { parent: String, name: String },
|
||||
/// Upload a file named `name` with content `bytes` into the
|
||||
/// mountpoint-relative `parent` folder. Replies with [`Response::Ok`].
|
||||
UploadFile {
|
||||
parent: String,
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
// There is deliberately no bytes-carrying single-file upload: it was
|
||||
// `UploadFile { parent, name, bytes }`, and it OOMed on anything large for
|
||||
// the reason described on `UploadPhoto`. `UploadPaths` covers the same case
|
||||
// by path, including the one-file batch.
|
||||
/// Bulk-upload local files and/or directory trees into the mountpoint-relative
|
||||
/// `parent` folder. `sources` are absolute paths on the daemon's own
|
||||
/// filesystem (the daemon is local): each file is uploaded, each directory is
|
||||
@@ -132,6 +157,14 @@ pub enum Request {
|
||||
/// Replies with [`Response::Ok`].
|
||||
EmptyTrash,
|
||||
|
||||
/// Drop a cached listing so the *next* read of it re-enumerates from the
|
||||
/// server. Replies with [`Response::Ok`]. This is what a front-end's Refresh
|
||||
/// button raises: the daemon serves listings from its persisted cache, which
|
||||
/// only notices another client's changes when its TTL lapses, so a user who
|
||||
/// knows the cache is stale needs a way to say so. Cheap and idempotent —
|
||||
/// it invalidates, it does not fetch.
|
||||
Refresh { scope: RefreshScope },
|
||||
|
||||
// ---- devices ----------------------------------------------------------
|
||||
/// List the account's registered devices. Replies with [`Response::Devices`].
|
||||
ListDevices,
|
||||
@@ -258,6 +291,23 @@ pub enum ShareEntryKind {
|
||||
ExternalInvite,
|
||||
}
|
||||
|
||||
/// Which cached listing a [`Request::Refresh`] drops.
|
||||
///
|
||||
/// Only the listings the daemon caches need naming here — the sharing, devices
|
||||
/// and activity listings are always fetched live, so a front-end refreshes those
|
||||
/// by simply re-asking.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RefreshScope {
|
||||
/// One folder's child listing, by mountpoint-relative path (`""` = root).
|
||||
/// Only the folder itself, not its subtree: refreshing what the user is
|
||||
/// looking at shouldn't re-walk everything below it.
|
||||
Dir { path: String },
|
||||
/// The trash listing.
|
||||
Trash,
|
||||
/// The photos timeline.
|
||||
Photos,
|
||||
}
|
||||
|
||||
/// A registered device in a [`Response::Devices`] listing.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct DeviceInfo {
|
||||
@@ -269,6 +319,11 @@ pub struct DeviceInfo {
|
||||
pub device_type: String,
|
||||
/// Last sync time, epoch seconds; `None` if it never synced.
|
||||
pub last_sync: Option<i64>,
|
||||
/// Whether this is the device *this* machine syncs to. Deleting it would
|
||||
/// delete the cloud copy of the folders this machine is syncing, so a
|
||||
/// front-end must not offer that as casually as removing another computer.
|
||||
#[serde(default)]
|
||||
pub this_device: bool,
|
||||
}
|
||||
|
||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
||||
@@ -282,6 +337,12 @@ pub struct SyncFolderInfo {
|
||||
pub remote_uid: String,
|
||||
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
||||
pub mode: String,
|
||||
/// A mode switch the user asked for that the daemon has queued: it applies
|
||||
/// once the folder's current pass has pushed any local changes up. `None`
|
||||
/// when nothing is queued. A front-end should paint the folder as already
|
||||
/// heading there — the request was accepted, not rejected.
|
||||
#[serde(default)]
|
||||
pub pending_mode: Option<String>,
|
||||
/// `idle` | `syncing` | `error` | `conflict`.
|
||||
pub state: String,
|
||||
/// Last successful sync, epoch seconds; `0` if never.
|
||||
@@ -296,7 +357,8 @@ pub struct SyncFolderInfo {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncPhase {
|
||||
/// Walking the local tree, the remote tree and the stored baseline to work
|
||||
/// out what changed. No per-item counts exist yet.
|
||||
/// out what changed. `done` counts the items checked so far; `total` is how
|
||||
/// many the last pass saw, so it is an estimate the walk can overshoot.
|
||||
Scanning,
|
||||
/// Applying the diff: creating folders, uploading, downloading, deleting.
|
||||
Applying,
|
||||
@@ -307,12 +369,15 @@ pub enum SyncPhase {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SyncProgress {
|
||||
pub phase: SyncPhase,
|
||||
/// Items applied so far this pass.
|
||||
/// Items checked ([`SyncPhase::Scanning`]) or applied ([`SyncPhase::Applying`])
|
||||
/// so far this pass.
|
||||
pub done: usize,
|
||||
/// Items known to need applying. This *grows* during a pass: paths are
|
||||
/// classified depth by depth (a folder must exist remotely before its
|
||||
/// children can be queued), so the total is what has been discovered so far,
|
||||
/// not a figure fixed up front.
|
||||
/// How many items `done` is counting towards. Neither phase can fix this up
|
||||
/// front, so it moves: while scanning it is the size of the last pass's
|
||||
/// baseline — an estimate the walk may overshoot when the folder has grown —
|
||||
/// and while applying it *grows*, because paths are classified depth by depth
|
||||
/// (a folder must exist remotely before its children can be queued). `0` means
|
||||
/// no estimate exists (a folder that has never synced), i.e. indeterminate.
|
||||
pub total: usize,
|
||||
/// The name of an item currently being applied, or empty between items.
|
||||
/// Several run at once; this is just the most recently started.
|
||||
@@ -538,6 +603,104 @@ pub struct LocalHit {
|
||||
pub modified: i64,
|
||||
}
|
||||
|
||||
/// What kind of media a timeline entry is, so the Photos page can split into
|
||||
/// Photos / Videos / Raw tabs. Derived from a photo's media type when the daemon
|
||||
/// has resolved it, falling back to its file-name extension (see
|
||||
/// [`PhotoKind::classify`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PhotoKind {
|
||||
/// A normal, directly viewable still image (JPEG, PNG, HEIC, …).
|
||||
Photo,
|
||||
/// A video clip (mp4, mkv, mov, …).
|
||||
Video,
|
||||
/// A camera raw file (CR2, NEF, ARW, DNG, …) — an image, but one that needs
|
||||
/// developing and is worth separating from ready-to-view photos.
|
||||
Raw,
|
||||
}
|
||||
|
||||
/// File-name extensions that denote a video, matched case-insensitively.
|
||||
const VIDEO_EXTS: &[&str] = &[
|
||||
"mkv", "mp4", "mov", "avi", "webm", "m4v", "flv", "wmv", "mpg", "mpeg", "ts", "3gp", "m2ts",
|
||||
"mts", "ogv",
|
||||
];
|
||||
|
||||
/// File-name extensions that denote a camera raw. The server media type for
|
||||
/// these is frequently a generic `application/octet-stream`, so the extension is
|
||||
/// the authoritative signal.
|
||||
const RAW_EXTS: &[&str] = &[
|
||||
"cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "dng", "raf", "orf", "rw2", "srw", "pef",
|
||||
"raw", "rwl", "iiq", "3fr", "dcr", "kdc", "mrw", "x3f",
|
||||
];
|
||||
|
||||
/// File-name extensions that denote a ready-to-view still photo. Listed so a
|
||||
/// known image name classifies as a photo outright, without deferring to a media
|
||||
/// type that might disagree.
|
||||
const PHOTO_EXTS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "avif", "bmp", "tif", "tiff",
|
||||
];
|
||||
|
||||
impl PhotoKind {
|
||||
/// Classify a photo from what the daemon knows about it. A recognised
|
||||
/// file-name extension is authoritative — it is reliable even before the
|
||||
/// node's media type is resolved, and for raw files the media type is often a
|
||||
/// useless generic — so the media type is only consulted for names that carry
|
||||
/// no extension we know. Anything still unresolved is a still photo.
|
||||
pub fn classify(name: Option<&str>, media_type: Option<&str>) -> PhotoKind {
|
||||
if let Some(ext) = name
|
||||
.and_then(|n| n.rsplit_once('.'))
|
||||
.map(|(_, e)| e.to_ascii_lowercase())
|
||||
{
|
||||
if RAW_EXTS.contains(&ext.as_str()) {
|
||||
return PhotoKind::Raw;
|
||||
}
|
||||
if VIDEO_EXTS.contains(&ext.as_str()) {
|
||||
return PhotoKind::Video;
|
||||
}
|
||||
if PHOTO_EXTS.contains(&ext.as_str()) {
|
||||
return PhotoKind::Photo;
|
||||
}
|
||||
}
|
||||
if let Some(mt) = media_type
|
||||
&& mt.starts_with("video/")
|
||||
{
|
||||
return PhotoKind::Video;
|
||||
}
|
||||
PhotoKind::Photo
|
||||
}
|
||||
|
||||
/// The stable integer this kind is persisted as (see the `kind` column of the
|
||||
/// `photos` table). Chosen once and never reordered.
|
||||
pub fn as_i64(self) -> i64 {
|
||||
match self {
|
||||
PhotoKind::Photo => 0,
|
||||
PhotoKind::Video => 1,
|
||||
PhotoKind::Raw => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`PhotoKind::as_i64`]; any unrecognised value reads as a still
|
||||
/// photo, the safe default for a tab that would otherwise show nothing.
|
||||
pub fn from_i64(v: i64) -> PhotoKind {
|
||||
match v {
|
||||
1 => PhotoKind::Video,
|
||||
2 => PhotoKind::Raw,
|
||||
_ => PhotoKind::Photo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One month the timeline spans, with how many photos it holds — a tick on the
|
||||
/// date scrubber (reply to [`Request::PhotoMonths`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PhotoMonth {
|
||||
/// Local-time calendar year, e.g. `2026`.
|
||||
pub year: i32,
|
||||
/// Local-time month, `1..=12`.
|
||||
pub month: i32,
|
||||
/// Photos captured in that month (within the requested kind, if any).
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PhotoItem {
|
||||
@@ -559,6 +722,16 @@ pub struct PhotoItem {
|
||||
/// and its bytes could not be decoded locally. The tile shows a placeholder
|
||||
/// rather than waiting for an image that will never come.
|
||||
pub no_thumb: bool,
|
||||
/// Which Photos-page tab this entry belongs to. Older daemons that predate
|
||||
/// the split omit it; a front-end then treats everything as a still photo.
|
||||
#[serde(default = "default_photo_kind")]
|
||||
pub kind: PhotoKind,
|
||||
}
|
||||
|
||||
/// A daemon too old to classify a timeline entry is assumed to have served a
|
||||
/// still photo — that was the only kind the Photos page showed before the split.
|
||||
fn default_photo_kind() -> PhotoKind {
|
||||
PhotoKind::Photo
|
||||
}
|
||||
|
||||
/// One thumbnail in a [`Response::Thumbs`] batch.
|
||||
@@ -579,6 +752,37 @@ pub struct PhotoThumb {
|
||||
pub pending: bool,
|
||||
}
|
||||
|
||||
/// A daemon too old to report connectivity is assumed online — it could not
|
||||
/// have mounted at all otherwise.
|
||||
fn default_online() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Phrase the queue depth of a [`Response::Status`] for a human, or `None` when
|
||||
/// there is nothing queued and the caller should say nothing at all.
|
||||
///
|
||||
/// Lives here, next to the counts it describes, because the tray, the CLI and
|
||||
/// the manager window all have to draw the same distinction between bytes that
|
||||
/// have not reached the remote and metadata that has not.
|
||||
pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
||||
let part = |n: u64, one: &str, many: &str| match n {
|
||||
0 => None,
|
||||
1 => Some(format!("1 {one}")),
|
||||
n => Some(format!("{n} {many}")),
|
||||
};
|
||||
let parts: Vec<String> = [
|
||||
part(uploads, "upload", "uploads"),
|
||||
part(changes, "change", "changes"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
match parts.is_empty() {
|
||||
true => None,
|
||||
false => Some(format!("{} queued", parts.join(", "))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon's reply to a [`Request`].
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub enum Response {
|
||||
@@ -595,6 +799,21 @@ pub enum Response {
|
||||
budget: u64,
|
||||
/// The pin registry.
|
||||
pins: Vec<Pin>,
|
||||
/// False when the daemon is serving the cached tree because the API is
|
||||
/// unreachable (offline.md Phase 1). Cached and pinned content still
|
||||
/// reads; anything else fails until the network is back.
|
||||
#[serde(default = "default_online")]
|
||||
online: bool,
|
||||
/// Writes accepted locally but not yet uploaded (offline.md Phase 3).
|
||||
/// Non-zero means the mount is ahead of the remote — either a copy is
|
||||
/// still draining, or it cannot drain because we are offline.
|
||||
#[serde(default)]
|
||||
pending_uploads: u64,
|
||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
|
||||
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
|
||||
/// calling a queued `mkdir` an upload is a lie.
|
||||
#[serde(default)]
|
||||
pending_changes: u64,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -608,7 +827,16 @@ pub enum Response {
|
||||
Photos {
|
||||
available: bool,
|
||||
items: Vec<PhotoItem>,
|
||||
/// Whole-timeline tab counts `(photos, videos, raw)`, so a front-end can
|
||||
/// label its Photos / Videos / Raw filter without paging the library.
|
||||
/// The counts describe the *whole* timeline regardless of the page's own
|
||||
/// `kind` filter. Older daemons omit it; a front-end then shows no counts.
|
||||
#[serde(default)]
|
||||
counts: Option<(usize, usize, usize)>,
|
||||
},
|
||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||
/// newest first.
|
||||
PhotoMonths { months: Vec<PhotoMonth> },
|
||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||
Thumbs { items: Vec<PhotoThumb> },
|
||||
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
||||
@@ -650,8 +878,78 @@ pub enum Response {
|
||||
/// The daemon's recent activity, newest first (reply to
|
||||
/// [`Request::ListActivity`]).
|
||||
Activity { items: Vec<ActivityEntry> },
|
||||
/// The request failed.
|
||||
Error { message: String },
|
||||
/// The request failed. `message` is for the user; `kind` is for the code —
|
||||
/// a front-end decides its copy and whether to offer a retry from `kind`,
|
||||
/// never by matching on the text.
|
||||
Error {
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
kind: ErrorKind,
|
||||
},
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Build a failure reply from a classified error, so the `kind` a
|
||||
/// request-serving method decided survives the trip to the front-end.
|
||||
pub fn error(e: crate::error::CoreError) -> Self {
|
||||
Response::Error {
|
||||
message: e.message,
|
||||
kind: e.kind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What class of thing went wrong, as opposed to what it read like.
|
||||
///
|
||||
/// The daemon answers most calls with prose assembled from whatever layer
|
||||
/// failed (`"resolve path: ENOENT"`), which is fine to show and useless to
|
||||
/// branch on. This is the branchable half: enough to pick the right copy, to
|
||||
/// know whether retrying is meaningful, and to tell a caller's mistake apart
|
||||
/// from an outage.
|
||||
///
|
||||
/// Deliberately coarse. A variant earns its place by changing what a front-end
|
||||
/// *does*, not by naming a distinct cause — anything finer belongs in `message`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorKind {
|
||||
/// The API is unreachable and the request needed it. The cached tree is
|
||||
/// still being served, so this is a "not right now", not a failure of the
|
||||
/// thing the user asked for.
|
||||
Offline,
|
||||
/// No node at that path or uid. Usually means the front-end's listing is
|
||||
/// stale, so the useful response is to reload it rather than to retry.
|
||||
NotFound,
|
||||
/// The account may not do that to that node — a viewer trying to write, a
|
||||
/// share whose role was downgraded. Retrying changes nothing.
|
||||
Denied,
|
||||
/// The remote moved underneath the request: a name already taken, a
|
||||
/// revision superseded. The caller has to decide, so never auto-retried.
|
||||
Conflict,
|
||||
/// The request itself was malformed — an empty name, a path with a `/` in
|
||||
/// it, an unparseable uid. A bug in the caller, not a condition to retry.
|
||||
Invalid,
|
||||
/// The API was reached and refused, or the transfer broke. The one class
|
||||
/// where an unchanged retry can legitimately succeed.
|
||||
Remote,
|
||||
/// The account is out of storage. Distinct from [`Denied`](Self::Denied)
|
||||
/// because the user *can* fix it, and distinct from [`Remote`](Self::Remote)
|
||||
/// because retrying an upload that did not fit will not make it fit.
|
||||
Quota,
|
||||
/// Something on this machine failed: the database, the content cache, the
|
||||
/// filesystem. Not the user's doing and not theirs to fix.
|
||||
#[default]
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl ErrorKind {
|
||||
/// Whether repeating the identical request could plausibly succeed.
|
||||
///
|
||||
/// Drives whether a front-end offers "Try again" at all: offering it for a
|
||||
/// [`NotFound`](Self::NotFound) or an [`Invalid`](Self::Invalid) teaches the
|
||||
/// user that the button does nothing.
|
||||
pub fn retryable(self) -> bool {
|
||||
matches!(self, ErrorKind::Offline | ErrorKind::Remote)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
||||
@@ -676,6 +974,52 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn photo_kind_classifies_by_extension_then_mime() {
|
||||
use PhotoKind::*;
|
||||
// Raw is extension-driven: the server media type is often generic.
|
||||
assert_eq!(PhotoKind::classify(Some("IMG_1.CR2"), None), Raw);
|
||||
assert_eq!(
|
||||
PhotoKind::classify(Some("shot.dng"), Some("application/octet-stream")),
|
||||
Raw
|
||||
);
|
||||
// Video by extension or by mime when the name has no useful extension.
|
||||
assert_eq!(PhotoKind::classify(Some("anime.mkv"), None), Video);
|
||||
assert_eq!(PhotoKind::classify(Some("clip.MP4"), None), Video);
|
||||
assert_eq!(PhotoKind::classify(None, Some("video/quicktime")), Video);
|
||||
// Everything else is a still photo, including a mismatched raw mime whose
|
||||
// name says it is a normal JPEG.
|
||||
assert_eq!(PhotoKind::classify(Some("pic.jpg"), None), Photo);
|
||||
assert_eq!(PhotoKind::classify(Some("pic.heic"), None), Photo);
|
||||
assert_eq!(PhotoKind::classify(None, None), Photo);
|
||||
// Extension wins over mime: a name ending .jpg is a photo even if the mime
|
||||
// is nonsense.
|
||||
assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_kind_i64_round_trips() {
|
||||
for k in [PhotoKind::Photo, PhotoKind::Video, PhotoKind::Raw] {
|
||||
assert_eq!(PhotoKind::from_i64(k.as_i64()), k);
|
||||
}
|
||||
// An unknown persisted value degrades to a still photo.
|
||||
assert_eq!(PhotoKind::from_i64(99), PhotoKind::Photo);
|
||||
}
|
||||
|
||||
/// The whole point of the split counts: a queued `mkdir` is work, but it is
|
||||
/// not an upload and must never be reported as one.
|
||||
#[test]
|
||||
fn pending_summary_separates_uploads_from_other_changes() {
|
||||
assert_eq!(pending_summary(0, 0), None);
|
||||
assert_eq!(pending_summary(1, 0).as_deref(), Some("1 upload queued"));
|
||||
assert_eq!(pending_summary(3, 0).as_deref(), Some("3 uploads queued"));
|
||||
assert_eq!(pending_summary(0, 1).as_deref(), Some("1 change queued"));
|
||||
assert_eq!(
|
||||
pending_summary(2, 4).as_deref(),
|
||||
Some("2 uploads, 4 changes queued")
|
||||
);
|
||||
}
|
||||
|
||||
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
||||
/// that is exactly how they cross the control socket.
|
||||
#[test]
|
||||
@@ -696,10 +1040,11 @@ mod tests {
|
||||
parent: "a".into(),
|
||||
name: "new".into(),
|
||||
},
|
||||
Request::UploadFile {
|
||||
parent: "a".into(),
|
||||
name: "f.bin".into(),
|
||||
bytes: vec![0, 1, 2, 255],
|
||||
Request::UploadPhoto {
|
||||
name: "p.jpg".into(),
|
||||
media_type: "image/jpeg".into(),
|
||||
source_path: "/home/u/p.jpg".into(),
|
||||
capture_time: Some(1_700_000_000),
|
||||
},
|
||||
Request::UploadPaths {
|
||||
parent: "a".into(),
|
||||
@@ -729,6 +1074,20 @@ mod tests {
|
||||
uids: vec!["vol~link".into()],
|
||||
},
|
||||
Request::EmptyTrash,
|
||||
Request::Refresh {
|
||||
scope: RefreshScope::Dir { path: "a/b".into() },
|
||||
},
|
||||
Request::Refresh {
|
||||
scope: RefreshScope::Dir {
|
||||
path: String::new(),
|
||||
},
|
||||
},
|
||||
Request::Refresh {
|
||||
scope: RefreshScope::Trash,
|
||||
},
|
||||
Request::Refresh {
|
||||
scope: RefreshScope::Photos,
|
||||
},
|
||||
];
|
||||
for req in reqs {
|
||||
let line = serde_json::to_string(&req).unwrap();
|
||||
@@ -817,4 +1176,72 @@ mod tests {
|
||||
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
/// A daemon built before `kind` existed sends `Error` without the field.
|
||||
/// It must still parse, and land on the class that promises the least.
|
||||
#[test]
|
||||
fn an_error_without_a_kind_reads_as_internal() {
|
||||
let wire = r#"{"Error":{"message":"something broke"}}"#;
|
||||
let back: Response = serde_json::from_str(wire).unwrap();
|
||||
match back {
|
||||
Response::Error { message, kind } => {
|
||||
assert_eq!(message, "something broke");
|
||||
assert_eq!(kind, ErrorKind::Internal);
|
||||
}
|
||||
other => panic!("expected an error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_kind_survives_the_wire() {
|
||||
for kind in [
|
||||
ErrorKind::Offline,
|
||||
ErrorKind::NotFound,
|
||||
ErrorKind::Denied,
|
||||
ErrorKind::Conflict,
|
||||
ErrorKind::Invalid,
|
||||
ErrorKind::Remote,
|
||||
ErrorKind::Internal,
|
||||
] {
|
||||
let line = serde_json::to_string(&Response::Error {
|
||||
message: "x".into(),
|
||||
kind,
|
||||
})
|
||||
.unwrap();
|
||||
let back: Response = serde_json::from_str(&line).unwrap();
|
||||
match back {
|
||||
Response::Error { kind: got, .. } => assert_eq!(got, kind),
|
||||
other => panic!("expected an error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry is offered to the user off the back of this, so it has to mean
|
||||
/// "an identical request could work", not "this looks recoverable".
|
||||
#[test]
|
||||
fn only_offline_and_remote_are_worth_retrying() {
|
||||
assert!(ErrorKind::Offline.retryable());
|
||||
assert!(ErrorKind::Remote.retryable());
|
||||
for kind in [
|
||||
ErrorKind::NotFound,
|
||||
ErrorKind::Denied,
|
||||
ErrorKind::Conflict,
|
||||
ErrorKind::Invalid,
|
||||
ErrorKind::Internal,
|
||||
] {
|
||||
assert!(!kind.retryable(), "{kind:?} must not offer a retry");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_error_carries_a_core_errors_classification() {
|
||||
let r = Response::error(crate::error::CoreError::not_found("no such file"));
|
||||
match r {
|
||||
Response::Error { message, kind } => {
|
||||
assert_eq!(message, "no such file");
|
||||
assert_eq!(kind, ErrorKind::NotFound);
|
||||
}
|
||||
other => panic!("expected an error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,66 @@
|
||||
//! The activity feed: a bounded, newest-first log of what the daemon did.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use crate::control::{ActivityEntry, ActivityKind};
|
||||
|
||||
/// How many activity rows to keep. Older rows are pruned on insert, so the feed
|
||||
/// stays a bounded "recent history" rather than growing without limit.
|
||||
pub(super) const ACTIVITY_KEEP: i64 = 2000;
|
||||
|
||||
impl Db {
|
||||
pub fn activity_add(&self, entry: &ActivityEntry) -> Result<()> {
|
||||
let kind = serde_json::to_string(&entry.kind)?;
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO activity (time, kind, target, detail, ok) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![entry.time, kind, entry.target, entry.detail, entry.ok],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM activity WHERE id <= (
|
||||
SELECT id FROM activity ORDER BY id DESC LIMIT 1 OFFSET ?1
|
||||
)",
|
||||
params![ACTIVITY_KEEP],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||
/// whose stored `kind` no longer parses (written by an older build) are
|
||||
/// skipped rather than failing the whole read.
|
||||
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT time, kind, target, detail, ok FROM activity
|
||||
ORDER BY id DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit as i64], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, bool>(4)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (time, kind, target, detail, ok) = row?;
|
||||
let Ok(kind) = serde_json::from_str::<ActivityKind>(&kind) else {
|
||||
continue;
|
||||
};
|
||||
out.push(ActivityEntry {
|
||||
time,
|
||||
kind,
|
||||
target,
|
||||
detail,
|
||||
ok,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! The content-cache index: one row per cached blob, carrying the access stamp
|
||||
//! the LRU eviction pass sorts on.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
pub struct CacheEntryInput<'a> {
|
||||
/// On-disk filename of the blob or block.
|
||||
pub key: &'a str,
|
||||
/// `'blob'` or `'block'` — the two byte budgets are counted separately.
|
||||
pub kind: &'a str,
|
||||
pub size: u64,
|
||||
/// Initial LRU key, in unix milliseconds.
|
||||
pub last_accessed: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn cache_touch(&self, key: &str, kind: &str, size: u64, now: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
size_bytes = excluded.size_bytes,
|
||||
last_accessed = excluded.last_accessed",
|
||||
params![key, kind, size as i64, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump only `last_accessed` on a cache hit (LRU ordering). A missing row is
|
||||
/// a no-op — best effort, mirroring the old best-effort mtime touch.
|
||||
pub fn cache_accessed(&self, key: &str, now: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||
params![key, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a single cache-entry row (one evicted blob or block).
|
||||
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM cache_entries WHERE cache_key = ?1",
|
||||
params![key],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the whole-file blob row for `key` plus every derived row for the
|
||||
/// same uid — blocks (`<key>.b<idx>`) and thumbnails (`<key>.t<type>`).
|
||||
/// Called by `ContentCache::evict`, which removes the blob, all of a uid's
|
||||
/// cached blocks and all of its thumbnails at once; a row left behind here
|
||||
/// would keep counting bytes that are no longer on disk.
|
||||
pub fn cache_remove_all(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM cache_entries
|
||||
WHERE cache_key = ?1 OR cache_key LIKE ?2 OR cache_key LIKE ?3",
|
||||
params![key, format!("{key}.b%"), format!("{key}.t%")],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Total bytes held by entries of `kind`.
|
||||
///
|
||||
/// The budget enforcers' first question, and almost always their last: the
|
||||
/// common case is "under budget, evict nothing". Answering it with a `SUM`
|
||||
/// costs one index range rather than materializing every row, which matters
|
||||
/// because `enforce_block_budget` runs on every cached block — once per
|
||||
/// 4 MiB of every cold read — while holding the shared connection.
|
||||
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
||||
let conn = self.conn.lock();
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||
params![kind],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(total.max(0) as u64)
|
||||
}
|
||||
|
||||
/// The `limit` least-recently-accessed entries of `kind`, oldest first.
|
||||
///
|
||||
/// Eviction wants a handful of victims, not the whole table. Bounded so a
|
||||
/// pass over a large cache does not read every row to drop a few, and so the
|
||||
/// `(kind, last_accessed)` index can answer it as a range scan with an early
|
||||
/// stop.
|
||||
pub fn cache_eviction_candidates(
|
||||
&self,
|
||||
kind: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![kind, limit as i64], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every cache entry of `kind`, ordered least-recently-accessed first, as
|
||||
/// `(cache_key, size_bytes)`. Used by the whole-cache passes
|
||||
/// (`clear_unpinned`); incremental eviction wants
|
||||
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
||||
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![kind], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace the whole cache index with `entries`, as one transaction. The
|
||||
/// daemon calls this on open, so a crash or an external file deletion can
|
||||
/// never leave a phantom row inflating the budget total.
|
||||
///
|
||||
/// The clear and the refill are the same commit deliberately: they are one
|
||||
/// operation — "the index now describes what is on disk" — and a crash
|
||||
/// between them would otherwise leave an empty index against a full cache
|
||||
/// directory, i.e. a budget total of zero and nothing eviction can see.
|
||||
///
|
||||
/// One commit also matters for speed. A mount is the only caller and it runs
|
||||
/// on the open path, so a cache of thousands of blobs used to pay thousands
|
||||
/// of autocommit fsyncs before the mountpoint appeared.
|
||||
pub fn cache_rebuild(&self, entries: &[CacheEntryInput<'_>]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM cache_entries", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
kind = excluded.kind,
|
||||
size_bytes = excluded.size_bytes,
|
||||
last_accessed = excluded.last_accessed",
|
||||
)?;
|
||||
for e in entries {
|
||||
stmt.execute(params![e.key, e.kind, e.size as i64, e.last_accessed])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Pins (P5) --------------------------------------------------------
|
||||
//
|
||||
// The pin registry, formerly `pins.json`. One row per directly-pinned node,
|
||||
// keyed by uid display string. `recursive` marks a folder pin: its whole
|
||||
// subtree counts as pinned, resolved against `nodes` via a CTE so a new
|
||||
// descendant is covered the moment it lands in the node cache.
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! This machine as a registered Proton Drive device.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
pub struct StoredDevice {
|
||||
pub uid: String,
|
||||
pub share_id: String,
|
||||
pub root_uid: String,
|
||||
pub name: String,
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||
[],
|
||||
|r| {
|
||||
Ok(StoredDevice {
|
||||
uid: r.get(0)?,
|
||||
share_id: r.get(1)?,
|
||||
root_uid: r.get(2)?,
|
||||
name: r.get(3)?,
|
||||
created: r.get(4)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Persist (or replace) this machine's device. The table holds a single row.
|
||||
pub fn device_set(&self, dev: &StoredDevice) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM device", [])?;
|
||||
conn.execute(
|
||||
"INSERT INTO device (uid, share_id, root_uid, name, created)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![dev.uid, dev.share_id, dev.root_uid, dev.name, dev.created],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! The local-file index: a generation-stamped scan of the mount, so search can
|
||||
//! answer over files the daemon has on disk as well as the remote tree.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use crate::localindex::LocalEntry;
|
||||
|
||||
use super::utils::{TRIGRAM_MIN, like_escape};
|
||||
|
||||
/// One hit from [`Db::search_local`]: an indexed file on the machine itself, not
|
||||
/// in Drive. `path` is absolute.
|
||||
pub struct LocalFileHit {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn local_begin_scan(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock();
|
||||
let current: i64 = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_scan_gen'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let next = current + 1;
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_scan_gen', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[next.to_string()],
|
||||
)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Write one batch of walked entries under scan generation `generation`. The
|
||||
/// FTS index is *not* touched here — it is rebuilt once in
|
||||
/// [`local_finish_scan`](Self::local_finish_scan).
|
||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
scan_gen = excluded.scan_gen",
|
||||
)?;
|
||||
for e in entries {
|
||||
stmt.execute(params![
|
||||
e.path,
|
||||
e.name,
|
||||
e.is_dir as i64,
|
||||
e.size,
|
||||
e.mtime,
|
||||
generation
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||
/// (those paths are gone from disk), rebuild the FTS index over what remains,
|
||||
/// and stamp the completion time. Returns the number of indexed entries.
|
||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[finished_at.to_string()],
|
||||
)?;
|
||||
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||
tx.commit()?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// When the last local scan completed (epoch seconds), or `None` if the index
|
||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||
/// needs an immediate rescan or can serve the existing index.
|
||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok()))
|
||||
}
|
||||
|
||||
/// Substring search over indexed local file names, newest-modified first
|
||||
/// within a relevance tier. Mirrors [`search`](Self::search): the trigram
|
||||
/// index handles queries of at least [`TRIGRAM_MIN`] chars, shorter ones fall
|
||||
/// back to a `LIKE` scan.
|
||||
pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt;
|
||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
stmt = conn.prepare(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY mtime DESC LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![pat, limit as i64], local_hit)?
|
||||
} else {
|
||||
let phrase = query
|
||||
.split_whitespace()
|
||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
stmt = conn.prepare(
|
||||
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
|
||||
FROM local_fts x JOIN local_files f ON f.id = x.rowid
|
||||
WHERE x.name MATCH ?1
|
||||
ORDER BY x.rank LIMIT ?2",
|
||||
)?;
|
||||
stmt.query_map(params![phrase, limit as i64], local_hit)?
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Row → [`LocalFileHit`]. Every local-search query selects the same
|
||||
/// columns in the same order.
|
||||
pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
|
||||
Ok(LocalFileHit {
|
||||
path: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
is_dir: row.get::<_, i64>(2)? != 0,
|
||||
size: row.get(3)?,
|
||||
mtime: row.get(4)?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! Forward-only schema migrations.
|
||||
//!
|
||||
//! Each `MIGRATION_V*` is the SQL that moves the schema from version N-1 to N.
|
||||
//! They are applied in order and never edited once shipped: a released version
|
||||
//! is already on disk somewhere, so a change here would silently diverge from
|
||||
//! what an existing install actually has. Add a new one instead.
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 14;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
|
||||
// `sync_state` is the key/value table holding `schema_version` and the
|
||||
// event cursor (later). Create it first so we can read the version.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS sync_state (key TEXT PRIMARY KEY, value TEXT);",
|
||||
)?;
|
||||
|
||||
let current: i64 = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if current >= SCHEMA_VERSION {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
if current < 1 {
|
||||
tx.execute_batch(MIGRATION_V1)?;
|
||||
}
|
||||
if current < 2 {
|
||||
tx.execute_batch(MIGRATION_V2)?;
|
||||
}
|
||||
if current < 3 {
|
||||
tx.execute_batch(MIGRATION_V3)?;
|
||||
}
|
||||
if current < 4 {
|
||||
tx.execute_batch(MIGRATION_V4)?;
|
||||
}
|
||||
if current < 5 {
|
||||
tx.execute_batch(MIGRATION_V5)?;
|
||||
}
|
||||
if current < 6 {
|
||||
tx.execute_batch(MIGRATION_V6)?;
|
||||
}
|
||||
if current < 7 {
|
||||
tx.execute_batch(MIGRATION_V7)?;
|
||||
}
|
||||
if current < 8 {
|
||||
tx.execute_batch(MIGRATION_V8)?;
|
||||
}
|
||||
if current < 9 {
|
||||
tx.execute_batch(MIGRATION_V9)?;
|
||||
}
|
||||
if current < 10 {
|
||||
tx.execute_batch(MIGRATION_V10)?;
|
||||
}
|
||||
if current < 11 {
|
||||
tx.execute_batch(MIGRATION_V11)?;
|
||||
}
|
||||
if current < 12 {
|
||||
tx.execute_batch(MIGRATION_V12)?;
|
||||
}
|
||||
if current < 13 {
|
||||
tx.execute_batch(MIGRATION_V13)?;
|
||||
}
|
||||
if current < 14 {
|
||||
tx.execute_batch(MIGRATION_V14)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[SCHEMA_VERSION.to_string()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created
|
||||
/// in [`Db::migrate`] before this runs.
|
||||
const MIGRATION_V1: &str = "
|
||||
CREATE TABLE nodes (
|
||||
uid TEXT PRIMARY KEY,
|
||||
parent_uid TEXT,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER,
|
||||
mtime INTEGER NOT NULL,
|
||||
revision_hash TEXT,
|
||||
trashed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
|
||||
|
||||
CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||
name, path, content='', tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TABLE cache_entries (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
size_bytes INTEGER,
|
||||
last_accessed INTEGER,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v2: node write-through (P1). `node_json` stores the full [`Node`] so
|
||||
/// the hot-cache maps rehydrate losslessly; `listed` records whether a folder's
|
||||
/// child listing was complete when persisted.
|
||||
const MIGRATION_V2: &str = "
|
||||
ALTER TABLE nodes ADD COLUMN node_json TEXT;
|
||||
ALTER TABLE nodes ADD COLUMN listed INTEGER NOT NULL DEFAULT 0;
|
||||
";
|
||||
|
||||
/// Schema v3: FTS search (P3). The v1 `nodes_fts` was contentless (`content=''`)
|
||||
/// and never populated — drop it and recreate as a self-contained trigram index
|
||||
/// carrying the `uid` (UNINDEXED, so retrievable but not tokenized) alongside the
|
||||
/// indexed `name`. Backfill from the nodes already persisted by P1.
|
||||
const MIGRATION_V3: &str = "
|
||||
DROP TABLE IF EXISTS nodes_fts;
|
||||
CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||
uid UNINDEXED, name, tokenize='trigram'
|
||||
);
|
||||
INSERT INTO nodes_fts (uid, name)
|
||||
SELECT uid, name FROM nodes WHERE trashed = 0;
|
||||
";
|
||||
|
||||
/// Schema v4: content-cache LRU index (P4). The v1 `cache_entries` keyed only by
|
||||
/// `cache_key`; add a `kind` discriminator so the whole-file blob pool and the
|
||||
/// block pool — which carry separate byte budgets — can be summed and evicted
|
||||
/// independently. Existing rows (none in practice, the table was never written)
|
||||
/// default to `'blob'`. The daemon rebuilds the index from disk on open, so no
|
||||
/// backfill is needed here.
|
||||
const MIGRATION_V4: &str = "
|
||||
ALTER TABLE cache_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'blob';
|
||||
";
|
||||
|
||||
/// Schema v5: pins move out of `pins.json` into the DB (P5). One row per
|
||||
/// directly-pinned node, keyed by uid display string so a pin survives
|
||||
/// renames/moves. `recursive` marks a folder pin whose whole subtree is kept;
|
||||
/// descendants are resolved on demand against `nodes` via a CTE rather than
|
||||
/// being expanded into rows here. `ContentCache::open` imports any legacy
|
||||
/// `pins.json` into this table once, then deletes the file.
|
||||
const MIGRATION_V5: &str = "
|
||||
CREATE TABLE pins (
|
||||
uid TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
recursive INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v6: the index of *local* (non-Drive) files, so the launcher prompt can
|
||||
/// search the machine alongside Drive. `local_files` is keyed by absolute path;
|
||||
/// `scan_gen` stamps the scan that last saw a row, so a rescan prunes vanished
|
||||
/// files with one `DELETE` instead of diffing.
|
||||
///
|
||||
/// `local_fts` is an *external-content* FTS5 index over `local_files.name`: the
|
||||
/// text lives once in the base table and the index is rebuilt in bulk at the end
|
||||
/// of a scan (`INSERT INTO local_fts(local_fts) VALUES('rebuild')`). That is far
|
||||
/// cheaper than the delete-then-insert-per-row dance `nodes_fts` needs, because
|
||||
/// a scan rewrites most rows at once rather than trickling single updates.
|
||||
const MIGRATION_V6: &str = "
|
||||
CREATE TABLE local_files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0,
|
||||
scan_gen INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE local_fts USING fts5(
|
||||
name, content='local_files', content_rowid='id', tokenize='trigram'
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v7: the photos timeline and the trash listing become persistent, so
|
||||
/// opening the app paints them from disk instead of re-fetching the world (both
|
||||
/// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all).
|
||||
///
|
||||
/// `photos.seq` preserves the server's newest-first order, which is the only order
|
||||
/// the timeline has — `capture_time` ties are common (a burst of shots) and would
|
||||
/// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally
|
||||
/// learned*: they cost a download to rediscover, so `photos_replace` carries them
|
||||
/// across a refresh while capture times and order come from the server.
|
||||
const MIGRATION_V7: &str = "
|
||||
CREATE TABLE photos (
|
||||
uid TEXT PRIMARY KEY,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_photos_seq ON photos(seq);
|
||||
|
||||
CREATE TABLE trash (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
mtime INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
|
||||
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
||||
/// row cached so we reuse the same device across restarts instead of creating a
|
||||
/// new one each run. `sync_folder` is one row per local folder the user added,
|
||||
/// each mapped to a folder under the device root; `mode` is `mirror` (full local
|
||||
/// copy, two-way synced) or `ondemand` (FUSE mount at `local_path`, no local
|
||||
/// storage). `sync_entry` is the per-file baseline for three-way merge — added in
|
||||
/// this migration so Phase 2 has the table, though Phase 1 leaves it empty.
|
||||
const MIGRATION_V8: &str = "
|
||||
CREATE TABLE device (
|
||||
uid TEXT PRIMARY KEY,
|
||||
share_id TEXT NOT NULL,
|
||||
root_uid TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE sync_folder (
|
||||
id INTEGER PRIMARY KEY,
|
||||
local_path TEXT NOT NULL UNIQUE,
|
||||
remote_uid TEXT NOT NULL,
|
||||
remote_share_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL DEFAULT 'mirror',
|
||||
state TEXT NOT NULL DEFAULT 'idle',
|
||||
last_sync INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE sync_entry (
|
||||
folder_id INTEGER NOT NULL,
|
||||
rel_path TEXT NOT NULL,
|
||||
remote_uid TEXT,
|
||||
local_mtime INTEGER NOT NULL DEFAULT 0,
|
||||
local_size INTEGER NOT NULL DEFAULT 0,
|
||||
remote_hash TEXT,
|
||||
remote_rev TEXT,
|
||||
PRIMARY KEY (folder_id, rel_path)
|
||||
);
|
||||
";
|
||||
|
||||
const MIGRATION_V9: &str = "
|
||||
CREATE TABLE activity (
|
||||
id INTEGER PRIMARY KEY,
|
||||
time INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ok INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE INDEX activity_time ON activity(time DESC);
|
||||
";
|
||||
|
||||
/// Schema v10: a mode switch the user asked for is queued rather than rejected
|
||||
/// when the folder is mid-pass or has un-uploaded changes, so `pending_mode`
|
||||
/// records the intent until the engine can act on it. NULL is the resting state:
|
||||
/// the folder is already where the user wants it.
|
||||
const MIGRATION_V10: &str = "
|
||||
ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
||||
";
|
||||
|
||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
||||
/// the bytes and records the intended upload here, and a drain worker performs it
|
||||
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
|
||||
/// dead network and a restart.
|
||||
///
|
||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||
/// the [`StagedWrite`] sidecar describing which of its bytes are real — an
|
||||
/// incomplete blob must be gap-filled from the remote base before it can be
|
||||
/// uploaded. `next_attempt_at` is a ms deadline implementing retry backoff.
|
||||
///
|
||||
/// [`StagedWrite`]: crate::cache::StagedWrite
|
||||
const MIGRATION_V11: &str = "
|
||||
CREATE TABLE pending_op (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
blob_path TEXT,
|
||||
meta_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX pending_op_uid ON pending_op(uid);
|
||||
";
|
||||
|
||||
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
||||
/// revision op never had to describe — it always addressed a node the server had
|
||||
/// already minted a uid for (offline.md Phase 3b).
|
||||
///
|
||||
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
||||
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||
/// (`parent_uid`) and what it is called (`name`). Both are nullable because a
|
||||
/// revision op sets neither.
|
||||
///
|
||||
/// `parent_uid` is indexed: draining a folder rewrites every queued child that
|
||||
/// points at its placeholder.
|
||||
const MIGRATION_V12: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN parent_uid TEXT;
|
||||
ALTER TABLE pending_op ADD COLUMN name TEXT;
|
||||
|
||||
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
|
||||
";
|
||||
|
||||
/// Schema v13: photos record their media type and a derived `kind` so the
|
||||
/// timeline can be split into Photos / Videos / Raw tabs. `media_type` is
|
||||
/// nullable — it is filled once the daemon resolves a photo's node — while
|
||||
/// `kind` (see [`crate::control::PhotoKind`]) defaults to `0` (still photo), the
|
||||
/// classification anything unresolved falls back to. Existing rows are
|
||||
/// reclassified for free on the next timeline refresh, so no backfill is needed.
|
||||
const MIGRATION_V13: &str = "
|
||||
ALTER TABLE photos ADD COLUMN media_type TEXT;
|
||||
ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
|
||||
";
|
||||
|
||||
/// Schema v14: index the content-cache LRU. `cache_entries` carried only its
|
||||
/// `cache_key` primary key, so every budget check and every eviction pass was a
|
||||
/// full scan plus a sort — on a path that runs once per cached 4 MiB block,
|
||||
/// under the connection lock every FUSE metadata call also needs. `(kind,
|
||||
/// last_accessed)` turns both into a range scan: the `SUM` reads one kind's
|
||||
/// slice, and eviction reads its front and stops.
|
||||
const MIGRATION_V14: &str = "
|
||||
CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed);
|
||||
";
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Unified SQLite metadata cache — the single persistence layer behind FUSE
|
||||
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
||||
//!
|
||||
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
||||
//! the same data through the control socket. The connection is wrapped in a
|
||||
//! `Mutex` because the FUSE callbacks are synchronous and already serialize
|
||||
//! behind the `State` lock, so a connection pool would be overkill.
|
||||
//!
|
||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||
//! event cursor, FTS, and the cache index land in later phases on this schema.
|
||||
//!
|
||||
//! The surface is one `Db` type; its methods live in a submodule per table group
|
||||
//! (`nodes`, `photos`, `pins`, …) as separate `impl Db` blocks. Everything those
|
||||
//! modules define is re-exported here, so callers keep saying `db::StoredPhoto`
|
||||
//! and never name a submodule.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::Result;
|
||||
use std::path::Path;
|
||||
|
||||
mod activity;
|
||||
mod cache;
|
||||
mod devices;
|
||||
mod local;
|
||||
mod migrations;
|
||||
mod nodes;
|
||||
mod ops;
|
||||
mod photos;
|
||||
mod pins;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod trash;
|
||||
mod utils;
|
||||
|
||||
pub use cache::CacheEntryInput;
|
||||
pub use devices::StoredDevice;
|
||||
pub use local::LocalFileHit;
|
||||
pub use nodes::{SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
|
||||
PendingCounts, PendingOp, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
/// Size the WAL is truncated back to after a checkpoint. Comfortably above the
|
||||
/// steady-state working set (a few MB), so the truncation only claws back the
|
||||
/// outliers rather than fighting the normal write path for disk.
|
||||
const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
|
||||
|
||||
/// How long a statement waits for a database lock before giving up.
|
||||
///
|
||||
/// Long enough to outlast any transaction this daemon writes (the largest, a
|
||||
/// full-index FTS rebuild, is well under a second), short enough that a
|
||||
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Handle to the unified metadata database.
|
||||
///
|
||||
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
||||
/// the inner `Mutex<Connection>`.
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||
/// the schema up to [`SCHEMA_VERSION`].
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
// WAL: readers never block the single writer. NORMAL sync is the
|
||||
// standard durability/throughput tradeoff for WAL.
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
// Hand the WAL's disk back after a checkpoint. Without a limit SQLite
|
||||
// reuses the file in place but never shrinks it, so a single large
|
||||
// transaction (a full-index FTS rebuild in `local_finish_scan` is the
|
||||
// one that reaches this size) leaves the WAL at its high-water mark
|
||||
// forever — a multi-GB file next to a database two orders of magnitude
|
||||
// smaller. Checkpointing is unaffected; only the file is truncated back.
|
||||
conn.pragma_update(None, "journal_size_limit", WAL_SIZE_LIMIT)?;
|
||||
// Wait for a lock rather than failing instantly on SQLITE_BUSY.
|
||||
//
|
||||
// This is deliberately explicit and deliberately redundant: rusqlite
|
||||
// already applies a five-second busy timeout of its own, so this changes
|
||||
// nothing today. It is here so the value is *ours* — a behaviour the
|
||||
// daemon depends on should not be an inherited default that a dependency
|
||||
// bump can silently change. `open_bounds_the_wal_size` pins it.
|
||||
//
|
||||
// (Bare SQLite does default to 0, which is where the belief that this
|
||||
// was unset came from. That default is not what we get.)
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Open an in-memory database. For tests.
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Run a closure with the locked connection. Escape hatch for callers that
|
||||
/// need a query no typed method covers yet.
|
||||
pub fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
|
||||
let conn = self.conn.lock();
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Node rows: the persisted mirror of the remote tree, plus the trigram
|
||||
//! full-text index over names and paths that backs `Request::Search`.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
||||
|
||||
pub struct StoredNode {
|
||||
pub node: Node,
|
||||
pub listed: bool,
|
||||
}
|
||||
|
||||
/// One full-text search match: the stored [`Node`] plus its mountpoint-relative
|
||||
/// path (`/`-joined, root excluded) so the front-end can navigate to or open it.
|
||||
pub struct SearchHit {
|
||||
pub node: Node,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn upsert_node(&self, node: &Node) -> Result<()> {
|
||||
self.upsert_nodes(std::slice::from_ref(node))
|
||||
}
|
||||
|
||||
/// Write-through a batch of nodes as one transaction — a whole directory
|
||||
/// listing, typically. Otherwise identical to [`upsert_node`](Self::upsert_node),
|
||||
/// which is the single-node case of it.
|
||||
///
|
||||
/// The commit count is the point: SQLite autocommits every statement that is
|
||||
/// not in an explicit transaction, so interning a folder of a thousand
|
||||
/// children row-by-row cost a thousand fsyncs, and `ls` waited for all of
|
||||
/// them.
|
||||
pub fn upsert_nodes(&self, nodes: &[Node]) -> Result<()> {
|
||||
if nodes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
for node in nodes {
|
||||
let json = serde_json::to_string(node)?;
|
||||
let uid = node.uid.to_string();
|
||||
tx.execute(
|
||||
"INSERT INTO nodes
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
parent_uid = excluded.parent_uid,
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
trashed = excluded.trashed,
|
||||
node_json = excluded.node_json",
|
||||
params![
|
||||
uid,
|
||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
||||
node.name,
|
||||
node.is_folder() as i64,
|
||||
node_size(node),
|
||||
node.modification_time,
|
||||
node.trashed as i64,
|
||||
json,
|
||||
],
|
||||
)?;
|
||||
// FTS5 has no UPSERT, so refresh the row by delete-then-insert.
|
||||
// Trashed nodes are kept out of the index entirely so they never
|
||||
// surface in search results.
|
||||
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?;
|
||||
if !node.trashed {
|
||||
tx.execute(
|
||||
"INSERT INTO nodes_fts (uid, name) VALUES (?1, ?2)",
|
||||
params![uid, node.name],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a node row (delete or trash from the hot cache). Children rows are
|
||||
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
|
||||
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
|
||||
let uid = uid.to_string();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
|
||||
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full-text search over node names, newest schema's trigram index giving
|
||||
/// substring (not just prefix) matches. Returns up to `limit` non-trashed
|
||||
/// hits, each with its mountpoint-relative path resolved. Queries shorter
|
||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||
/// nothing below 3 chars.
|
||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
||||
} else {
|
||||
// Escape double quotes and quote each term, then combine with AND so
|
||||
// all terms must match but can appear in any order or position.
|
||||
let phrase = query
|
||||
.split_whitespace()
|
||||
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid
|
||||
FROM nodes_fts f JOIN nodes n ON n.uid = f.uid
|
||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||
ORDER BY f.rank LIMIT ?2",
|
||||
)?;
|
||||
collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)?
|
||||
};
|
||||
|
||||
let mut hits = Vec::with_capacity(rows.len());
|
||||
for (json, uid) in rows {
|
||||
let node: Node = serde_json::from_str(&json)?;
|
||||
let path = path_of(&conn, &uid)?;
|
||||
hits.push(SearchHit { node, path });
|
||||
}
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// Mark (or unmark) a folder's child listing as complete. A listed folder
|
||||
/// rehydrates its `children` map on mount even when empty; an unlisted one
|
||||
/// re-enumerates from the remote on next access.
|
||||
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE nodes SET listed = ?2 WHERE uid = ?1",
|
||||
params![uid.to_string(), listed as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load every persisted node for cold-start hydration of the `State` maps.
|
||||
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let json: String = row.get(0)?;
|
||||
let listed: i64 = row.get(1)?;
|
||||
Ok((json, listed != 0))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (json, listed) = row?;
|
||||
let node: Node = serde_json::from_str(&json)?;
|
||||
out.push(StoredNode { node, listed });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Load one persisted node back by uid. Used to recover the My Files root
|
||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
||||
/// (offline.md Phase 1).
|
||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||
let conn = self.conn.lock();
|
||||
let json: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||
params![uid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
match json {
|
||||
Some(json) => Ok(Some(serde_json::from_str(&json)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
|
||||
/// The daemon resumes from this on restart instead of reseeding to the
|
||||
/// server head, so changes made while unmounted are still applied (P2).
|
||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||
let conn = self.conn.lock();
|
||||
let listed: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
||||
params![parent.to_string()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if listed != Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json FROM nodes
|
||||
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for json in rows {
|
||||
out.push(serde_json::from_str(&json?)?);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
// --- Content-cache LRU index (P4) -------------------------------------
|
||||
//
|
||||
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
|
||||
// blob/block carries one row keyed by its on-disk filename (`cache_key`),
|
||||
// tagged with `kind` ('blob' | 'block') so the two byte budgets stay
|
||||
// separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns
|
||||
// the on-disk cache and rebuilds this index from disk on open, then keeps it
|
||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||
}
|
||||
|
||||
/// Effective plaintext size of a node for the indexed `size` column: the
|
||||
/// claimed size when known, else the on-storage size; folders are 0.
|
||||
pub(super) fn node_size(node: &Node) -> i64 {
|
||||
match &node.kind {
|
||||
NodeKind::Folder => 0,
|
||||
NodeKind::File {
|
||||
total_size_on_storage,
|
||||
claimed_size,
|
||||
..
|
||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//! The offline mutation queue (`pending_op`): writes the kernel has accepted but
|
||||
//! the remote has not yet seen. Rebuilt into memory on mount and drained in row
|
||||
//! order, so a child never drains before the parent that gives it a real uid.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that uploads a staged file as a new revision.
|
||||
pub const OP_REVISION: &str = "revision";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only
|
||||
/// locally, under a `local:` placeholder uid (offline.md Phase 3b).
|
||||
///
|
||||
/// The written bytes ride along on the same row rather than as a follow-on
|
||||
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would
|
||||
/// leave a separate revision op addressed to a uid that no longer exists.
|
||||
pub const OP_CREATE: &str = "create";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that creates a folder that so far exists only
|
||||
/// locally. Ordering matters: a child's op is queued after its parent's and
|
||||
/// [`Db::pending_ops`] replays by row id, so the parent has a real uid by the
|
||||
/// time the child drains.
|
||||
pub const OP_MKDIR: &str = "mkdir";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
|
||||
/// both — the queued form of `mv` (offline.md Phase 3b).
|
||||
///
|
||||
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
|
||||
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
|
||||
/// drain can compare them against the remote and skip whichever half already
|
||||
/// matches. The parent may be a `local~` placeholder — moving a file into a
|
||||
/// folder that was itself created offline — in which case it is rewritten by
|
||||
/// that folder's own drain, exactly as for [`OP_CREATE`].
|
||||
pub const OP_RENAME: &str = "rename";
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
||||
/// (offline.md Phase 3b).
|
||||
///
|
||||
/// A node that only ever existed locally never gets one of these: there is
|
||||
/// nothing to trash remotely, so deleting it just drops its queued ops
|
||||
/// ([`Db::delete_ops_for_uid`]).
|
||||
pub const OP_TRASH: &str = "trash";
|
||||
|
||||
/// Whether at most one op of this `kind` may be queued per node, so that a newer
|
||||
/// one replaces the older rather than queueing behind it.
|
||||
///
|
||||
/// True exactly of the kinds that describe a node's desired *end state* rather
|
||||
/// than a step towards it: the newest revision already contains every earlier
|
||||
/// one's bytes, the newest name is the only name wanted, and a trash subsumes
|
||||
/// anything queued before it. A `create`/`mkdir` is the opposite — it is the one
|
||||
/// thing that will ever make the node exist, so it must never be replaced.
|
||||
pub fn op_supersedes(kind: &str) -> bool {
|
||||
matches!(kind, OP_REVISION | OP_RENAME | OP_TRASH)
|
||||
}
|
||||
|
||||
/// The volume id given to a node that exists only on this machine, so far. A
|
||||
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<uuid>` and
|
||||
/// round-trips through the same `Display`/parse path as any other uid.
|
||||
///
|
||||
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
||||
/// drain replaces it with the uid the server assigns.
|
||||
pub const LOCAL_VOLUME: &str = "local";
|
||||
|
||||
/// A mutation that has been accepted locally but not yet performed against the
|
||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||
///
|
||||
/// The daemon answers the FUSE call the moment this row and its staged blob are
|
||||
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens
|
||||
/// behind it. That also makes an offline write succeed rather than EIO: the row
|
||||
/// simply waits for the network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingOp {
|
||||
/// Row id, `0` on a value being inserted.
|
||||
pub id: i64,
|
||||
/// See [`OP_REVISION`], [`OP_CREATE`], [`OP_MKDIR`].
|
||||
pub kind: String,
|
||||
/// Node this op targets. For [`OP_CREATE`]/[`OP_MKDIR`] this is the
|
||||
/// `local~<uuid>` placeholder the node is known by until it drains.
|
||||
pub uid: String,
|
||||
/// Where the new node goes; only set for [`OP_CREATE`]/[`OP_MKDIR`]. May
|
||||
/// itself be a placeholder when the parent folder is also still queued, in
|
||||
/// which case it is rewritten when the parent drains.
|
||||
pub parent_uid: Option<String>,
|
||||
/// The new node's name; only set for [`OP_CREATE`]/[`OP_MKDIR`].
|
||||
pub name: Option<String>,
|
||||
/// Staged blob holding the bytes to upload.
|
||||
pub blob_path: Option<String>,
|
||||
/// Serialized [`StagedWrite`](crate::cache::StagedWrite).
|
||||
pub meta_json: Option<String>,
|
||||
/// When the op was queued (ms since epoch).
|
||||
pub created_at: i64,
|
||||
pub attempts: i64,
|
||||
pub last_error: Option<String>,
|
||||
/// Earliest ms at which to retry, for backoff.
|
||||
pub next_attempt_at: i64,
|
||||
}
|
||||
|
||||
/// How much work the queue owes the server, by whether it carries bytes.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct PendingCounts {
|
||||
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
||||
pub uploads: i64,
|
||||
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
||||
/// told about.
|
||||
pub changes: i64,
|
||||
}
|
||||
|
||||
/// The outcome of folding freshly written bytes into a queued create.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttachedBlob {
|
||||
/// Row id of the create the bytes were attached to.
|
||||
pub id: i64,
|
||||
/// Blob the create held before, now orphaned.
|
||||
pub superseded: Option<String>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||
let conn = self.conn.lock();
|
||||
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
||||
let blob: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![op.uid, op.kind],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
conn.execute(
|
||||
"DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![op.uid, op.kind],
|
||||
)?;
|
||||
blob
|
||||
} else {
|
||||
None
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
op.kind,
|
||||
op.uid,
|
||||
op.parent_uid,
|
||||
op.name,
|
||||
op.blob_path,
|
||||
op.meta_json,
|
||||
op.created_at
|
||||
],
|
||||
)?;
|
||||
Ok((conn.last_insert_rowid(), superseded))
|
||||
}
|
||||
|
||||
/// Point a queued create at the bytes that were just written to it, returning
|
||||
/// any blob it previously held so the caller can discard it.
|
||||
///
|
||||
/// This is what `release` does for a file that only exists locally: the node
|
||||
/// has no uid to hang a revision op on yet, so the bytes ride on the create.
|
||||
/// Repeated writes before the drain simply replace the blob.
|
||||
///
|
||||
/// Returns `Ok(None)` and touches nothing if the create has already drained —
|
||||
/// the caller must then queue an ordinary revision against the real uid.
|
||||
pub fn attach_blob_to_create(
|
||||
&self,
|
||||
uid: &str,
|
||||
blob_path: &str,
|
||||
meta_json: &str,
|
||||
) -> Result<Option<AttachedBlob>> {
|
||||
let conn = self.conn.lock();
|
||||
let existing: Option<(i64, Option<String>)> = conn
|
||||
.query_row(
|
||||
"SELECT id, blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![uid, OP_CREATE],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((id, superseded)) = existing else {
|
||||
return Ok(None);
|
||||
};
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET blob_path = ?2, meta_json = ?3, attempts = 0, next_attempt_at = 0
|
||||
WHERE id = ?1",
|
||||
params![id, blob_path, meta_json],
|
||||
)?;
|
||||
Ok(Some(AttachedBlob { id, superseded }))
|
||||
}
|
||||
|
||||
/// Replace the sidecar of a queued op, for a baseline that has moved under
|
||||
/// it.
|
||||
///
|
||||
/// Exists for one caller: when *our own* upload seals a new revision while a
|
||||
/// further write to the same node is already queued, that queued write's
|
||||
/// [`StagedWrite::based_on`](crate::cache::StagedWrite::based_on) still
|
||||
/// names the revision we just replaced. Left alone it would read as "another
|
||||
/// device changed this file" and divert the write into a conflict copy over
|
||||
/// nothing — a self-conflict. Restamping it is what keeps a chain of writes
|
||||
/// to one file a chain rather than a pile of copies.
|
||||
///
|
||||
/// Deliberately narrow: only the sidecar changes, so the blob, the attempt
|
||||
/// count and the backoff all survive. Returns false when no such op is
|
||||
/// queued, which is the ordinary case.
|
||||
pub fn update_op_meta(&self, uid: &str, kind: &str, meta_json: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let n = conn.execute(
|
||||
"UPDATE pending_op SET meta_json = ?3 WHERE uid = ?1 AND kind = ?2",
|
||||
params![uid, kind, meta_json],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Point a queued create at a new parent and name, for a node renamed or
|
||||
/// moved before it ever reached the server.
|
||||
///
|
||||
/// A `local~` uid means nothing to the API, so there is no rename call to
|
||||
/// make — the node is still only a queued intent, and rewriting that intent
|
||||
/// *is* the rename. Returns false when the create has already drained, in
|
||||
/// which case the node has a real uid and the caller must rename it there
|
||||
/// instead (offline.md Phase 3b).
|
||||
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let n = conn.execute(
|
||||
"UPDATE pending_op SET parent_uid = ?2, name = ?3
|
||||
WHERE uid = ?1 AND kind IN (?4, ?5)",
|
||||
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Drop every op targeting a node **or anything queued beneath it**,
|
||||
/// returning the staged blobs they held so the caller can delete them.
|
||||
///
|
||||
/// Used when a node that only ever existed locally is deleted: there is
|
||||
/// nothing on the server to trash, so the queued work simply stops being
|
||||
/// wanted.
|
||||
///
|
||||
/// The descent is what keeps the queue alive. Deleting a folder created
|
||||
/// offline drops the `mkdir` that would have given it a real uid, and any
|
||||
/// op still queued under that placeholder is then unreachable forever: it
|
||||
/// can never be attempted (its parent is a `local~` uid, so `op_is_ready`
|
||||
/// refuses it) and nothing is left to remap it. It would sit in the queue,
|
||||
/// and in the user's pending count, for the life of the database.
|
||||
///
|
||||
/// Only `create`/`mkdir` ops carry a `parent_uid`, so for a file this
|
||||
/// recursion finds nothing and costs one query.
|
||||
pub fn delete_ops_for_uid(&self, uid: &str) -> Result<Vec<String>> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(blobs)
|
||||
}
|
||||
|
||||
/// Rewrite every queued op that points at a placeholder parent, once that
|
||||
/// parent has drained and has a real uid. Also moves the node rows whose
|
||||
/// parent column still names the placeholder, so listings keep resolving.
|
||||
pub fn remap_local_uid(&self, local: &str, real: &str) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
tx.execute(
|
||||
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
|
||||
params![local, real],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every queued op, oldest first. The drain worker replays them in this order
|
||||
/// so a file's writes land in the order they were made.
|
||||
/// The oldest queued op that is due and not blocked, or `None`.
|
||||
///
|
||||
/// This is the drain loop's per-iteration query, and it is deliberately not
|
||||
/// `pending_ops().find(...)`. That variant read every row — including each
|
||||
/// op's full `meta_json` — sorted them, and threw all but one away, once per
|
||||
/// drained op *and* once per retry, while holding the single connection
|
||||
/// mutex that every FUSE `lookup` also needs. A queue of a few thousand ops
|
||||
/// (an ordinary offline `cp -r`) made draining quadratic in the queue length.
|
||||
///
|
||||
/// Both filters are in SQL so the row is decided by the database rather than
|
||||
/// materialized and rejected in Rust:
|
||||
///
|
||||
/// * `next_attempt_at <= now` — the backoff.
|
||||
/// * a `local~` parent is skipped. A node created inside a folder that was
|
||||
/// itself created offline cannot be sent anywhere until that folder is
|
||||
/// real. Ops replay in queue order so the parent normally drains first;
|
||||
/// this matters when the parent is backing off, where the child must wait
|
||||
/// rather than burn its own retries.
|
||||
///
|
||||
/// `id` is `INTEGER PRIMARY KEY`, i.e. the rowid, so `ORDER BY id LIMIT 1`
|
||||
/// walks the table in insertion order and stops at the first match — no
|
||||
/// sort, and no separate index to maintain. The common case (something is
|
||||
/// due) exits on the first row.
|
||||
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare_cached(&format!(
|
||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||
attempts, last_error, next_attempt_at
|
||||
FROM pending_op
|
||||
WHERE next_attempt_at <= ?1
|
||||
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
|
||||
ORDER BY id LIMIT 1",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
))?;
|
||||
let op = stmt
|
||||
.query_row(params![now], |r| {
|
||||
Ok(PendingOp {
|
||||
id: r.get(0)?,
|
||||
kind: r.get(1)?,
|
||||
uid: r.get(2)?,
|
||||
parent_uid: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
blob_path: r.get(5)?,
|
||||
meta_json: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
attempts: r.get(8)?,
|
||||
last_error: r.get(9)?,
|
||||
next_attempt_at: r.get(10)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
Ok(op)
|
||||
}
|
||||
|
||||
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
||||
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
||||
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||
attempts, last_error, next_attempt_at
|
||||
FROM pending_op ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(PendingOp {
|
||||
id: r.get(0)?,
|
||||
kind: r.get(1)?,
|
||||
uid: r.get(2)?,
|
||||
parent_uid: r.get(3)?,
|
||||
name: r.get(4)?,
|
||||
blob_path: r.get(5)?,
|
||||
meta_json: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
attempts: r.get(8)?,
|
||||
last_error: r.get(9)?,
|
||||
next_attempt_at: r.get(10)?,
|
||||
})
|
||||
})?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many queued ops there are of each kind, for `Response::Status`.
|
||||
///
|
||||
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
||||
/// not on the remote yet. A queued `mkdir`/`rename`/`trash` is also work the
|
||||
/// mount owes the server, but it carries no bytes and reporting it as an
|
||||
/// upload is simply untrue.
|
||||
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
||||
let conn = self.conn.lock();
|
||||
let uploads = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
||||
params![OP_REVISION, OP_CREATE],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let changes = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE kind NOT IN (?1, ?2)",
|
||||
params![OP_REVISION, OP_CREATE],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(PendingCounts { uploads, changes })
|
||||
}
|
||||
|
||||
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
||||
/// drain replaces it with the uid the server assigns.
|
||||
pub const LOCAL_VOLUME: &str = "local";
|
||||
|
||||
/// Drop a queued op, once its upload has actually landed.
|
||||
pub fn delete_op(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM pending_op WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a failed attempt and when to next try. Leaves the row in place —
|
||||
/// the staged bytes are still the only copy of the user's write.
|
||||
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3
|
||||
WHERE id = ?1",
|
||||
params![id, error, next_attempt_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! The photos timeline: a flat, date-ordered projection of the photo share,
|
||||
//! persisted so the gallery opens instantly instead of re-enumerating on launch.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// A photo whose thumbnail state is not known yet: it has never been asked for.
|
||||
pub const THUMB_UNKNOWN: i64 = 0;
|
||||
/// A thumbnail exists for this photo — served by the server, or generated locally
|
||||
/// from the full file when the server had none.
|
||||
pub const THUMB_HAVE: i64 = 1;
|
||||
/// This photo can never be given a thumbnail: the server has none and the bytes
|
||||
/// could not be decoded locally either. Never retried.
|
||||
pub const THUMB_NONE: i64 = 2;
|
||||
|
||||
/// One photo of the persisted timeline. The timeline itself is server-ordered
|
||||
/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state`
|
||||
/// are locally learned and survive a refresh of the timeline.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredPhoto {
|
||||
pub uid: String,
|
||||
pub capture_time: i64,
|
||||
pub name: Option<String>,
|
||||
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
|
||||
pub ratio: Option<f64>,
|
||||
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
|
||||
pub thumb_state: i64,
|
||||
/// Which Photos-page tab this entry belongs to, derived from its name and
|
||||
/// media type when the timeline was last replaced.
|
||||
pub kind: crate::control::PhotoKind,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn photos_replace(
|
||||
&self,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||
// timeline DTO carries only the uid and capture time, so the daemon may
|
||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||
// refresh instead of collapsing back to name-extension guesses.
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
tx.execute("DELETE FROM photos", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
let kind =
|
||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
||||
stmt.execute(params![
|
||||
uid,
|
||||
capture_time,
|
||||
name,
|
||||
ratio,
|
||||
thumb_state,
|
||||
seq as i64,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One page of the persisted timeline, newest first. `kind`, when set,
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||
pub fn photos_page(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql =
|
||||
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
|
||||
let mut binds: Vec<i64> = Vec::new();
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
if let Some(k) = kind {
|
||||
binds.push(k.as_i64());
|
||||
conds.push(format!("kind = ?{}", binds.len()));
|
||||
}
|
||||
if let Some((from, to)) = range {
|
||||
binds.push(from);
|
||||
conds.push(format!("capture_time >= ?{}", binds.len()));
|
||||
binds.push(to);
|
||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||
}
|
||||
if !conds.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&conds.join(" AND "));
|
||||
}
|
||||
binds.push(limit as i64);
|
||||
let limit_pos = binds.len();
|
||||
binds.push(offset as i64);
|
||||
let offset_pos = binds.len();
|
||||
sql.push_str(&format!(
|
||||
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||
));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The months the timeline spans, newest first, each with how many photos it
|
||||
/// holds — the data behind the date scrubber. Buckets are local-time
|
||||
/// `(year, month)` so they line up with the day headings the gallery draws
|
||||
/// and with the boundaries a front-end computes when it jumps to one. `kind`
|
||||
/// scopes the counts to one tab when set.
|
||||
pub fn photos_months(
|
||||
&self,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
) -> Result<Vec<(i32, i32, usize)>> {
|
||||
let conn = self.conn.lock();
|
||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
||||
None => ("", Vec::new()),
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||
CAST(strftime('%m', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS m, \
|
||||
COUNT(*) \
|
||||
FROM photos{filter} GROUP BY y, m ORDER BY y DESC, m DESC"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
||||
Ok((
|
||||
r.get::<_, i32>(0)?,
|
||||
r.get::<_, i32>(1)?,
|
||||
r.get::<_, i64>(2)? as usize,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||
for row in rows {
|
||||
let (kind, n) = row?;
|
||||
match PhotoKind::from_i64(kind) {
|
||||
PhotoKind::Photo => photos = n as usize,
|
||||
PhotoKind::Video => videos = n as usize,
|
||||
PhotoKind::Raw => raw = n as usize,
|
||||
}
|
||||
}
|
||||
Ok((photos, videos, raw))
|
||||
}
|
||||
|
||||
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
|
||||
/// path, which needs each photo's capture time (the cache validity tag) and
|
||||
/// its thumbnail verdict.
|
||||
pub fn photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
})
|
||||
})?;
|
||||
let mut photos = Vec::new();
|
||||
for row in rows {
|
||||
photos.push(row?);
|
||||
}
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record what a thumbnail attempt learned: whether the photo now has one
|
||||
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
||||
/// seen. A `None` ratio leaves any previously learned one alone.
|
||||
pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
|
||||
params![uid, state, ratio],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of photos in the persisted timeline.
|
||||
pub fn photos_count(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Pinned nodes: files and folders the user has asked to keep on disk, exempt
|
||||
//! from LRU eviction. A recursive pin covers a whole subtree.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
impl Db {
|
||||
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO pins (uid, path, recursive) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
path = excluded.path, recursive = excluded.recursive",
|
||||
params![uid, path, recursive as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop `uid`'s pin row. Returns whether a row existed.
|
||||
pub fn pin_remove(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let n = conn.execute("DELETE FROM pins WHERE uid = ?1", params![uid])?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Every directly-pinned entry `(uid, path, recursive)`, ordered by uid.
|
||||
pub fn pin_list(&self) -> Result<Vec<(String, String, bool)>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT uid, path, recursive FROM pins ORDER BY uid")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)? != 0,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
|
||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
||||
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let direct: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
if direct.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
let anc: Option<i64> = conn
|
||||
.query_row(
|
||||
"WITH RECURSIVE anc(uid, parent_uid) AS (
|
||||
SELECT uid, parent_uid FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
)
|
||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||
WHERE a.uid != ?1 AND p.recursive = 1
|
||||
LIMIT 1",
|
||||
params![uid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(anc.is_some())
|
||||
}
|
||||
|
||||
/// Every uid that is pinned, directly or transitively: all direct pins plus
|
||||
/// every descendant of a recursively-pinned folder. Used to build the
|
||||
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM pins WHERE recursive = 1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
)
|
||||
SELECT uid FROM sub
|
||||
UNION
|
||||
SELECT uid FROM pins",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||
UNION
|
||||
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
)
|
||||
SELECT uid FROM sub",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- local (non-Drive) file index (schema v6) ----
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The `state` key/value table: small scalars that outlive a mount — the volume
|
||||
//! event cursor, listing freshness stamps, the local-index generation.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
impl Db {
|
||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Persist the incremental-sync cursor after a batch of events is applied.
|
||||
pub fn set_event_cursor(&self, cursor: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('event_cursor', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![cursor],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a `sync_state` value as a string.
|
||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
params![key],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Write a `sync_state` string value.
|
||||
pub fn set_state_str(&self, key: &str, value: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue an op to be performed by the drain worker, returning its row id.
|
||||
///
|
||||
/// One *revision* op per node: a second write to the same file before the
|
||||
/// first has drained replaces it, since the newer blob already contains
|
||||
/// everything the older one did. The superseded blob's path is returned so
|
||||
/// the caller can delete it.
|
||||
///
|
||||
/// Rows supersede only their own kind, and only the kinds
|
||||
/// [`op_supersedes`] names. A create op for the same uid must survive —
|
||||
/// dropping it would leave a file that exists nowhere but this machine with
|
||||
/// nothing left to create it. (Writes to a node that is itself still queued
|
||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock();
|
||||
let v: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
params![key],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(v.and_then(|v| v.parse().ok()))
|
||||
}
|
||||
|
||||
/// Write a `sync_state` integer value.
|
||||
pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop a `sync_state` key, so whatever it stamped counts as never fetched.
|
||||
pub fn clear_state(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Folder-sync bookkeeping: the folders this device mirrors, and the per-file
|
||||
//! state each pass reconciles against.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct StoredSyncFolder {
|
||||
pub id: i64,
|
||||
pub local_path: String,
|
||||
pub remote_uid: String,
|
||||
pub remote_share_id: String,
|
||||
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
|
||||
pub mode: String,
|
||||
/// A mode the user asked for that could not be applied on the spot — the
|
||||
/// folder was mid-pass, or had un-uploaded changes. The engine applies it
|
||||
/// once the folder is safe to switch, so the request is queued rather than
|
||||
/// rejected. `None` when the folder is where the user wants it.
|
||||
pub pending_mode: Option<String>,
|
||||
/// `idle` | `syncing` | `error` | `conflict`.
|
||||
pub state: String,
|
||||
pub last_sync: i64,
|
||||
}
|
||||
|
||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||
/// last successful sync, so the next reconcile can tell which side changed
|
||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
||||
/// its modification time and size as strings — since no cheap content hash is
|
||||
/// exposed; change detection is `(mtime, size)` on each side.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredSyncEntry {
|
||||
pub rel_path: String,
|
||||
pub remote_uid: Option<String>,
|
||||
pub local_mtime: i64,
|
||||
pub local_size: i64,
|
||||
pub remote_rev: Option<String>,
|
||||
pub remote_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn sync_folder_add(
|
||||
&self,
|
||||
local_path: &str,
|
||||
remote_uid: &str,
|
||||
remote_share_id: &str,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![local_path, remote_uid, remote_share_id],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Every synced folder, oldest first.
|
||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(StoredSyncFolder {
|
||||
id: r.get(0)?,
|
||||
local_path: r.get(1)?,
|
||||
remote_uid: r.get(2)?,
|
||||
remote_share_id: r.get(3)?,
|
||||
mode: r.get(4)?,
|
||||
pending_mode: r.get(5)?,
|
||||
state: r.get(6)?,
|
||||
last_sync: r.get(7)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(row?);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Look up one synced folder by id.
|
||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder WHERE id = ?1",
|
||||
params![id],
|
||||
|r| {
|
||||
Ok(StoredSyncFolder {
|
||||
id: r.get(0)?,
|
||||
local_path: r.get(1)?,
|
||||
remote_uid: r.get(2)?,
|
||||
remote_share_id: r.get(3)?,
|
||||
mode: r.get(4)?,
|
||||
pending_mode: r.get(5)?,
|
||||
state: r.get(6)?,
|
||||
last_sync: r.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Remove a synced folder and its per-file baseline.
|
||||
pub fn sync_folder_remove(&self, id: i64) -> Result<bool> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM sync_entry WHERE folder_id = ?1", params![id])?;
|
||||
let n = tx.execute("DELETE FROM sync_folder WHERE id = ?1", params![id])?;
|
||||
tx.commit()?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has
|
||||
/// reached the mode it was asked for, so any queued request is satisfied and
|
||||
/// cleared in the same write — a `pending_mode` outliving the switch it asked
|
||||
/// for would have the engine try to apply it again on the next pass.
|
||||
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||
/// is safe to switch.
|
||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET pending_mode = ?2 WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a synced folder's state and stamp `last_sync` to now.
|
||||
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET state = ?2, last_sync = ?3 WHERE id = ?1",
|
||||
params![id, state, last_sync],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
||||
FROM sync_entry WHERE folder_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder_id], |r| {
|
||||
Ok(StoredSyncEntry {
|
||||
rel_path: r.get(0)?,
|
||||
remote_uid: r.get(1)?,
|
||||
local_mtime: r.get(2)?,
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let e = row?;
|
||||
map.insert(e.rel_path.clone(), e);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Insert or replace one baseline row.
|
||||
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash",
|
||||
params![
|
||||
folder_id,
|
||||
e.rel_path,
|
||||
e.remote_uid,
|
||||
e.local_mtime,
|
||||
e.local_size,
|
||||
e.remote_rev,
|
||||
e.remote_hash,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop one baseline row (a path that left the sync set on both sides).
|
||||
pub fn sync_entry_remove(&self, folder_id: i64, rel_path: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2",
|
||||
params![folder_id, rel_path],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM sync_entry WHERE folder_id = ?1",
|
||||
params![folder_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,64 @@
|
||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
pub struct StoredTrash {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
pub size: i64,
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM trash", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)?;
|
||||
for item in items {
|
||||
stmt.execute(params![
|
||||
item.uid,
|
||||
item.name,
|
||||
item.is_dir as i64,
|
||||
item.size,
|
||||
item.mtime
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persisted trash listing, folders first then by name — the order the
|
||||
/// Trash page shows it in.
|
||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok(StoredTrash {
|
||||
uid: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
is_dir: r.get::<_, i64>(2)? != 0,
|
||||
size: r.get(3)?,
|
||||
mtime: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(row?);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
// ---- device sync (devices.md) -----------------------------------------
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Small query helpers shared across the table modules.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::Result;
|
||||
|
||||
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
|
||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||
pub(super) const TRIGRAM_MIN: usize = 3;
|
||||
|
||||
pub(super) fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
}
|
||||
|
||||
/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors.
|
||||
pub(super) fn collect_pairs(
|
||||
rows: impl Iterator<Item = rusqlite::Result<(String, String)>>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Escape `LIKE` wildcards in a user query so `%` and `_` match literally
|
||||
/// (paired with `ESCAPE '\'` in the statement).
|
||||
pub(super) fn like_escape(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the
|
||||
/// root via a recursive CTE. The root (the node with no parent) is excluded, so
|
||||
/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
)
|
||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![uid], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
|
||||
})?;
|
||||
let mut parts = Vec::new();
|
||||
for r in rows {
|
||||
let (name, parent_uid) = r?;
|
||||
// Skip the root node (no parent); its name is the mount itself.
|
||||
if parent_uid.is_some() {
|
||||
parts.push(name);
|
||||
}
|
||||
}
|
||||
Ok(parts.join("/"))
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::control::ErrorKind;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("proton api: {0}")]
|
||||
@@ -34,3 +36,255 @@ pub enum Error {
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A failure on its way to a front-end: prose for the user, plus the
|
||||
/// [`ErrorKind`] the front-end actually branches on.
|
||||
///
|
||||
/// This is what the daemon's request-serving methods return, as opposed to
|
||||
/// [`Error`] above, which is the plumbing error of the `pdfs-core` layer. The
|
||||
/// two meet at the `From` impls below: a `rusqlite` or `io` failure carries no
|
||||
/// information a user can act on, so it arrives here as
|
||||
/// [`ErrorKind::Internal`] and keeps its text only for the log.
|
||||
///
|
||||
/// Classification happens where the failure is *understood* — at the API
|
||||
/// boundary that knows a 404 from a 403, at the path resolver that knows an
|
||||
/// `ENOENT` — never at the call site, which by then has only a string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CoreError {
|
||||
pub kind: ErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl CoreError {
|
||||
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The API is unreachable. Carries fixed wording because there is only ever
|
||||
/// one thing to say about it.
|
||||
pub fn offline() -> Self {
|
||||
Self::new(ErrorKind::Offline, "the Proton Drive API is unreachable")
|
||||
}
|
||||
|
||||
pub fn not_found(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::NotFound, what)
|
||||
}
|
||||
|
||||
pub fn denied(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Denied, what)
|
||||
}
|
||||
|
||||
pub fn conflict(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Conflict, what)
|
||||
}
|
||||
|
||||
pub fn invalid(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Invalid, what)
|
||||
}
|
||||
|
||||
pub fn remote(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Remote, what)
|
||||
}
|
||||
|
||||
pub fn internal(what: impl Into<String>) -> Self {
|
||||
Self::new(ErrorKind::Internal, what)
|
||||
}
|
||||
|
||||
/// Classify a failure from an SDK call, prefixing `what` for the log.
|
||||
///
|
||||
/// Takes `&dyn Error` rather than `&ProtonError` so it reads the same
|
||||
/// through the boxes the drain deals in, where the concrete type survives
|
||||
/// but the static one does not.
|
||||
///
|
||||
/// A failure that is not an API response at all — a transport error, a
|
||||
/// timeout, a TLS failure — is what being offline looks like from here, so
|
||||
/// it classifies as [`ErrorKind::Offline`] rather than
|
||||
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
|
||||
/// user their connection is down beats telling them the server refused.
|
||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that is not an `Api` variant never reached the
|
||||
// API; so does anything that is not a `ProtonError` at all.
|
||||
_ => ErrorKind::Offline,
|
||||
};
|
||||
Self::new(kind, format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
/// Prefix the message while keeping the classification.
|
||||
///
|
||||
/// For adding the caller's context to an already-classified failure — the
|
||||
/// kind was decided by whoever understood the error and must survive being
|
||||
/// passed up.
|
||||
pub fn context(mut self, what: &str) -> Self {
|
||||
self.message = format!("{what}: {}", self.message);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CoreError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CoreError {}
|
||||
|
||||
impl From<rusqlite::Error> for CoreError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::internal(format!("database: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for CoreError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::internal(format!("io: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for CoreError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
Self::internal(format!("serialization: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied,
|
||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that never reached the API is a transport failure.
|
||||
Error::Proton(_) => ErrorKind::Offline,
|
||||
_ => ErrorKind::Internal,
|
||||
};
|
||||
Self::new(kind, e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What an API response code means to a front-end.
|
||||
///
|
||||
/// The grouping is by *what the user can do about it*, which is what
|
||||
/// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a
|
||||
/// `NotEnoughPermissions` land together despite being different numbers, while
|
||||
/// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx.
|
||||
fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
match code {
|
||||
// The account may not do this, and repeating it will not help.
|
||||
C::Unauthorized
|
||||
| C::Forbidden
|
||||
| C::NotEnoughPermissions
|
||||
| C::NotEnoughPermissionsToGrantPermissions
|
||||
| C::InsufficientScope
|
||||
| C::IncorrectLoginCredentials
|
||||
| C::InvalidRefreshToken
|
||||
| C::AccountDeleted
|
||||
| C::AccountDisabled
|
||||
| C::NoActiveSubscription => ErrorKind::Denied,
|
||||
|
||||
C::DoesNotExist => ErrorKind::NotFound,
|
||||
|
||||
// The remote moved underneath the request; the caller has to decide.
|
||||
C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict,
|
||||
|
||||
// The request was malformed or asks for something structurally
|
||||
// impossible. A bug in the caller, not a condition to retry.
|
||||
C::InvalidRequirements
|
||||
| C::InvalidValue
|
||||
| C::InvalidEncryptedIdFormat
|
||||
| C::InvalidApp
|
||||
| C::TooManyChildren
|
||||
| C::NestingTooDeep => ErrorKind::Invalid,
|
||||
|
||||
C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => {
|
||||
ErrorKind::Quota
|
||||
}
|
||||
|
||||
// Reached Proton, but it could not answer *now*. Retrying is exactly
|
||||
// the right response, so these are Offline rather than Remote — the
|
||||
// front-end already words that as "not right now".
|
||||
C::RequestTimeout
|
||||
| C::Timeout
|
||||
| C::TooManyRequests
|
||||
| C::ServiceUnavailable
|
||||
| C::Offline => ErrorKind::Offline,
|
||||
|
||||
// Reached Proton and it refused for a reason we have no specific
|
||||
// handling for. Retryable, because an unchanged retry can legitimately
|
||||
// succeed against a transient server-side condition.
|
||||
_ => ErrorKind::Remote,
|
||||
}
|
||||
}
|
||||
|
||||
/// The result every request-serving method in the daemon returns.
|
||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
use proton_sdk::{ProtonApiError, ProtonError};
|
||||
|
||||
fn api(code: C) -> ProtonError {
|
||||
ProtonError::Api(ProtonApiError {
|
||||
code,
|
||||
http_status: 422,
|
||||
message: "nope".into(),
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_codes_classify_by_what_the_user_can_do() {
|
||||
assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied);
|
||||
assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied);
|
||||
assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound);
|
||||
assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict);
|
||||
assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid);
|
||||
assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota);
|
||||
assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline);
|
||||
// Nothing specific known about it, but Proton did answer.
|
||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
||||
}
|
||||
|
||||
/// The regression this whole pass exists to prevent: before it, every SDK
|
||||
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||
/// request that could never succeed.
|
||||
#[test]
|
||||
fn a_forbidden_response_is_not_offered_as_retryable() {
|
||||
let e = CoreError::from_api(&api(C::Forbidden), "rename");
|
||||
assert_eq!(e.kind, ErrorKind::Denied);
|
||||
assert!(!e.kind.retryable());
|
||||
assert!(e.message.starts_with("rename: "));
|
||||
}
|
||||
|
||||
/// Being full is not a transient condition, so retrying an upload that did
|
||||
/// not fit must not be offered as a fix.
|
||||
#[test]
|
||||
fn running_out_of_space_is_not_retryable() {
|
||||
assert!(
|
||||
!CoreError::from_api(&api(C::InsufficientQuota), "upload")
|
||||
.kind
|
||||
.retryable()
|
||||
);
|
||||
}
|
||||
|
||||
/// A failure that never reached the API is the user's connection, not
|
||||
/// Proton refusing — the front-end words those differently.
|
||||
#[test]
|
||||
fn a_transport_failure_reads_as_offline() {
|
||||
let io = std::io::Error::other("connection reset");
|
||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||
/// the direct path, or the two disagree about the same failure.
|
||||
#[test]
|
||||
fn converting_from_the_plumbing_error_classifies_the_api_code() {
|
||||
let e: CoreError = Error::Proton(api(C::DoesNotExist)).into();
|
||||
assert_eq!(e.kind, ErrorKind::NotFound);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ pub mod localindex;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
// Re-export the SDK surface downstream crates need, so they depend on
|
||||
// `pdfs-core` rather than pinning the SDK directly.
|
||||
|
||||
@@ -93,7 +93,7 @@ pub fn scan(
|
||||
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
|
||||
});
|
||||
|
||||
let state = std::sync::Mutex::new(SinkState {
|
||||
let state = parking_lot::Mutex::new(SinkState {
|
||||
buf: Vec::with_capacity(BATCH),
|
||||
total: 0,
|
||||
sink,
|
||||
@@ -112,7 +112,7 @@ pub fn scan(
|
||||
return WalkState::Continue;
|
||||
};
|
||||
|
||||
let mut state = state.lock().unwrap();
|
||||
let mut state = state.lock();
|
||||
state.push(local);
|
||||
if state.total >= MAX_ENTRIES {
|
||||
return WalkState::Quit;
|
||||
@@ -121,7 +121,7 @@ pub fn scan(
|
||||
})
|
||||
});
|
||||
|
||||
let mut state = state.into_inner().unwrap();
|
||||
let mut state = state.into_inner();
|
||||
state.flush();
|
||||
state.total
|
||||
}
|
||||
|
||||
@@ -17,3 +17,4 @@ libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
image.workspace = true
|
||||
notify = "8"
|
||||
parking_lot.workspace = true
|
||||
@@ -0,0 +1,702 @@
|
||||
//! The control socket: the daemon`s IPC surface.
|
||||
//!
|
||||
//! The CLI and the GUI never touch the mount or the database directly — they
|
||||
//! open this Unix socket, write one JSON [`CtlRequest`] line, and read one JSON
|
||||
//! [`CtlResponse`] line back. That keeps the daemon the single writer of both
|
||||
//! the FUSE state and `cache.db`.
|
||||
//!
|
||||
//! Connections are served concurrently, one thread each, because requests differ
|
||||
//! wildly in cost: an `OpenPhoto` downloads a whole photo, and a serial loop
|
||||
//! would stall the GUI`s 2s status poll behind it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
TransferDirection,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::transfers::CountingReader;
|
||||
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||
/// the mount root.
|
||||
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
p.strip_prefix(mountpoint)
|
||||
.map(Path::to_path_buf)
|
||||
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
||||
} else {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle one control-socket connection: read a single JSON request line,
|
||||
/// dispatch it against `core`, and write back a JSON response line.
|
||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||
let mut reader = BufReader::new(match stream.try_clone() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "control: clone stream failed");
|
||||
return;
|
||||
}
|
||||
});
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
||||
Ok(CtlRequest::Status) => {
|
||||
let pins = core.cache.list_pins();
|
||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||
CtlResponse::Status {
|
||||
username: username.to_string(),
|
||||
mountpoint: mountpoint.display().to_string(),
|
||||
pinned: pins.len(),
|
||||
used: core.cache.usage(),
|
||||
budget: core.cache.budget(),
|
||||
pins,
|
||||
online: core.online.load(Ordering::Relaxed),
|
||||
pending_uploads: queued.uploads.max(0) as u64,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.pin(&rel) {
|
||||
Ok(name) => CtlResponse::Ok {
|
||||
message: format!("pinned {name}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.unpin(&rel) {
|
||||
Ok(name) => CtlResponse::Ok {
|
||||
message: format!("unpinned {name}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||
pins: core.cache.list_pins(),
|
||||
},
|
||||
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.list_dir(&rel) {
|
||||
Ok(entries) => CtlResponse::Entries { entries },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Refresh { scope }) => {
|
||||
let result = match &scope {
|
||||
RefreshScope::Dir { path } => {
|
||||
rel_to_mount(mountpoint, path).and_then(|rel| core.refresh_dir(&rel))
|
||||
}
|
||||
RefreshScope::Trash => {
|
||||
core.invalidate_trash();
|
||||
Ok(())
|
||||
}
|
||||
RefreshScope::Photos => {
|
||||
core.invalidate_photos();
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "refreshed".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::PhotosTimeline {
|
||||
offset,
|
||||
limit,
|
||||
kind,
|
||||
range,
|
||||
}) => match core.photos_timeline(offset, limit, kind, range) {
|
||||
Ok(Some(items)) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
counts: core.db.photos_counts().ok(),
|
||||
},
|
||||
Ok(None) => CtlResponse::Photos {
|
||||
available: false,
|
||||
items: Vec::new(),
|
||||
counts: None,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||
Ok(months) => CtlResponse::PhotoMonths {
|
||||
months: months
|
||||
.into_iter()
|
||||
.map(|(year, month, count)| PhotoMonth { year, month, count })
|
||||
.collect(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e.into()),
|
||||
},
|
||||
Ok(CtlRequest::PhotoThumbs { uids }) => {
|
||||
let parsed: Vec<NodeUid> = uids.iter().filter_map(|u| parse_uid(u)).collect();
|
||||
CtlResponse::Thumbs {
|
||||
items: core.photo_thumbs(&parsed),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||
Some(u) => match core.open_photo(&u) {
|
||||
Ok(p) => CtlResponse::FilePath {
|
||||
path: p.display().to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::UploadPhoto {
|
||||
name,
|
||||
media_type,
|
||||
source_path,
|
||||
capture_time,
|
||||
}) => {
|
||||
// Streamed off disk rather than carried over the socket: see the note
|
||||
// on `Request::UploadPhoto`. The length comes from the same handle the
|
||||
// bytes do, so a file changing size under us cannot make the two
|
||||
// disagree.
|
||||
match std::fs::File::open(&source_path).and_then(|f| f.metadata().map(|m| (f, m.len())))
|
||||
{
|
||||
Err(e) => CtlResponse::error(CoreError::invalid(format!(
|
||||
"cannot read {source_path}: {e}"
|
||||
))),
|
||||
Ok((file, len)) => {
|
||||
let photos = core.photos();
|
||||
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||
capture_time,
|
||||
..Default::default()
|
||||
};
|
||||
let guard =
|
||||
core.transfers
|
||||
.begin(name.clone(), "", TransferDirection::Upload, len);
|
||||
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||
match core.rt.block_on(photos.upload_photo_from(
|
||||
&name,
|
||||
&media_type,
|
||||
reader,
|
||||
len as i64,
|
||||
Vec::new(),
|
||||
metadata,
|
||||
false,
|
||||
)) {
|
||||
Ok(uid) => {
|
||||
// The photo we just uploaded belongs at the head of the
|
||||
// timeline, and the GUI reloads the gallery the moment this
|
||||
// reply lands — so refresh now rather than leaving it to a
|
||||
// background pass that would land just after that reload.
|
||||
if let Err(e) = core.rt.block_on(core.refresh_timeline()) {
|
||||
warn!(error = %e, "timeline refresh after upload failed");
|
||||
}
|
||||
CtlResponse::Ok {
|
||||
message: format!("uploaded photo with uid {uid}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.open_file(&rel) {
|
||||
Ok(p) => CtlResponse::FilePath {
|
||||
path: p.display().to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
|
||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
|
||||
Ok(hits) => CtlResponse::LocalResults {
|
||||
hits,
|
||||
indexing: core.indexing.load(Ordering::Relaxed),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.rename(&rel, &new_name) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::Rename, &name, format!("was {path}"), true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("renamed to {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Rename, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Move { path, new_parent }) => {
|
||||
match (
|
||||
rel_to_mount(mountpoint, &path),
|
||||
rel_to_mount(mountpoint, &new_parent),
|
||||
) {
|
||||
(Ok(rel), Ok(parent_rel)) => match core.move_to(&rel, &parent_rel) {
|
||||
Ok(name) => {
|
||||
let dest = if new_parent.is_empty() {
|
||||
"My files".to_string()
|
||||
} else {
|
||||
new_parent.clone()
|
||||
};
|
||||
core.log_activity(ActivityKind::Move, &name, format!("to {dest}"), true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("moved {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Move, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.delete(&rel) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::Trash, &name, "", true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("trashed {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Trash, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) {
|
||||
Ok(parent_rel) => match core.create_folder(&parent_rel, &name) {
|
||||
Ok(name) => {
|
||||
core.log_activity(ActivityKind::CreateFolder, &name, "", true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("created folder {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UploadPaths { parent, sources }) => {
|
||||
match rel_to_mount(mountpoint, &parent) {
|
||||
// Ack immediately and upload on a background thread: a directory tree
|
||||
// far outlasts the socket read timeout. Progress and completion are
|
||||
// observed through GetQueueStatus; the activity log gets the summary
|
||||
// when the whole batch finishes.
|
||||
Ok(parent_rel) => {
|
||||
let core = core.clone();
|
||||
let paths: Vec<PathBuf> = sources.into_iter().map(PathBuf::from).collect();
|
||||
let n = paths.len();
|
||||
std::thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
match core.upload_paths(&parent_rel, &paths) {
|
||||
Ok(stats) => {
|
||||
// e.g. "Uploaded 700 files to Photos" — name the destination
|
||||
// so the log reads like a sentence rather than a bare count.
|
||||
let dest = parent_rel
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("your Drive");
|
||||
let target = format!(
|
||||
"{} to {dest}",
|
||||
count_noun(stats.uploaded, "file", "files")
|
||||
);
|
||||
// Size · folders · elapsed, with a trailing failure count
|
||||
// when some files didn't make it.
|
||||
let mut parts = vec![human_bytes(stats.bytes)];
|
||||
if stats.folders > 0 {
|
||||
parts.push(count_noun(stats.folders, "folder", "folders"));
|
||||
}
|
||||
parts.push(human_duration(started.elapsed()));
|
||||
if stats.failed > 0 {
|
||||
parts.push(format!("{} failed", stats.failed));
|
||||
}
|
||||
core.log_activity(
|
||||
ActivityKind::Upload,
|
||||
target,
|
||||
parts.join(" · "),
|
||||
stats.failed == 0,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "bulk upload failed");
|
||||
core.log_activity(ActivityKind::Upload, "bulk upload", &e, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
CtlResponse::Ok {
|
||||
message: format!("uploading {n} item(s)"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
||||
Ok(entries) => CtlResponse::Entries { entries },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) {
|
||||
Ok(n) => {
|
||||
core.log_activity(ActivityKind::Restore, format!("{n} item(s)"), "", true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("restored {n} item(s)"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) {
|
||||
Ok(n) => {
|
||||
core.log_activity(
|
||||
ActivityKind::DeleteForever,
|
||||
format!("{n} item(s)"),
|
||||
"",
|
||||
true,
|
||||
);
|
||||
CtlResponse::Ok {
|
||||
message: format!("permanently deleted {n} item(s)"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||
Ok(n) => {
|
||||
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("emptied trash ({n} item(s))"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PurgeCache) => {
|
||||
let freed = core.cache.clear_unpinned();
|
||||
CtlResponse::Ok {
|
||||
message: format!(
|
||||
"purged {:.1} MiB of unpinned cache",
|
||||
freed as f64 / 1_048_576.0
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
|
||||
items: core.transfers.snapshot(),
|
||||
jobs: core.jobs_snapshot(),
|
||||
},
|
||||
Ok(CtlRequest::SetCacheBudget { bytes }) => {
|
||||
core.cache.set_budget(bytes);
|
||||
// Persist so the next mount keeps the new cap. Best-effort: a config
|
||||
// write failure is reported but the live cap is already applied.
|
||||
match AppDirs::new().map(|dirs| {
|
||||
let mut cfg = dirs.load_config();
|
||||
cfg.cache_budget = Some(bytes);
|
||||
dirs.save_config(&cfg)
|
||||
}) {
|
||||
Ok(Ok(())) => CtlResponse::Ok {
|
||||
message: format!("cache budget set to {bytes} bytes"),
|
||||
},
|
||||
Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!(
|
||||
"budget applied but config write failed: {e}"
|
||||
))),
|
||||
Err(e) => CtlResponse::error(CoreError::internal(format!(
|
||||
"budget applied but config unavailable: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
||||
Ok(items) => CtlResponse::Devices { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("renamed device to {name}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::DeleteDevice { uid }) => match core.delete_device(&uid) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "device deleted".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||
// Registering the device and uploading a folder tree far outlasts the
|
||||
// socket read timeout, so ack immediately and work on a background
|
||||
// thread. The folder appears in ListSyncFolders once the row lands;
|
||||
// completion (and any failures) go to the activity log.
|
||||
let core = core.clone();
|
||||
let path = PathBuf::from(&local_path);
|
||||
std::thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
match core.add_sync_folder(&path) {
|
||||
Ok(folder) => {
|
||||
let name = Path::new(&folder.local_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&folder.local_path)
|
||||
.to_string();
|
||||
core.log_activity(
|
||||
ActivityKind::Upload,
|
||||
format!("synced {name}"),
|
||||
human_duration(started.elapsed()),
|
||||
folder.state != "error",
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "add sync folder failed");
|
||||
core.log_activity(ActivityKind::Upload, "add sync folder", &e, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
CtlResponse::Ok {
|
||||
message: format!("adding {local_path}"),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
|
||||
Ok(items) => CtlResponse::SyncFolders { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemoveSyncFolder { id, delete_remote }) => {
|
||||
match core.remove_sync_folder(id, delete_remote) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "removed synced folder".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SetSyncFolderMode { id, mode }) => {
|
||||
match core.request_sync_folder_mode(id, &mode) {
|
||||
Ok(message) => {
|
||||
core.log_activity(ActivityKind::Upload, &message, "", true);
|
||||
CtlResponse::Ok { message }
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SyncNow { id }) => {
|
||||
core.sync_now(id);
|
||||
CtlResponse::Ok {
|
||||
message: match id {
|
||||
Some(id) => format!("reconciling folder {id}"),
|
||||
None => "reconciling all folders".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ShareNode {
|
||||
path,
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.share_node(&rel, &emails, &role, message.as_deref()) {
|
||||
Ok((proton, external)) => {
|
||||
core.log_activity(
|
||||
ActivityKind::Share,
|
||||
&path,
|
||||
format!("{} recipient(s) as {role}", proton + external),
|
||||
true,
|
||||
);
|
||||
CtlResponse::Ok {
|
||||
message: format!("invited {proton} Proton and {external} external user(s)"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Share, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.list_share(&rel) {
|
||||
Ok((entries, link)) => CtlResponse::Share { entries, link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdateShareRole {
|
||||
path,
|
||||
id,
|
||||
kind,
|
||||
role,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.update_share_role(&rel, &id, kind, &role) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("role updated to {role}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
|
||||
match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
|
||||
Ok(()) => {
|
||||
core.log_activity(ActivityKind::Unshare, &path, "access removed", true);
|
||||
CtlResponse::Ok {
|
||||
message: "removed".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::CreatePublicLink {
|
||||
path,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.create_public_link(&rel, &role, password.as_deref(), expires) {
|
||||
Ok(link) => {
|
||||
core.log_activity(ActivityKind::PublicLink, &path, "link created", true);
|
||||
CtlResponse::PublicLink { link }
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::PublicLink, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||
Ok(()) => {
|
||||
core.log_activity(ActivityKind::Unshare, &path, "link removed", true);
|
||||
CtlResponse::Ok {
|
||||
message: "public link removed".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
|
||||
Ok(entries) => CtlResponse::Entries { entries },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::LeaveShared { uid }) => match core.leave_shared(&uid) {
|
||||
Ok(()) => {
|
||||
core.log_activity(ActivityKind::Unshare, "shared item", "left", true);
|
||||
CtlResponse::Ok {
|
||||
message: "left shared item".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListInvitations) => match core.list_invitations() {
|
||||
Ok(items) => CtlResponse::Invitations { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AcceptInvitation { id }) => match core.accept_invitation(&id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "invitation accepted".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RejectInvitation { id }) => match core.reject_invitation(&id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "invitation rejected".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListBookmarks) => match core.list_bookmarks() {
|
||||
Ok(items) => CtlResponse::Bookmarks { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::CreateBookmark { url, password }) => {
|
||||
match core.create_bookmark(&url, password.as_deref()) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "bookmark saved".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::DeleteBookmark { token }) => match core.delete_bookmark(&token) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "bookmark removed".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListSharedByMe) => match core.list_shared_by_me() {
|
||||
Ok(items) => CtlResponse::SharedByMe { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
|
||||
items: core.list_activity(limit),
|
||||
},
|
||||
// The request did not parse: the caller sent something malformed.
|
||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||
};
|
||||
let mut out = match serde_json::to_vec(&response) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "control: serialize response failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
out.push(b'\n');
|
||||
let mut stream = stream;
|
||||
let _ = stream.write_all(&out);
|
||||
}
|
||||
|
||||
/// Listen on the control socket, serving one command per connection, each on its
|
||||
/// own thread. Runs on its own thread; returns only if the listener itself fails.
|
||||
///
|
||||
/// Concurrent rather than serial because requests differ wildly in cost: an
|
||||
/// `OpenPhoto` downloads a whole photo, and while it ran the accept loop used to
|
||||
/// stall every other caller behind it — the GUI's 2s status poll, and the
|
||||
/// thumbnail batches the gallery needs to paint. [`Core`] is a bundle of handles
|
||||
/// (`Arc`/`Clone`), so each connection gets its own copy of it.
|
||||
pub(crate) fn run_control_socket(
|
||||
core: Core,
|
||||
username: String,
|
||||
mountpoint: PathBuf,
|
||||
listener: UnixListener,
|
||||
) {
|
||||
info!("control socket listening");
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
let core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.clone();
|
||||
if let Err(e) = std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || handle_control_conn(&core, &username, &mountpoint, stream))
|
||||
{
|
||||
warn!(error = %e, "control: spawn handler failed");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "control: accept failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
||||
//! step with the remote (devices.md).
|
||||
//!
|
||||
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
||||
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
||||
//! tree and mounts a second FUSE session over the same path, rooted at the
|
||||
//! folder`s remote node. Flipping between them is the delicate part — see
|
||||
//! [`Core::apply_sync_folder_mode`].
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use fuser::{Config, MountOption, Session};
|
||||
use parking_lot::Mutex;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::sync::{self, base_name};
|
||||
use super::{
|
||||
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str,
|
||||
dir_is_empty, evict_dir_contents, now_secs, parse_uid, sync_folder_info, this_hostname,
|
||||
};
|
||||
|
||||
impl Core {
|
||||
// ---- devices ----------------------------------------------------------
|
||||
|
||||
/// List the account's registered devices, flagging the one *this* machine
|
||||
/// syncs to so a front-end can treat it as more than another computer in the
|
||||
/// list — deleting it takes this machine's synced folders down with it.
|
||||
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
||||
let devices = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
||||
// No cached device row yet means this machine syncs nothing, so none of
|
||||
// the listed devices is ours.
|
||||
let this_uid = self.db.device_get().ok().flatten().map(|d| d.uid);
|
||||
Ok(devices
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let uid = d.uid.to_string();
|
||||
DeviceInfo {
|
||||
this_device: this_uid.as_deref() == Some(uid.as_str()),
|
||||
uid,
|
||||
name: d.name.unwrap_or_else(|_| "(unnamed device)".to_string()),
|
||||
device_type: device_type_str(d.device_type).to_string(),
|
||||
last_sync: d.last_sync_time,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Rename a device by its uid.
|
||||
pub(crate) fn rename_device(&self, uid: &str, name: &str) -> CoreResult<()> {
|
||||
if name.is_empty() {
|
||||
return Err(CoreError::invalid("device name must not be empty"));
|
||||
}
|
||||
let device_uid = DeviceUid::from(uid);
|
||||
self.rt
|
||||
.block_on(self.client.rename_device(&device_uid, name))
|
||||
.map_err(|e| CoreError::from_api(&e, "rename device"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete (deregister) a device by its uid.
|
||||
pub(crate) fn delete_device(&self, uid: &str) -> CoreResult<()> {
|
||||
let device_uid = DeviceUid::from(uid);
|
||||
self.rt
|
||||
.block_on(self.client.delete_device(&device_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete device"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||
/// Linux device by name before creating a new one, so a lost local record
|
||||
/// doesn't orphan the device's root folder.
|
||||
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
||||
let name = this_hostname();
|
||||
// Enumerate the remote devices once: used both to validate any cached
|
||||
// record and to recover an existing device by name.
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
|
||||
// A cached device is only trustworthy if it still exists remotely. A
|
||||
// device deleted from another client (or the web UI) leaves a stale row
|
||||
// whose root folder is gone, so creating folders under it fails with
|
||||
// "parent node is not a folder". Re-register in that case.
|
||||
if let Some(dev) = self
|
||||
.db
|
||||
.device_get()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
{
|
||||
if remote.iter().any(|d| d.uid.to_string() == dev.uid) {
|
||||
return Ok(dev);
|
||||
}
|
||||
warn!(uid = %dev.uid, "cached device is gone remotely; re-registering");
|
||||
}
|
||||
|
||||
// Recover: an existing remote Linux device with the same name is ours.
|
||||
let existing = remote.into_iter().find(|d| {
|
||||
d.device_type == DeviceType::Linux && d.name.as_deref().ok() == Some(name.as_str())
|
||||
});
|
||||
let dev = match existing {
|
||||
Some(d) => StoredDevice {
|
||||
uid: d.uid.to_string(),
|
||||
share_id: d.share_id.to_string(),
|
||||
root_uid: d.root_folder_uid.to_string(),
|
||||
name,
|
||||
created: d.creation_time,
|
||||
},
|
||||
None => {
|
||||
let d = self
|
||||
.rt
|
||||
.block_on(self.client.create_device(&name, DeviceType::Linux))
|
||||
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
||||
StoredDevice {
|
||||
uid: d.uid.to_string(),
|
||||
share_id: d.share_id.to_string(),
|
||||
root_uid: d.root_folder_uid.to_string(),
|
||||
name,
|
||||
created: d.creation_time,
|
||||
}
|
||||
}
|
||||
};
|
||||
self.db
|
||||
.device_set(&dev)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
Ok(dev)
|
||||
}
|
||||
|
||||
/// An untrashed folder named `name` directly under the device root, if one
|
||||
/// already exists.
|
||||
pub(crate) fn find_device_child_folder(
|
||||
&self,
|
||||
root_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// Add a local folder to this device's sync set: register the device if
|
||||
/// needed, create a matching folder under the device root, upload the local
|
||||
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
|
||||
/// the two-way engine (Phase 2) reconciles later changes.
|
||||
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
|
||||
let meta = std::fs::metadata(local)
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
|
||||
if !meta.is_dir() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is not a directory",
|
||||
local.display()
|
||||
)));
|
||||
}
|
||||
let local = local
|
||||
.canonicalize()
|
||||
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
|
||||
let local_str = local.to_string_lossy().to_string();
|
||||
|
||||
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
|
||||
if self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.iter()
|
||||
.any(|f| f.local_path == local_str)
|
||||
{
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is already synced",
|
||||
local.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let name = local
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| {
|
||||
CoreError::invalid(format!("unusable folder name: {}", local.display()))
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let device = self.ensure_device()?;
|
||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
|
||||
// The synced folder's remote root: the folder under the device root named
|
||||
// after the local basename. Reuse an existing one rather than creating a
|
||||
// second folder with the same name — re-adding a folder (after a removal, or
|
||||
// after a failed add that had already created it) must land back on the
|
||||
// original, not leave the user with two "Downloads" in their Drive. The
|
||||
// reconcile treats an existing remote tree correctly: unmatched paths read as
|
||||
// a conflict, not as data loss.
|
||||
let remote_root = match self.find_device_child_folder(&root_uid, &name)? {
|
||||
Some(uid) => {
|
||||
info!(name, "reusing existing device folder");
|
||||
uid
|
||||
}
|
||||
None => self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_folder(&root_uid, &name, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?,
|
||||
};
|
||||
|
||||
let id = self
|
||||
.db
|
||||
.sync_folder_add(&local_str, &remote_root.to_string(), &device.share_id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
|
||||
// Hand the initial upload to the sync engine: an empty baseline against a
|
||||
// full local tree reconciles as "upload everything", and the folder is
|
||||
// added to the filesystem watch set in the same pass.
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
|
||||
info!(local = %local.display(), id, "added sync folder");
|
||||
self.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found("sync folder vanished after insert"))
|
||||
}
|
||||
|
||||
/// List this device's synced folders for the front-ends, each carrying the
|
||||
/// live progress of its pass when one is running.
|
||||
pub(crate) fn list_sync_folders(&self) -> CoreResult<Vec<SyncFolderInfo>> {
|
||||
let progress = self.sync_progress.lock();
|
||||
Ok(self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let live = progress.get(&f.id).cloned();
|
||||
sync_folder_info(f, live)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Everything the daemon is chewing on that isn't moving bytes, for
|
||||
/// `GetQueueStatus`: the registered jobs (bulk-upload scans, the local index)
|
||||
/// plus a synthesized job per running sync pass, so one Activity view answers
|
||||
/// "is anything still happening?" without also polling `ListSyncFolders`.
|
||||
///
|
||||
/// The sync passes are folded in here rather than tracked as registry jobs
|
||||
/// because the Devices page needs them per folder anyway
|
||||
/// ([`SyncFolderInfo::progress`]) — this keeps one source of truth and hits
|
||||
/// the db only while a pass is actually running.
|
||||
pub(crate) fn jobs_snapshot(&self) -> Vec<JobItem> {
|
||||
let mut jobs = self.transfers.jobs_snapshot();
|
||||
let mut passes: Vec<(i64, SyncProgress)> = self
|
||||
.sync_progress
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|(id, p)| (*id, p.clone()))
|
||||
.collect();
|
||||
if passes.is_empty() {
|
||||
return jobs;
|
||||
}
|
||||
passes.sort_by_key(|(id, _)| *id);
|
||||
|
||||
let names: HashMap<i64, String> = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|f| (f.id, f.local_path))
|
||||
.collect();
|
||||
for (id, p) in passes {
|
||||
// The row is titled with the folder's own name; the full local path
|
||||
// is what the Devices page shows, and is far too long for this line.
|
||||
let folder = names
|
||||
.get(&id)
|
||||
.and_then(|path| Path::new(path).file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "folder".to_string());
|
||||
jobs.push(match p.phase {
|
||||
// The scan's total is the last pass's baseline, so a folder that
|
||||
// has never synced still reports indeterminate (`total: 0`) — but
|
||||
// every later pass has a real bar. A grown folder can push `done`
|
||||
// past the estimate; clamp so the row never reads "600 of 500".
|
||||
SyncPhase::Scanning => JobItem {
|
||||
title: format!("Checking {folder}"),
|
||||
detail: "Looking for changes".to_string(),
|
||||
done: p.done as u64,
|
||||
total: if p.total == 0 {
|
||||
0
|
||||
} else {
|
||||
p.total.max(p.done) as u64
|
||||
},
|
||||
},
|
||||
SyncPhase::Applying => JobItem {
|
||||
title: format!("Syncing {folder}"),
|
||||
detail: p.current.clone(),
|
||||
done: p.done as u64,
|
||||
total: p.total.max(p.done) as u64,
|
||||
},
|
||||
});
|
||||
}
|
||||
jobs
|
||||
}
|
||||
|
||||
/// The lock guarding sync-folder `id` against concurrent reconcile/mode-switch.
|
||||
pub(crate) fn sync_lock(&self, id: i64) -> Arc<Mutex<()>> {
|
||||
self.sync_locks.lock().entry(id).or_default().clone()
|
||||
}
|
||||
|
||||
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
|
||||
/// folder under the device root; otherwise the cloud copy is left in place.
|
||||
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
|
||||
// only the row would strand the mount: the path would keep serving a
|
||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||
// Tear it down first — including before trashing the remote tree it
|
||||
// serves, which would otherwise leave it answering for deleted nodes.
|
||||
if let Some(session) = self.mounts.lock().remove(&id) {
|
||||
if let Err(e) = session.umount_and_join() {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
} else {
|
||||
info!(id, path = %folder.local_path, "unmounted on-demand folder");
|
||||
}
|
||||
}
|
||||
if delete_remote
|
||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
|
||||
{
|
||||
warn!(id, error = %e, "delete remote device folder failed");
|
||||
}
|
||||
if !self
|
||||
.db
|
||||
.sync_folder_remove(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
{
|
||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||
}
|
||||
// Stop watching the folder we just dropped.
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trigger a reconcile: one folder by id, or every folder when `id` is `None`.
|
||||
pub(crate) fn sync_now(&self, id: Option<i64>) {
|
||||
let _ = match id {
|
||||
Some(id) => self.sync_tx.send(sync::SyncMsg::Reconcile(id)),
|
||||
None => self.sync_tx.send(sync::SyncMsg::ReconcileAll),
|
||||
};
|
||||
}
|
||||
|
||||
/// A sibling Core that shares this one's client/rt/cache/db (and transfer,
|
||||
/// activity, mount registries) but gets a **fresh, empty `State`** — its own
|
||||
/// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session
|
||||
/// at an `ondemand` sync folder without colliding with the main mount's inodes
|
||||
/// (devices.md Phase 3).
|
||||
pub(crate) fn fork_state(&self) -> Core {
|
||||
let mut fork = self.clone();
|
||||
fork.state = Arc::new(Mutex::new(State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino: 2,
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
db: self.db.clone(),
|
||||
}));
|
||||
fork
|
||||
}
|
||||
|
||||
/// Ask for a synced folder to move to `mode`, applying it now if the folder is
|
||||
/// free and safe to switch, and **queueing** it otherwise. Returns the human
|
||||
/// message for the reply.
|
||||
///
|
||||
/// Queueing rather than rejecting is what makes the toggle usable: a folder
|
||||
/// that syncs continuously (a busy Downloads folder) is almost never caught in
|
||||
/// the narrow window where it is both unlocked and `idle`, so a `try_lock`
|
||||
/// rejection asks the user to keep retrying until they get lucky. Instead the
|
||||
/// intent is recorded and the engine applies it at the end of the pass already
|
||||
/// running — which, seeing a queued `ondemand`, also stops pulling down files
|
||||
/// it is about to evict ([`Core::push_pass`]).
|
||||
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
|
||||
if mode != "mirror" && mode != "ondemand" {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"unknown mode {mode:?} (want mirror|ondemand)"
|
||||
)));
|
||||
}
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode == mode {
|
||||
// Already there. A queued switch the other way is the user changing
|
||||
// their mind back before it landed, so withdraw it.
|
||||
if folder.pending_mode.is_some() {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, None)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
return Ok(format!("staying {mode}"));
|
||||
}
|
||||
return Ok(format!("already {mode}"));
|
||||
}
|
||||
|
||||
match self.apply_sync_folder_mode(id, mode) {
|
||||
Ok(message) => Ok(message),
|
||||
// Not switchable this instant — remember the intent instead of making
|
||||
// the user retry, and kick a pass to clear whatever is in the way.
|
||||
Err(SwitchBlocked::NotNow) => {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, Some(mode))
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
Ok(match mode {
|
||||
"ondemand" => format!(
|
||||
"{} will go on-demand once its local changes are uploaded",
|
||||
base_name(&folder.local_path)
|
||||
),
|
||||
_ => format!(
|
||||
"{} will start mirroring once the current sync finishes",
|
||||
base_name(&folder.local_path)
|
||||
),
|
||||
})
|
||||
}
|
||||
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a queued mode switch if the folder has one and is now able to take it.
|
||||
/// Called by the sync engine after every pass, so a switch the user asked for
|
||||
/// mid-sync lands as soon as the pass that blocked it is done. A folder that is
|
||||
/// still not ready (its push failed, so the local copy is not safe to evict)
|
||||
/// keeps its `pending_mode` and is retried after the next pass.
|
||||
pub(crate) fn settle_pending_mode(&self, id: i64) {
|
||||
let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
|
||||
return;
|
||||
};
|
||||
let Some(mode) = folder.pending_mode.as_deref() else {
|
||||
return;
|
||||
};
|
||||
if folder.mode == mode {
|
||||
let _ = self.db.sync_folder_set_pending_mode(id, None);
|
||||
return;
|
||||
}
|
||||
match self.apply_sync_folder_mode(id, mode) {
|
||||
Ok(message) => {
|
||||
info!(id, mode, "applied queued mode switch");
|
||||
self.log_activity(ActivityKind::Sync, &message, "", true);
|
||||
}
|
||||
// Still blocked: the pass could not get everything up, so the local copy
|
||||
// is not safe to evict yet. Leave the request standing — the next pass
|
||||
// (poll, or the retry the engine schedules) tries again.
|
||||
Err(SwitchBlocked::NotNow) => {
|
||||
info!(id, mode, "queued mode switch still waiting");
|
||||
}
|
||||
Err(SwitchBlocked::Failed(e)) => {
|
||||
warn!(id, mode, error = %e, "queued mode switch failed; withdrawing");
|
||||
let _ = self.db.sync_folder_set_pending_mode(id, None);
|
||||
self.log_activity(
|
||||
ActivityKind::Sync,
|
||||
format!("couldn't switch {}", base_name(&folder.local_path)),
|
||||
e,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip a synced folder between `mirror` (full local copy + two-way sync) and
|
||||
/// `ondemand` (a FUSE mount over the local path; no local storage). Returns a
|
||||
/// human message on success.
|
||||
///
|
||||
/// - **mirror→ondemand**: require the folder in-sync (`idle`), stop watching it,
|
||||
/// evict the local files to reclaim disk, then mount a secondary `ProtonFs`
|
||||
/// rooted at the folder's remote node over its local path.
|
||||
/// - **ondemand→mirror**: unmount, clear the stale baseline (the local tree was
|
||||
/// evicted), then hand the folder back to the engine, which re-downloads it.
|
||||
///
|
||||
/// [`SwitchBlocked::NotNow`] means "not yet, try after a pass" and is never an
|
||||
/// error the user needs to see — callers queue on it.
|
||||
pub(crate) fn apply_sync_folder_mode(
|
||||
&self,
|
||||
id: i64,
|
||||
mode: &str,
|
||||
) -> Result<String, SwitchBlocked> {
|
||||
// Hold the folder's lock for the whole switch so no reconcile pass can be
|
||||
// running over the tree we are about to evict (or start while we mount over
|
||||
// it). A pass in flight holds the lock for its full duration, so `try_lock`
|
||||
// failing is exactly "still syncing" — and it is the only reliable signal:
|
||||
// the `state` column is still `idle` in the window between `add_sync_folder`
|
||||
// inserting the row and the engine picking it up.
|
||||
let lock = self.sync_lock(id);
|
||||
let Some(_guard) = lock.try_lock() else {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
};
|
||||
// Re-read under the lock: a pass that finished while we waited may have
|
||||
// changed `state`.
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode == mode {
|
||||
return Ok(format!("already {mode}"));
|
||||
}
|
||||
let local = PathBuf::from(&folder.local_path);
|
||||
|
||||
match mode {
|
||||
"ondemand" => {
|
||||
// Only flip a folder that is fully in sync — a failed reconcile means
|
||||
// local edits could still be un-uploaded, and we are about to delete
|
||||
// the local copy. Not an error: a pass makes this true, and the queued
|
||||
// request is applied once one does.
|
||||
if folder.state != "idle" {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
}
|
||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||
})?;
|
||||
let root = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("fetch remote root: {e}")))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| SwitchBlocked::Failed("remote folder not found".to_string()))?;
|
||||
|
||||
// Persist the mode *before* touching the local tree, and never roll it
|
||||
// back on failure.
|
||||
//
|
||||
// The eviction below deletes every local file in the folder. While the
|
||||
// row still says `mirror`, that empty directory is a valid input to the
|
||||
// sync engine, which reads it as "the user deleted all of this" and
|
||||
// trashes the whole folder on Drive. So the window between the evict and
|
||||
// the mode write is a window in which any failure — a busy mountpoint, a
|
||||
// missing fuse module, a dead DB — destroys the folder. Writing the mode
|
||||
// first closes it: the engine skips non-mirror folders, so the worst a
|
||||
// failure can now leave behind is an `ondemand` row with no mount, which
|
||||
// is inert. Inert and marked `error` is recoverable; trashed is not.
|
||||
//
|
||||
// The same reasoning forbids reverting to `mirror` when a step below
|
||||
// fails, since eviction may already have deleted part of the tree.
|
||||
// Recovery is the user's explicit switch back to `mirror`, whose arm
|
||||
// clears the baseline and re-downloads.
|
||||
self.db
|
||||
.sync_folder_set_mode(id, "ondemand")
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
|
||||
// Reclaim the disk: empty the local dir (keep it as the mountpoint).
|
||||
if let Err(e) = evict_dir_contents(&local) {
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"evict {}: {e}",
|
||||
local.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let session = match self.spawn_ondemand_mount(&local, root) {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(SwitchBlocked::Failed(e.to_string()));
|
||||
}
|
||||
};
|
||||
self.mounts.lock().insert(id, session);
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
|
||||
info!(id, path = %local.display(), "mounted sync folder on-demand");
|
||||
Ok(format!("{} is now on-demand", local.display()))
|
||||
}
|
||||
_ => {
|
||||
// ondemand→mirror: tear down the secondary mount first.
|
||||
if let Some(session) = self.mounts.lock().remove(&id)
|
||||
&& let Err(e) = session.umount_and_join()
|
||||
{
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
}
|
||||
// The evicted local tree makes the old baseline lie ("everything
|
||||
// deleted locally"); clear it so the reconcile is a pure download.
|
||||
self.db
|
||||
.sync_entries_clear(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
self.db
|
||||
.sync_folder_set_mode(id, "mirror")
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
info!(id, path = %local.display(), "restored sync folder to mirror");
|
||||
Ok(format!(
|
||||
"{} is mirroring again; downloading",
|
||||
local.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked
|
||||
/// inode space. Clears any stale kernel mount first (a crashed run can leave
|
||||
/// one, which would fail the fresh mount with EBUSY).
|
||||
pub(crate) fn spawn_ondemand_mount(
|
||||
&self,
|
||||
local: &Path,
|
||||
root: Node,
|
||||
) -> CoreResult<BackgroundSession> {
|
||||
clear_stale_mount(local);
|
||||
let mut config = Config::default();
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
MountOption::DefaultPermissions,
|
||||
];
|
||||
let fs = ProtonFs::new(self.fork_state(), root);
|
||||
Session::new(fs, local, &config)
|
||||
.and_then(|s| s.spawn())
|
||||
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))
|
||||
}
|
||||
|
||||
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
|
||||
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
||||
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
||||
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
||||
/// own thread from `mount` so the network fetches never block startup
|
||||
/// (devices.md Phase 4).
|
||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||
let folders = match self.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "restore on-demand: cannot list folders");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for folder in folders {
|
||||
if folder.mode != "ondemand" {
|
||||
continue;
|
||||
}
|
||||
let local = PathBuf::from(&folder.local_path);
|
||||
if !local.is_dir() {
|
||||
warn!(id = folder.id, path = %local.display(), "restore on-demand: local path missing");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
}
|
||||
// An `ondemand` folder's local dir is empty by construction — the switch
|
||||
// evicts it. Finding files there means the row is lying (a switch that
|
||||
// died between persisting the mode and evicting), and mounting over them
|
||||
// would hide real local data behind the remote tree. Leave the files
|
||||
// alone and let the user resolve it.
|
||||
match dir_is_empty(&local) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
id = folder.id,
|
||||
path = %local.display(),
|
||||
"restore on-demand: local dir is not empty; refusing to mount over it"
|
||||
);
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, path = %local.display(), error = %e, "restore on-demand: cannot read local dir");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
||||
warn!(id = folder.id, "restore on-demand: bad remote uid");
|
||||
continue;
|
||||
};
|
||||
let root = match self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||
{
|
||||
Ok(v) => match v.into_iter().next() {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
warn!(id = folder.id, "restore on-demand: remote folder gone");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self.spawn_ondemand_mount(&local, root) {
|
||||
Ok(session) => {
|
||||
self.mounts.lock().insert(folder.id, session);
|
||||
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
|
||||
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
//! The write-back drain: turning queued mutations into remote calls.
|
||||
//!
|
||||
//! Every write the kernel hands us is answered the moment its bytes and its
|
||||
//! `pending_op` row are on disk (offline.md Phase 3), which is what lets a `cp`
|
||||
//! into the mount run at disk speed and lets an offline write succeed at all.
|
||||
//! This module is the other half: the worker that walks that queue and performs
|
||||
//! the uploads, creates, renames and trashes it recorded.
|
||||
//!
|
||||
//! Two invariants matter more than anything else here, because between them
|
||||
//! they are the only thing standing between a queued write and lost data:
|
||||
//!
|
||||
//! 1. A staged blob is the *only* copy of the user`s bytes. It is dropped only
|
||||
//! after the op it belongs to has provably landed — never before, and never
|
||||
//! on a failure path that might be retried.
|
||||
//! 2. A failure never pauses the queue. Recording it pushes that op`s
|
||||
//! `next_attempt_at` past now, so one file wedged against a vanished parent
|
||||
//! cannot hold up an unrelated upload behind it.
|
||||
//!
|
||||
//! When the remote has moved on underneath a queued revision, the losing bytes
|
||||
//! are kept as a conflict copy rather than discarded — see
|
||||
//! [`Core::revision_conflict`].
|
||||
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
|
||||
use proton_drive_rs::Node;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::state::Intervals;
|
||||
use super::transfers::CountingReader;
|
||||
use super::{
|
||||
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, ROOT_INO, conflict_name,
|
||||
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_size, now_millis, now_secs,
|
||||
parse_node_uid,
|
||||
};
|
||||
|
||||
impl Core {
|
||||
/// Drain the pending-op queue: the background half of every write
|
||||
/// (offline.md Phase 3).
|
||||
///
|
||||
/// Runs for the life of the mount. Ops are replayed oldest-first, each
|
||||
/// retried with doubling backoff and *never* dropped on failure — the staged
|
||||
/// blob is the only copy of the user's bytes, so a failed op stays queued
|
||||
/// until it lands or the user deletes the file.
|
||||
///
|
||||
/// A failure does not pause the queue. Recording it pushes that op's
|
||||
/// `next_attempt_at` past `now`, so the next pass simply picks the next op
|
||||
/// that is due — one file wedged against a folder that no longer exists must
|
||||
/// not hold up an unrelated upload behind it. The worker only blocks once
|
||||
/// nothing is due at all.
|
||||
pub(crate) fn run_pending_drain(&self) {
|
||||
loop {
|
||||
let now = now_millis();
|
||||
// One row, chosen by the database. Reading the whole queue to pick
|
||||
// one op made a long queue quadratic to drain, and held the shared
|
||||
// connection — and so every FUSE metadata call — for the duration.
|
||||
let due = self.db.next_due_op(now).unwrap_or_default();
|
||||
|
||||
let Some(op) = due.filter(|_| self.online.load(Ordering::Relaxed)) else {
|
||||
self.wait_for_drain_work();
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.drain_op(&op) {
|
||||
let attempts = op.attempts + 1;
|
||||
let backoff = DRAIN_BACKOFF_MIN
|
||||
.saturating_mul(1u32 << attempts.min(6))
|
||||
.min(DRAIN_BACKOFF_MAX);
|
||||
warn!(uid = %op.uid, attempts, error = %e, "pending upload failed; will retry");
|
||||
if let Err(e) = self.db.record_op_failure(
|
||||
op.id,
|
||||
&e.to_string(),
|
||||
now_millis() + backoff.as_millis() as i64,
|
||||
) {
|
||||
// The backoff is the only thing keeping a failing op from
|
||||
// being picked again immediately, so without it the loop
|
||||
// would spin on this op as fast as the API can refuse it.
|
||||
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
|
||||
self.wait_for_drain_work();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until there is plausibly something to do: a new op, a reconnect, or
|
||||
/// the shortest outstanding backoff elapsing.
|
||||
pub(crate) fn wait_for_drain_work(&self) {
|
||||
let (lock, cv) = &*self.drain_wake;
|
||||
let mut woken = lock.lock();
|
||||
if !*woken {
|
||||
cv.wait_for(&mut woken, DRAIN_IDLE_POLL);
|
||||
}
|
||||
*woken = false;
|
||||
}
|
||||
|
||||
/// Perform one queued op and retire it.
|
||||
pub(crate) fn drain_op(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match op.kind.as_str() {
|
||||
OP_REVISION => self.drain_revision(op),
|
||||
OP_CREATE | OP_MKDIR => self.drain_local_node(op),
|
||||
OP_RENAME => self.drain_rename(op),
|
||||
OP_TRASH => self.drain_trash(op),
|
||||
other => Err(format!("unknown pending op kind {other:?}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a queued rename/move to the remote.
|
||||
///
|
||||
/// The op is the desired end state, so the remote's current state decides
|
||||
/// what actually has to be called: either half may already match (the event
|
||||
/// sync saw someone else do it, or an earlier attempt got half way through
|
||||
/// before failing). That also makes the whole thing idempotent, which a
|
||||
/// retrying queue needs.
|
||||
pub(crate) fn drain_rename(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let uid = parse_node_uid(&op.uid).ok_or("rename op has an unparseable uid")?;
|
||||
let parent_str = op.parent_uid.as_deref().ok_or("rename op has no parent")?;
|
||||
if is_local_uid_str(parent_str) {
|
||||
return Err(format!("destination {parent_str} has not been created yet").into());
|
||||
}
|
||||
let parent = parse_node_uid(parent_str).ok_or("rename op has an unparseable parent")?;
|
||||
let name = op.name.clone().ok_or("rename op has no name")?;
|
||||
|
||||
// The node we were asked to rename may be gone or trashed by now. Either
|
||||
// way there is nothing to rename and nothing to lose — a rename holds no
|
||||
// bytes — so the op is satisfied rather than retried forever.
|
||||
let node = match self.fetch_node_remote(&uid)? {
|
||||
Some(n) if !n.trashed => n,
|
||||
_ => {
|
||||
warn!(%uid, name, "renamed node is gone or trashed remotely; dropping the rename");
|
||||
self.db.delete_op(op.id)?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// The name half goes first, so that a collision on the move half below is
|
||||
// about the name the node will actually land under rather than the one it
|
||||
// is about to lose.
|
||||
let mut landed = node.name.clone();
|
||||
if landed != name {
|
||||
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
|
||||
Ok(()) => landed = name.clone(),
|
||||
// Someone took the name while we were offline. Renaming to a
|
||||
// *different* name is the non-destructive resolution: it neither
|
||||
// clobbers their file nor drops ours, and it is visible.
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
let alt = conflict_name(&name, now_secs());
|
||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
||||
landed = alt.clone();
|
||||
self.adopt_drained_name(&uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&name,
|
||||
format!("name was taken remotely; renamed to {alt}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
if node.parent_uid.as_ref() != Some(&parent) {
|
||||
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
|
||||
Ok(()) => {}
|
||||
// The destination holds a `landed` of its own. Same resolution as
|
||||
// a name collision, and it has to happen before the move: the API
|
||||
// has no move-and-rename, so the node is renamed out of the way
|
||||
// here and moved second.
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
let alt = conflict_name(&landed, now_secs());
|
||||
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
||||
self.rt.block_on(self.client.move_node(&uid, &parent))?;
|
||||
self.adopt_drained_name(&uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&landed,
|
||||
format!("destination already had that name; moved as {alt}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
// The destination folder is gone. Leaving the node in its current
|
||||
// parent is the honest outcome: it is not where the user asked for
|
||||
// it, but it exists, it is where it has always been, and the
|
||||
// rename half above still applied. Retrying could only fail again
|
||||
// — the folder is not coming back — and would wedge the queue.
|
||||
Err(e) if is_gone(&e) => {
|
||||
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is");
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&landed,
|
||||
"destination folder no longer exists; the file was left in place"
|
||||
.to_string(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
self.db.delete_op(op.id)?;
|
||||
info!(%uid, name = %landed, "pending rename landed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a queued trash to the remote.
|
||||
///
|
||||
/// A node that is already gone is a success, not a failure: the outcome the
|
||||
/// op asked for holds either way, and retrying forever against a node the
|
||||
/// server has forgotten would wedge the queue.
|
||||
pub(crate) fn drain_trash(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let uid = parse_node_uid(&op.uid).ok_or("trash op has an unparseable uid")?;
|
||||
let name = op.name.clone().unwrap_or_else(|| op.uid.clone());
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if is_gone(&e) => {
|
||||
debug!(%uid, name, "node was already gone remotely; trash op satisfied");
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
self.db.delete_op(op.id)?;
|
||||
self.invalidate_trash();
|
||||
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
||||
info!(%uid, name, "pending trash landed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record the name the remote actually gave a node, after a conflict forced
|
||||
/// it away from the one the user asked for. Best effort: the event sync
|
||||
/// would correct the tree anyway, but not before the user has looked at it.
|
||||
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
|
||||
let mut st = self.state.lock();
|
||||
let Some(&ino) = st.by_uid.get(uid) else {
|
||||
return;
|
||||
};
|
||||
let Some(entry) = st.entries.get_mut(&ino) else {
|
||||
return;
|
||||
};
|
||||
entry.node.name = name.to_string();
|
||||
let node = entry.node.clone();
|
||||
if let Err(e) = st.db.upsert_node(&node) {
|
||||
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a node that so far exists only on this machine real, and adopt the
|
||||
/// uid the server gives it (offline.md Phase 3b).
|
||||
pub(crate) fn drain_local_node(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let local = parse_node_uid(&op.uid).ok_or("pending op has an unparseable uid")?;
|
||||
let parent_str = op.parent_uid.as_deref().ok_or("create op has no parent")?;
|
||||
// `run_pending_drain` will not offer an op whose parent is still a
|
||||
// placeholder, so reaching here with one is a bug, not a wait.
|
||||
if is_local_uid_str(parent_str) {
|
||||
return Err(format!("parent {parent_str} has not been created yet").into());
|
||||
}
|
||||
let parent = parse_node_uid(parent_str).ok_or("create op has an unparseable parent")?;
|
||||
let wanted = op.name.clone().ok_or("create op has no name")?;
|
||||
|
||||
// Someone else may have taken the name while this sat in the queue —
|
||||
// reachable only because the op waited, which is the whole point of the
|
||||
// queue. Never overwrite theirs and never drop ours: land under a
|
||||
// conflict name, exactly as the sync engine does.
|
||||
let mut name = wanted.clone();
|
||||
let mut real = self.create_drained_node(op, &parent, &name);
|
||||
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref())) {
|
||||
name = conflict_name(&wanted, now_secs());
|
||||
warn!(%local, wanted, name, "name is taken remotely; creating under a conflict name");
|
||||
real = self.create_drained_node(op, &parent, &name);
|
||||
if real.is_ok() {
|
||||
self.log_activity(
|
||||
ActivityKind::Upload,
|
||||
&wanted,
|
||||
format!("name was taken remotely; created as {name}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The folder this node was created in may have been trashed remotely
|
||||
// while the op waited, in which case nothing will ever make the op
|
||||
// succeed as written and retrying it forever wedges the queue behind a
|
||||
// file that has bytes to save. Re-home it to the root: not where the
|
||||
// user put it, but it exists, it is visible, and the bytes are intact.
|
||||
if let Some(root) = self.root_uid()
|
||||
&& real.is_err()
|
||||
&& self.parent_is_gone(&parent)
|
||||
{
|
||||
warn!(%local, name, "parent folder is gone remotely; creating in the root instead");
|
||||
real = self.create_drained_node(op, &root, &name);
|
||||
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref())) {
|
||||
name = conflict_name(&wanted, now_secs());
|
||||
real = self.create_drained_node(op, &root, &name);
|
||||
}
|
||||
if real.is_ok() {
|
||||
self.log_activity(
|
||||
ActivityKind::Upload,
|
||||
&wanted,
|
||||
format!("its folder was trashed remotely; created in the root as {name}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let real = real?;
|
||||
|
||||
// Retire the op before touching anything else: if we crash here the node
|
||||
// exists remotely and the local placeholder is reconciled by the event
|
||||
// sync, whereas a surviving op would create the file a second time.
|
||||
self.db.delete_op(op.id)?;
|
||||
self.adopt_real_uid(&local, &real)?;
|
||||
if let Some(blob) = op.blob_path.as_deref() {
|
||||
self.cache.discard_staged(Path::new(blob));
|
||||
}
|
||||
self.pending.lock().remove(&local);
|
||||
self.log_activity(ActivityKind::Upload, &name, "created", true);
|
||||
info!(%local, %real, name, kind = %op.kind, "pending create landed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make one queued `create`/`mkdir` real under a given name, and hand back
|
||||
/// the API's own error so the caller can tell a name clash from a failure.
|
||||
///
|
||||
/// Split out because the conflict path has to run it twice: the second time
|
||||
/// under a different name.
|
||||
pub(crate) fn create_drained_node(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
parent: &NodeUid,
|
||||
name: &str,
|
||||
) -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||
match op.kind == OP_MKDIR {
|
||||
true => {
|
||||
Ok(self
|
||||
.rt
|
||||
.block_on(self.client.create_folder(parent, name, Some(now_secs())))?)
|
||||
}
|
||||
false => self.upload_created_file(op, parent, name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the bytes a queued create accumulated, if any. A file that was
|
||||
/// created but never written (`touch`) has no blob and uploads as empty.
|
||||
pub(crate) fn upload_created_file(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
parent: &NodeUid,
|
||||
name: &str,
|
||||
) -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||
let Some(blob) = op.blob_path.as_deref() else {
|
||||
return Ok(self.rt.block_on(self.client.upload_file(
|
||||
parent,
|
||||
name,
|
||||
media_type_for(name),
|
||||
b"",
|
||||
))?);
|
||||
};
|
||||
let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
|
||||
// An incomplete blob would be authored bytes over zeros, and there is no
|
||||
// base to repair it from — the file has never existed remotely. Refusing
|
||||
// to queue that is `queue_revision`'s job, so reaching here means the blob
|
||||
// is whole.
|
||||
if !meta.complete {
|
||||
return Err("queued create holds an incomplete blob".into());
|
||||
}
|
||||
let guard = self
|
||||
.transfers
|
||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||
let uid = self.rt.block_on(self.client.upload_file_from(
|
||||
parent,
|
||||
name,
|
||||
media_type_for(name),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Swap a placeholder uid for the real one across everything that keyed off
|
||||
/// it: queued children, the DB, the in-memory tree, and the caches.
|
||||
///
|
||||
/// The inode is deliberately kept, so anything already holding the file open
|
||||
/// keeps working across the drain.
|
||||
pub(crate) fn adopt_real_uid(
|
||||
&self,
|
||||
local: &NodeUid,
|
||||
real: &NodeUid,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let node = self
|
||||
.fetch_node(real)
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
// Repoints queued children and node rows, and drops the placeholder row.
|
||||
self.db
|
||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||
|
||||
let mut st = self.state.lock();
|
||||
if let Some(ino) = st.by_uid.remove(local) {
|
||||
st.by_uid.insert(real.clone(), ino);
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.uid = real.clone();
|
||||
e.node = node.clone();
|
||||
}
|
||||
// Where the op said the node goes and where it actually landed can
|
||||
// differ — a conflict re-homes it — so the tree follows the parent
|
||||
// the server reports rather than the one we asked for.
|
||||
if let Some(parent) = node.parent_uid.clone()
|
||||
&& let Some(&pino) = st.by_uid.get(&parent)
|
||||
&& st.entries.get(&ino).is_some_and(|e| e.parent != pino)
|
||||
{
|
||||
let old = st.entries.get(&ino).map(|e| e.parent);
|
||||
if let Some(old) = old
|
||||
&& let Some(kids) = st.children.get_mut(&old)
|
||||
{
|
||||
kids.retain(|&k| k != ino);
|
||||
}
|
||||
if let Some(e) = st.entries.get_mut(&ino) {
|
||||
e.parent = pino;
|
||||
}
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
&& !kids.contains(&ino)
|
||||
{
|
||||
kids.push(ino);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(st);
|
||||
// Write the real node through, now that nothing points at the old uid.
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||
}
|
||||
if node.is_folder() {
|
||||
// It was recorded as listed while local (it was empty and had nothing
|
||||
// to enumerate). That still holds: its queued children re-intern under
|
||||
// the real uid as they drain.
|
||||
if let Err(e) = self.db.set_listed(real, true) {
|
||||
warn!(%real, error = %e, "db set_listed(true) failed after remap");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why a queued write must not be applied to its node, or `None` when it
|
||||
/// still can be.
|
||||
///
|
||||
/// A queued write is an edit of a specific revision. Time passes before it
|
||||
/// drains — indefinitely, if that is how long the network is gone — and in
|
||||
/// that window the node can be rewritten by another device, trashed, or
|
||||
/// deleted outright. Sending the blob anyway would silently drop whatever
|
||||
/// happened in between, which is exactly the thing the sync engine refuses
|
||||
/// to do (offline.md Phase 3b).
|
||||
///
|
||||
/// Only checkable against a recorded baseline: a write staged before
|
||||
/// [`StagedWrite::based_on`] existed, or one against a node that has never
|
||||
/// existed remotely, has nothing to compare and is applied as before.
|
||||
pub(crate) fn revision_conflict(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
meta: &StagedWrite,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let Some(base) = meta.based_on else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(node) = self.fetch_node_remote(uid)? else {
|
||||
return Ok(Some("the file no longer exists remotely".into()));
|
||||
};
|
||||
if node.trashed {
|
||||
return Ok(Some("the file was trashed remotely".into()));
|
||||
}
|
||||
let (mtime, size) = (node.modification_time, node_size(&node));
|
||||
if mtime != base.mtime || size != base.size {
|
||||
return Ok(Some(format!(
|
||||
"the remote revision changed under the queued write \
|
||||
(expected {} bytes at mtime {}, found {size} at {mtime})",
|
||||
base.size, base.mtime
|
||||
)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Land a queued write that can no longer be applied to its own node as a
|
||||
/// *new* file beside it, and retire the op.
|
||||
///
|
||||
/// The non-destructive resolution, and the same one the sync engine reaches
|
||||
/// for: the remote keeps whatever it has, the user keeps their bytes, and
|
||||
/// the name says which is which.
|
||||
///
|
||||
/// An incomplete blob is gap-filled from whatever the node holds *now* —
|
||||
/// mixing revisions, which is only defensible because the result is
|
||||
/// explicitly a conflict copy rather than anyone's file. When even that is
|
||||
/// impossible the op is dropped but the staged bytes are deliberately left
|
||||
/// on disk: unreachable through the mount, but not destroyed, and the
|
||||
/// activity log says where they are.
|
||||
pub(crate) fn keep_as_conflict_copy(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
blob: &Path,
|
||||
meta: &StagedWrite,
|
||||
uid: &NodeUid,
|
||||
reason: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let place = self.node_place(uid);
|
||||
let name = place
|
||||
.as_ref()
|
||||
.map(|(_, n)| n.clone())
|
||||
.unwrap_or_else(|| self.node_name(uid));
|
||||
// A node the tree has forgotten still has bytes worth keeping, so the
|
||||
// copy falls back to the root rather than being abandoned.
|
||||
let Some(parent) = place.map(|(p, _)| p).or_else(|| self.root_uid()) else {
|
||||
return self.abandon_to_staging(op, blob, uid, &name, reason);
|
||||
};
|
||||
warn!(%uid, name, reason, "queued write conflicts; keeping a conflict copy");
|
||||
|
||||
if !meta.complete {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(blob)?;
|
||||
let mut written = Intervals::default();
|
||||
for &(s, e) in &meta.authored {
|
||||
written.add(s, e);
|
||||
}
|
||||
if let Err(e) = self.fill_gaps(
|
||||
uid,
|
||||
&file,
|
||||
meta.len,
|
||||
meta.base_mtime,
|
||||
meta.base_size,
|
||||
&written,
|
||||
) {
|
||||
error!(%uid, name, error = ?e, "cannot complete a conflicted partial write");
|
||||
return self.abandon_to_staging(op, blob, uid, &name, reason);
|
||||
}
|
||||
}
|
||||
|
||||
let alt = conflict_name(&name, now_secs());
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||
self.rt.block_on(self.client.upload_file_from(
|
||||
&parent,
|
||||
&alt,
|
||||
media_type_for(&alt),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
drop(guard);
|
||||
|
||||
self.db.delete_op(op.id)?;
|
||||
// Dropping the pending entry hands the node back to the remote's truth:
|
||||
// reads stop coming from the staged blob, and the event sync stops
|
||||
// skipping it as "ahead of the server" (offline.md Phase 3a).
|
||||
self.pending.lock().remove(uid);
|
||||
self.cache.discard_staged(blob);
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
// The conflict copy is a node the tree has never seen.
|
||||
let mut st = self.state.lock();
|
||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||
st.invalidate_listing(ino);
|
||||
}
|
||||
drop(st);
|
||||
self.log_activity(
|
||||
ActivityKind::Upload,
|
||||
&name,
|
||||
format!("{reason}; local changes uploaded as {alt}"),
|
||||
false,
|
||||
);
|
||||
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give up on placing a queued write anywhere the mount can see, without
|
||||
/// destroying it: the op goes (it could only fail forever) but the staged
|
||||
/// blob deliberately stays on disk, and the activity log says where.
|
||||
///
|
||||
/// The last resort of the conflict path, and the same bargain
|
||||
/// [`Core::stage_orphaned_write`] strikes at the other end: bytes we cannot
|
||||
/// place are still bytes we do not get to delete.
|
||||
pub(crate) fn abandon_to_staging(
|
||||
&self,
|
||||
op: &PendingOp,
|
||||
blob: &Path,
|
||||
uid: &NodeUid,
|
||||
name: &str,
|
||||
reason: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
error!(%uid, name, reason, staged = %blob.display(),
|
||||
"cannot place a conflicted write; bytes kept in staging");
|
||||
self.db.delete_op(op.id)?;
|
||||
self.pending.lock().remove(uid);
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
self.log_activity(
|
||||
ActivityKind::Upload,
|
||||
name,
|
||||
format!("{reason}; local changes kept at {}", blob.display()),
|
||||
false,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a folder a queued op targets has stopped being a place a node can
|
||||
/// go. A failure to ask is not an answer: only a definite "trashed" or "not
|
||||
/// there" counts, so a network fault leaves the op to retry normally.
|
||||
pub(crate) fn parent_is_gone(&self, parent: &NodeUid) -> bool {
|
||||
match self.fetch_node_remote(parent) {
|
||||
Ok(Some(node)) => node.trashed,
|
||||
Ok(None) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A node's parent uid and name, as the tree currently has them.
|
||||
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
||||
{
|
||||
let st = self.state.lock();
|
||||
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
|
||||
&& let Some(parent) = entry.node.parent_uid.clone()
|
||||
{
|
||||
return Some((parent, entry.node.name.clone()));
|
||||
}
|
||||
}
|
||||
// The in-memory tree only holds what has been walked to since the daemon
|
||||
// started, and the drain routinely runs before anything has walked to
|
||||
// this file — after a restart, or for a write that arrived by path. The
|
||||
// DB knows it anyway. Without this fallback a conflict copy was named
|
||||
// after the node's *uid* and dumped in the root, which is how a file
|
||||
// called `G88km…==~c2do…== (sync-conflict 1784429627)` appears at the
|
||||
// top of someone's Drive.
|
||||
let node = self.db.node_by_uid(&uid.to_string()).ok().flatten()?;
|
||||
Some((node.parent_uid.clone()?, node.name.clone()))
|
||||
}
|
||||
|
||||
/// The name to show a user for `uid`, for a transfer entry or an activity
|
||||
/// log line.
|
||||
///
|
||||
/// Falls back to something short and human rather than to the uid: a uid is
|
||||
/// 130 characters of base64 that identifies the file to us and to nobody
|
||||
/// else, and it has ended up in both the activity log and on real files in
|
||||
/// the Drive root. The link prefix keeps it traceable without pretending to
|
||||
/// be a name.
|
||||
pub(crate) fn node_name(&self, uid: &NodeUid) -> String {
|
||||
self.node_place(uid).map(|(_, n)| n).unwrap_or_else(|| {
|
||||
let short: String = uid.link_id.to_string().chars().take(8).collect();
|
||||
format!("recovered-{short}")
|
||||
})
|
||||
}
|
||||
|
||||
/// The uid of the My Files root, which every node in the mount descends
|
||||
/// from — the last resort for placing a file whose own parent is unknown.
|
||||
///
|
||||
/// `None` only for a [`Core::fork_state`] sibling that has not interned its
|
||||
/// root yet, which is not where the drain runs; the drain must not panic
|
||||
/// over it regardless, since that would stop the queue for good.
|
||||
pub(crate) fn root_uid(&self) -> Option<NodeUid> {
|
||||
let st = self.state.lock();
|
||||
st.entries.get(&ROOT_INO).map(|e| e.uid.clone())
|
||||
}
|
||||
|
||||
/// Upload a staged revision of a file the server already knows about.
|
||||
pub(crate) fn drain_revision(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let blob = op
|
||||
.blob_path
|
||||
.clone()
|
||||
.ok_or("pending op has no staged blob")?;
|
||||
let blob = PathBuf::from(blob);
|
||||
let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
|
||||
let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?;
|
||||
|
||||
if let Some(reason) = self.revision_conflict(&uid, &meta)? {
|
||||
return self.keep_as_conflict_copy(op, &blob, &meta, &uid, &reason);
|
||||
}
|
||||
|
||||
// An incomplete blob is authored bytes over zeros; the untouched ranges
|
||||
// have to be filled from the base before it can be sent. This is the case
|
||||
// the write could not resolve at release time (it was offline), and the
|
||||
// reason it is safe to do now is that we are not.
|
||||
if !meta.complete {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&blob)?;
|
||||
let mut written = Intervals::default();
|
||||
for &(s, e) in &meta.authored {
|
||||
written.add(s, e);
|
||||
}
|
||||
self.fill_gaps(
|
||||
&uid,
|
||||
&file,
|
||||
meta.len,
|
||||
meta.base_mtime,
|
||||
meta.base_size,
|
||||
&written,
|
||||
)
|
||||
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
|
||||
}
|
||||
|
||||
let name = self.node_name(&uid);
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
let reader = CountingReader::new(File::open(&blob)?, &guard);
|
||||
self.rt.block_on(self.client.upload_new_revision_from(
|
||||
&uid,
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
None,
|
||||
))?;
|
||||
drop(guard);
|
||||
|
||||
// Retire the op before dropping the blob: a crash between the two leaves
|
||||
// an orphaned file (harmless), whereas the reverse would leave a queued
|
||||
// op pointing at nothing.
|
||||
self.db.delete_op(op.id)?;
|
||||
self.pending.lock().remove(&uid);
|
||||
|
||||
// The staged blob now matches the sealed revision, so a pinned file keeps
|
||||
// it as its cached content rather than re-downloading what we just sent.
|
||||
// Adopted by link, not by value: the blob is the whole file, and a pinned
|
||||
// video read into a `Vec` here is an OOM. `discard_staged` below drops the
|
||||
// staging name only — the cache keeps the inode.
|
||||
if self.cache.is_pinned(&uid) {
|
||||
let _ = self.cache.store_file(&uid, now_secs(), meta.len, &blob);
|
||||
}
|
||||
self.cache.discard_staged(&blob);
|
||||
self.evict_reader(&uid);
|
||||
self.refresh_after_upload(&uid);
|
||||
self.log_activity(ActivityKind::Upload, &name, "uploaded", true);
|
||||
info!(%uid, len = meta.len, "pending upload landed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adopt the server's metadata for a node we have just uploaded a revision
|
||||
/// for.
|
||||
///
|
||||
/// [`State::record_pending_write`] deliberately stamps the node with the
|
||||
/// moment we *accepted* the write, so `ls` reflects it before the upload
|
||||
/// lands. The server stamps the sealed revision with its own time, and the
|
||||
/// two differ by however long the upload took. That difference is not
|
||||
/// cosmetic: [`Core::remote_baseline`] reads this node to build
|
||||
/// [`StagedWrite::based_on`], and [`Core::revision_conflict`] compares that
|
||||
/// baseline against the remote — so leaving our optimistic time in place
|
||||
/// makes the *next* write to this file look like another device changed it
|
||||
/// underneath us, and diverts it into a conflict copy over nothing.
|
||||
///
|
||||
/// A write queued while this upload was on the wire is handled instead by
|
||||
/// [`Core::rebaseline_pending`]: its optimistic size/mtime must stay on the
|
||||
/// node (the same reason `apply_event` refuses to overwrite a node with a
|
||||
/// queued write), but its *baseline* still has to learn about the revision
|
||||
/// we just sealed. Skipping both, as this used to, is what produced a file
|
||||
/// that conflicted with itself and left a full-size duplicate behind.
|
||||
///
|
||||
/// Best effort: a failure here costs a spurious conflict copy on the next
|
||||
/// write, not this upload, which has already landed.
|
||||
pub(crate) fn refresh_after_upload(&self, uid: &NodeUid) {
|
||||
let node = match self.fetch_node_remote(uid) {
|
||||
Ok(Some(node)) => node,
|
||||
// Trashed or deleted under us: the tree will hear it from the event
|
||||
// sync, which is better placed to unhook the inode than we are.
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
warn!(%uid, error = %e,
|
||||
"refreshing metadata after an upload failed; \
|
||||
the next write to this file may conflict with itself");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Ordered so that a write queued *during* the fetch above is still
|
||||
// caught: it took its baseline from the node's optimistic stamp, and
|
||||
// this overwrites it with the revision the server actually holds.
|
||||
if self.rebaseline_pending(uid, &node) {
|
||||
return;
|
||||
}
|
||||
let mut st = self.state.lock();
|
||||
let Some(parent) = st
|
||||
.by_uid
|
||||
.get(uid)
|
||||
.and_then(|ino| st.entries.get(ino))
|
||||
.map(|e| e.parent)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
st.intern(parent, node);
|
||||
}
|
||||
|
||||
/// Point a still-queued write at the revision this upload just sealed, and
|
||||
/// report whether there was one.
|
||||
///
|
||||
/// The revision we sent *is* the base the queued write will be applied over,
|
||||
/// but nothing else says so. [`Core::remote_baseline`] carries a baseline
|
||||
/// across a supersede because the op it inherits from "is the last one that
|
||||
/// actually observed the remote" — true only until that op drains. Once it
|
||||
/// has, the inherited baseline names a revision we replaced ourselves, and
|
||||
/// [`Core::revision_conflict`] reads that as another device having moved the
|
||||
/// file. It is a self-conflict, and it is expensive: the whole staged blob
|
||||
/// is re-uploaded as a second file.
|
||||
///
|
||||
/// Both copies of the sidecar are updated — the in-memory one the next
|
||||
/// `release` inherits from, and the persisted one the drain reloads after a
|
||||
/// restart. Best effort on the DB half; the in-memory half is what the
|
||||
/// immediate next write reads.
|
||||
fn rebaseline_pending(&self, uid: &NodeUid, sealed: &Node) -> bool {
|
||||
let base = Baseline {
|
||||
mtime: sealed.modification_time,
|
||||
size: node_size(sealed),
|
||||
};
|
||||
let mut pending = self.pending.lock();
|
||||
let Some(p) = pending.get_mut(uid) else {
|
||||
return false;
|
||||
};
|
||||
p.meta.based_on = Some(base);
|
||||
match serde_json::to_string(&p.meta) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = self.db.update_op_meta(&uid.to_string(), OP_REVISION, &json) {
|
||||
warn!(%uid, error = %e,
|
||||
"restamping a queued write's baseline failed; \
|
||||
it may land as a conflict copy of itself");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(%uid, error = %e, "serializing a restamped sidecar failed"),
|
||||
}
|
||||
debug!(%uid, mtime = base.mtime, size = base.size,
|
||||
"queued write rebaselined onto the revision just uploaded");
|
||||
true
|
||||
}
|
||||
}
|
||||
+2215
-2595
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
||||
//! The photos timeline and its thumbnails.
|
||||
//!
|
||||
//! The timeline is a flat, newest-first projection of the photo share, persisted
|
||||
//! by [`pdfs_core::db`] so the gallery opens instantly and refreshes behind the
|
||||
//! user. Thumbnails the server has none for (anything a camera wrote rather than
|
||||
//! a phone) are generated locally and stored as if the server had served them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
|
||||
use pdfs_core::db::{self, StoredPhoto};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{NodeKind, ThumbnailType};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{
|
||||
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, THUMB_GEN_CONCURRENCY, TIMELINE_ENRICH_CHUNK,
|
||||
TIMELINE_TTL, ThumbAttempt, node_size, now_ms, parse_uid, ratio_of, scale_thumbnail,
|
||||
};
|
||||
|
||||
impl Core {
|
||||
pub(crate) fn photos_timeline(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
kind: Option<PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
||||
let count = self.db.photos_count().map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
// Nothing to serve, so this one request has to wait for the fetch —
|
||||
// unless we already know the account has no photos volume and the
|
||||
// answer is a fresh "no".
|
||||
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
|
||||
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.rt.block_on(self.refresh_timeline())? {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
|
||||
self.spawn_timeline_refresh();
|
||||
}
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.photos_page(offset, limit, kind, range)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
||||
}
|
||||
|
||||
/// Project a persisted photo into the wire item the front-end paints: its
|
||||
/// learned aspect ratio, its thumbnail verdict, and the on-disk path of its
|
||||
/// thumbnail when one is cached (tagged with the capture time, which is the
|
||||
/// only revision handle a photo carries).
|
||||
pub(crate) fn photo_item(&self, photo: StoredPhoto) -> PhotoItem {
|
||||
let thumb_path = parse_uid(&photo.uid).and_then(|uid| {
|
||||
self.cache
|
||||
.cached_thumbnail_path(&uid, ThumbnailType::Thumbnail.as_i32(), photo.capture_time)
|
||||
.map(|p| p.display().to_string())
|
||||
});
|
||||
PhotoItem {
|
||||
uid: photo.uid,
|
||||
capture_time: photo.capture_time,
|
||||
thumb_path,
|
||||
name: photo.name,
|
||||
ratio: photo.ratio,
|
||||
no_thumb: photo.thumb_state == db::THUMB_NONE,
|
||||
kind: photo.kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// Thumbnails for `uids`, served from the cache, fetched from the server for
|
||||
/// whatever is missing, and — for the photos the server has no thumbnail for
|
||||
/// at all — *generated locally* from the full file (see
|
||||
/// [`Core::generate_thumbs`]). Requested on demand as tiles scroll into view,
|
||||
/// so a cold timeline paints immediately and only the photos actually on
|
||||
/// screen cost a round-trip.
|
||||
///
|
||||
/// A photo absent from the persisted timeline is skipped: its capture time is
|
||||
/// the cache's validity tag, and guessing that would poison the cache.
|
||||
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
|
||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
|
||||
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
||||
let tags: HashMap<String, i64> = stored
|
||||
.iter()
|
||||
.map(|p| (p.uid.clone(), p.capture_time))
|
||||
.collect();
|
||||
|
||||
// Ask the server only for photos that are missing a cached thumbnail and
|
||||
// haven't already been written off as un-thumbnailable.
|
||||
let want: Vec<NodeUid> = uids
|
||||
.iter()
|
||||
.filter(|uid| {
|
||||
let key = uid.to_string();
|
||||
stored
|
||||
.iter()
|
||||
.find(|p| p.uid == key)
|
||||
.is_some_and(|p| p.thumb_state != db::THUMB_NONE)
|
||||
&& tags.get(&key).is_some_and(|&tag| {
|
||||
self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !want.is_empty() {
|
||||
let mut missing = want.clone();
|
||||
match self.rt.block_on(
|
||||
self.photos()
|
||||
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
|
||||
) {
|
||||
Ok(thumbs) => {
|
||||
for ft in thumbs {
|
||||
let Some(&tag) = tags.get(&ft.file_uid.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(bytes) = ft.result else { continue };
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.cache
|
||||
.store_thumbnail(&ft.file_uid, ttype, tag, &bytes)
|
||||
.is_ok()
|
||||
{
|
||||
missing.retain(|uid| uid != &ft.file_uid);
|
||||
self.record_thumb(&ft.file_uid, db::THUMB_HAVE, ratio_of(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A failed batch is not a verdict: leave every uid in `missing` so
|
||||
// the local fallback still gives those tiles an image.
|
||||
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
|
||||
}
|
||||
|
||||
// Whatever the server had nothing for gets a thumbnail made from its
|
||||
// own bytes — this is what fills in camera photos uploaded by clients
|
||||
// that never generated one. Off the request path: a full-size photo
|
||||
// takes far longer to fetch than the whole rest of the batch, and the
|
||||
// thumbnails that *are* ready must not wait behind it.
|
||||
if !missing.is_empty() {
|
||||
self.spawn_generate_thumbs(missing, &tags);
|
||||
}
|
||||
}
|
||||
|
||||
let pending = self.thumb_gen.lock();
|
||||
uids.iter()
|
||||
.map(|uid| PhotoThumb {
|
||||
uid: uid.to_string(),
|
||||
path: tags.get(&uid.to_string()).and_then(|&tag| {
|
||||
self.cache
|
||||
.cached_thumbnail_path(uid, ttype, tag)
|
||||
.map(|p| p.display().to_string())
|
||||
}),
|
||||
pending: pending.contains(uid),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate the missing thumbnails on the runtime, skipping any photo already
|
||||
/// being generated. The uids are marked in-flight before the task starts, so
|
||||
/// the reply this call is about to send already reports them as pending.
|
||||
pub(crate) fn spawn_generate_thumbs(&self, uids: Vec<NodeUid>, tags: &HashMap<String, i64>) {
|
||||
let fresh: Vec<NodeUid> = {
|
||||
let mut inflight = self.thumb_gen.lock();
|
||||
uids.into_iter()
|
||||
.filter(|uid| inflight.insert(uid.clone()))
|
||||
.collect()
|
||||
};
|
||||
if fresh.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let core = self.clone();
|
||||
let tags = tags.clone();
|
||||
// `generate_thumbs` blocks on the runtime itself, so it belongs on the
|
||||
// blocking pool rather than on an async worker.
|
||||
self.rt.spawn_blocking(move || {
|
||||
core.generate_thumbs(&fresh, &tags);
|
||||
let mut inflight = core.thumb_gen.lock();
|
||||
for uid in &fresh {
|
||||
inflight.remove(uid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Make thumbnails for photos the server has none for: download each full
|
||||
/// file once, scale it to [`THUMB_EDGE`], and store the result in the thumbnail
|
||||
/// cache exactly as if the server had served it.
|
||||
///
|
||||
/// Bounded by [`THUMB_GEN_CONCURRENCY`] — these are full-size originals, and a
|
||||
/// screenful of them at once would saturate the link. A photo whose bytes
|
||||
/// can't be decoded (a codec `image` doesn't speak) is marked
|
||||
/// [`db::THUMB_NONE`] and never attempted again.
|
||||
pub(crate) fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap<String, i64>) {
|
||||
info!(
|
||||
count = uids.len(),
|
||||
"generating thumbnails the server has none for"
|
||||
);
|
||||
let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async {
|
||||
let mut out = Vec::with_capacity(uids.len());
|
||||
for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) {
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for uid in chunk {
|
||||
let client = self.client.clone();
|
||||
let uid = uid.clone();
|
||||
set.spawn(async move {
|
||||
let bytes = match client.download_file(&uid).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
warn!(%uid, error = %e, "photo download for thumbnail failed");
|
||||
return (uid, ThumbAttempt::Unavailable);
|
||||
}
|
||||
};
|
||||
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
|
||||
// stall the runtime's worker; hand it to the blocking pool.
|
||||
let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes))
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
match made {
|
||||
Some(thumb) => (uid, ThumbAttempt::Made(thumb)),
|
||||
None => (uid, ThumbAttempt::Undecodable),
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(joined) = set.join_next().await {
|
||||
if let Ok(result) = joined {
|
||||
out.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
|
||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||
for (uid, attempt) in results {
|
||||
match attempt {
|
||||
ThumbAttempt::Made(thumb) => {
|
||||
let Some(&tag) = tags.get(&uid.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) {
|
||||
Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)),
|
||||
Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"),
|
||||
}
|
||||
}
|
||||
// The photo's own bytes aren't an image we can read: no thumbnail
|
||||
// will ever exist for it, so stop trying.
|
||||
ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None),
|
||||
// The download failed — a dropped connection, an expired link. That
|
||||
// is not a verdict on the photo: leave its state alone so the next
|
||||
// scroll past it tries again.
|
||||
ThumbAttempt::Unavailable => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist what a thumbnail attempt learned about a photo.
|
||||
pub(crate) fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
|
||||
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
|
||||
warn!(%uid, error = %e, "recording thumbnail state failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
||||
/// fetched).
|
||||
pub(crate) async fn refresh_timeline(&self) -> CoreResult<bool> {
|
||||
let photos = self.photos();
|
||||
if photos
|
||||
.get_photos_root()
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "photos root"))?
|
||||
.is_none()
|
||||
{
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
|
||||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||||
return Ok(false);
|
||||
}
|
||||
let items = photos
|
||||
.enumerate_timeline()
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "timeline"))?;
|
||||
|
||||
// The timeline DTO carries only a uid and capture time, but the Photos
|
||||
// page has to split into Photos / Videos / Raw — which needs each photo's
|
||||
// name and media type. Resolve those in batches off the request path.
|
||||
// Best-effort: a photo whose node we fail to resolve keeps whatever was
|
||||
// learned before (or classifies from nothing, i.e. a still photo), so a
|
||||
// partial resolve never blanks the timeline.
|
||||
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
|
||||
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
for node in nodes {
|
||||
let media_type = match &node.kind {
|
||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||
NodeKind::Folder => None,
|
||||
};
|
||||
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let key = it.uid.to_string();
|
||||
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
|
||||
(key, it.capture_time, name, media_type)
|
||||
})
|
||||
.collect();
|
||||
self.db.photos_replace(&rows).map_err(CoreError::from)?;
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
|
||||
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Refresh the timeline off the request path, so a stale page is still served
|
||||
/// at DB speed. At most one refresh runs at a time.
|
||||
pub(crate) fn spawn_timeline_refresh(&self) {
|
||||
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_timeline().await {
|
||||
warn!(error = %e, "background timeline refresh failed");
|
||||
}
|
||||
core.timeline_refreshing.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Download a photo's full content into the content cache, returning its
|
||||
/// on-disk path (served from cache when a fresh blob already exists).
|
||||
pub(crate) fn open_photo(&self, uid: &NodeUid) -> CoreResult<PathBuf> {
|
||||
let photos = self.photos();
|
||||
let node = self
|
||||
.rt
|
||||
.block_on(photos.get_node(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "photo node"))?
|
||||
.ok_or_else(|| CoreError::not_found("photo not found"))?;
|
||||
let (mtime, size) = (node.modification_time, node_size(&node));
|
||||
if let Some(p) = self.cache.cached_content_path(uid, mtime, size) {
|
||||
return Ok(p);
|
||||
}
|
||||
let bytes = self
|
||||
.download_photo_tracked(&photos, uid, &node.name, size)
|
||||
.map_err(|e| CoreError::from_api(&e, "download photo"))?;
|
||||
self.cache
|
||||
.store(uid, mtime, size, &bytes)
|
||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||||
Ok(self.cache.content_path(uid))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Sharing: per-node share membership, public links, the invitations this
|
||||
//! account has received, saved public-link bookmarks, and the reverse view of
|
||||
//! everything this account shares out.
|
||||
//!
|
||||
//! All of it is remote-only state — nothing here touches the inode maps or the
|
||||
//! content cache, so every method is a round-trip wrapped in the crate error
|
||||
//! shape the control socket expects.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
use super::{Core, ROOT_INO, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
|
||||
impl Core {
|
||||
// ---- sharing a node ---------------------------------------------------
|
||||
|
||||
/// Invite Proton and/or external emails to the node at `rel` at `role`.
|
||||
/// Returns `(proton_invited, external_invited)`.
|
||||
pub(crate) fn share_node(
|
||||
&self,
|
||||
rel: &Path,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let role = role_from_str(role)?;
|
||||
let invitees: Vec<(String, MemberRole)> =
|
||||
emails.iter().map(|e| (e.clone(), role)).collect();
|
||||
self.rt
|
||||
.block_on(self.client.invite_users(&uid, &invitees, message))
|
||||
.map_err(|e| CoreError::from_api(&e, "share"))
|
||||
}
|
||||
|
||||
/// List the members, pending invitations and public link of the node at `rel`.
|
||||
pub(crate) fn list_share(
|
||||
&self,
|
||||
rel: &Path,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for m in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: m.membership_id.to_string(),
|
||||
email: m.email,
|
||||
role: role_to_str(m.role).to_string(),
|
||||
kind: ShareEntryKind::Member,
|
||||
});
|
||||
}
|
||||
for inv in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: inv.invitation_id,
|
||||
email: inv.invitee_email,
|
||||
role: role_to_str(inv.role).to_string(),
|
||||
kind: ShareEntryKind::ProtonInvite,
|
||||
});
|
||||
}
|
||||
for ext in self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
id: ext.invitation_id,
|
||||
email: ext.invitee_email,
|
||||
role: role_to_str(ext.role).to_string(),
|
||||
kind: ShareEntryKind::ExternalInvite,
|
||||
});
|
||||
}
|
||||
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.map(public_link_info);
|
||||
|
||||
Ok((entries, link))
|
||||
}
|
||||
|
||||
/// Change the role of a member or pending Proton invitation on the node at
|
||||
/// `rel`. External invitations have no role-update endpoint.
|
||||
pub(crate) fn update_share_role(
|
||||
&self,
|
||||
rel: &Path,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let role = role_from_str(role)?;
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
.ok_or_else(|| CoreError::not_found("member not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.update_member_role(&member, role))
|
||||
.map_err(|e| CoreError::from_api(&e, "update role"))
|
||||
}
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.update_invitation_role(&inv, role))
|
||||
.map_err(|e| CoreError::from_api(&e, "update role"))
|
||||
}
|
||||
ShareEntryKind::ExternalInvite => Err(CoreError::invalid(
|
||||
"an external invitation's role cannot be changed",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a member, pending Proton invite, or external invite from the node
|
||||
/// at `rel`.
|
||||
pub(crate) fn remove_share_entry(
|
||||
&self,
|
||||
rel: &Path,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
.into_iter()
|
||||
.find(|m| m.membership_id.to_string() == id)
|
||||
.ok_or_else(|| CoreError::not_found("member not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.remove_member(&member))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove member"))
|
||||
}
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.delete_invitation(&inv))
|
||||
.map_err(|e| CoreError::from_api(&e, "revoke invitation"))
|
||||
}
|
||||
ShareEntryKind::ExternalInvite => {
|
||||
let ext = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
.into_iter()
|
||||
.find(|i| i.invitation_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("external invitation not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.delete_external_invitation(&ext))
|
||||
.map_err(|e| CoreError::from_api(&e, "revoke external invitation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a public link on the node at `rel`, returning its info (with URL).
|
||||
pub(crate) fn create_public_link(
|
||||
&self,
|
||||
rel: &Path,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let role = role_from_str(role)?;
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_public_link(&uid, role, password, expires),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Remove the public link `id` from the node at `rel`.
|
||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
self.rt
|
||||
.block_on(self.client.remove_public_link(&link))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||
}
|
||||
|
||||
// ---- shared with me ---------------------------------------------------
|
||||
|
||||
/// List nodes shared with me that I have accepted.
|
||||
pub(crate) fn list_shared_with_me(&self) -> CoreResult<Vec<DirEntry>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_shared_with_me_node_uids())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
let is_dir = n.is_folder();
|
||||
let size = match &n.kind {
|
||||
NodeKind::File {
|
||||
claimed_size,
|
||||
total_size_on_storage,
|
||||
..
|
||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||
NodeKind::Folder => 0,
|
||||
};
|
||||
DirEntry {
|
||||
name: n.name,
|
||||
is_dir,
|
||||
size,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: n.uid.to_string(),
|
||||
path: String::new(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Leave a shared node by its uid.
|
||||
pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> {
|
||||
let uid =
|
||||
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
|
||||
self.rt
|
||||
.block_on(self.client.leave_shared_node(&uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "leave shared"))
|
||||
}
|
||||
|
||||
// ---- incoming invitations ---------------------------------------------
|
||||
|
||||
/// List invitations addressed to me, pending accept or reject.
|
||||
pub(crate) fn list_invitations(&self) -> CoreResult<Vec<InvitationInfo>> {
|
||||
let invitations = self
|
||||
.rt
|
||||
.block_on(self.client.list_incoming_invitations())
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||
Ok(invitations
|
||||
.into_iter()
|
||||
.map(|i| InvitationInfo {
|
||||
id: i.invitation_id,
|
||||
inviter_email: i.inviter_email,
|
||||
name: i.node_name,
|
||||
is_dir: i.is_folder,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Accept an invitation addressed to me by its id.
|
||||
pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.accept_invitation(id))
|
||||
.map_err(|e| CoreError::from_api(&e, "accept invitation"))
|
||||
}
|
||||
|
||||
/// Reject an invitation addressed to me by its id.
|
||||
pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.reject_invitation(id))
|
||||
.map_err(|e| CoreError::from_api(&e, "reject invitation"))
|
||||
}
|
||||
|
||||
// ---- bookmarks --------------------------------------------------------
|
||||
|
||||
/// List public links saved to my account.
|
||||
pub(crate) fn list_bookmarks(&self) -> CoreResult<Vec<BookmarkInfo>> {
|
||||
let bookmarks = self
|
||||
.rt
|
||||
.block_on(self.client.list_bookmarks())
|
||||
.map_err(|e| CoreError::from_api(&e, "list bookmarks"))?;
|
||||
Ok(bookmarks
|
||||
.into_iter()
|
||||
.map(|b| BookmarkInfo {
|
||||
token: b.token,
|
||||
url: b.url,
|
||||
name: b.node_name,
|
||||
is_dir: b.is_folder,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Save a public link (optionally password-protected) as a bookmark.
|
||||
pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.create_bookmark(url, password))
|
||||
.map_err(|e| CoreError::from_api(&e, "create bookmark"))
|
||||
}
|
||||
|
||||
/// Remove a saved bookmark by its token.
|
||||
pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.client.delete_bookmark(token))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete bookmark"))
|
||||
}
|
||||
|
||||
// ---- shared by me -----------------------------------------------------
|
||||
|
||||
/// List the nodes I have shared with others, each with a summary of its share
|
||||
/// state (members, pending invitations, public link). One list call enumerates
|
||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||
/// node racing with an unshare drops from the list rather than failing the whole
|
||||
/// request.
|
||||
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_shared_by_me_node_uids())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate shared-by-me"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
let mut items = Vec::with_capacity(nodes.len());
|
||||
for n in nodes {
|
||||
let uid = n.uid.clone();
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
let proton_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
let external_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(public_link_info);
|
||||
items.push(SharedItem {
|
||||
uid: uid.to_string(),
|
||||
is_dir: n.is_folder(),
|
||||
name: n.name,
|
||||
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
||||
member_count: members,
|
||||
invite_count: proton_invites + external_invites,
|
||||
link,
|
||||
});
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Best-effort mountpoint-relative path for a node already interned in the live
|
||||
/// tree, by walking parent inodes to the root. `None` when the node has never
|
||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||
/// the caller then leaves the path empty.
|
||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||
let st = self.state.lock();
|
||||
let mut ino = *st.by_uid.get(uid)?;
|
||||
let mut parts = Vec::new();
|
||||
while ino != ROOT_INO {
|
||||
let entry = st.entries.get(&ino)?;
|
||||
parts.push(entry.node.name.clone());
|
||||
ino = entry.parent;
|
||||
}
|
||||
parts.reverse();
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Inode bookkeeping: the maps that give a remote node a stable kernel inode,
|
||||
//! the open write handles, and the interval set that tracks which bytes of a
|
||||
//! staged write are authored locally.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfs_core::cache::StagedWrite;
|
||||
use pdfs_core::db::Db;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
use tracing::warn;
|
||||
|
||||
/// A node known to the filesystem, addressed by its kernel inode.
|
||||
pub(crate) struct Entry {
|
||||
pub(crate) uid: NodeUid,
|
||||
pub(crate) parent: u64,
|
||||
pub(crate) node: Node,
|
||||
}
|
||||
|
||||
/// A set of non-overlapping `[start, end)` byte ranges, kept sorted and merged.
|
||||
/// Tracks which bytes of a [`WriteHandle`]'s scratch file were authored locally
|
||||
/// (vs. still living only in the remote base), so reads and the commit gap-fill
|
||||
/// know which regions to pull from the network.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Intervals(pub(crate) Vec<(u64, u64)>);
|
||||
|
||||
impl Intervals {
|
||||
/// Mark `[start, end)` as authored, coalescing with any touching ranges.
|
||||
pub(crate) fn add(&mut self, start: u64, end: u64) {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
self.0.push((start, end));
|
||||
self.0.sort_by_key(|&(s, _)| s);
|
||||
let mut merged: Vec<(u64, u64)> = Vec::with_capacity(self.0.len());
|
||||
for &(s, e) in &self.0 {
|
||||
match merged.last_mut() {
|
||||
Some(last) if s <= last.1 => last.1 = last.1.max(e),
|
||||
_ => merged.push((s, e)),
|
||||
}
|
||||
}
|
||||
self.0 = merged;
|
||||
}
|
||||
|
||||
/// Drop everything at or beyond `len` (a shrink/truncate).
|
||||
pub(crate) fn clip(&mut self, len: u64) {
|
||||
self.0.retain(|&(s, _)| s < len);
|
||||
for iv in &mut self.0 {
|
||||
iv.1 = iv.1.min(len);
|
||||
}
|
||||
}
|
||||
|
||||
/// Split `[start, end)` into contiguous `(s, e, authored)` segments, in
|
||||
/// order. `authored == true` means the bytes live in the scratch file;
|
||||
/// `false` means they must come from the remote base (or are a hole).
|
||||
pub(crate) fn segments(&self, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut pos = start;
|
||||
for &(s, e) in &self.0 {
|
||||
if e <= start {
|
||||
continue;
|
||||
}
|
||||
if s >= end {
|
||||
break;
|
||||
}
|
||||
let ws = s.max(start);
|
||||
let we = e.min(end);
|
||||
if pos < ws {
|
||||
out.push((pos, ws, false));
|
||||
}
|
||||
out.push((ws, we, true));
|
||||
pos = we;
|
||||
}
|
||||
if pos < end {
|
||||
out.push((pos, end, false));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// State for a file opened for writing. Authored bytes are staged in an on-disk
|
||||
/// scratch file (positional reads/writes) rather than RAM, so a multi-GiB write
|
||||
/// never balloons the daemon. On flush/release the scratch file — gap-filled
|
||||
/// from the remote base where untouched — is streamed up as one new revision,
|
||||
/// since the SDK seals whole revisions rather than byte ranges.
|
||||
pub(crate) struct WriteHandle {
|
||||
pub(crate) ino: u64,
|
||||
pub(crate) uid: NodeUid,
|
||||
/// Disk-backed staging buffer. Shared (`Arc`) so reads can use it without
|
||||
/// holding the state lock across I/O. Accessed positionally (`read_at`/
|
||||
/// `write_at`), so a clone never disturbs another's file offset.
|
||||
pub(crate) file: Arc<File>,
|
||||
/// Scratch file path, removed on release.
|
||||
pub(crate) path: PathBuf,
|
||||
/// Byte ranges authored into `file`. Everything else in `[0, len)` is base.
|
||||
pub(crate) written: Intervals,
|
||||
/// Logical file size (may exceed authored bytes after a truncate-extend).
|
||||
pub(crate) len: u64,
|
||||
/// Size of the remote base at open, for serving untouched ranges.
|
||||
pub(crate) base_size: u64,
|
||||
/// Modification time of the remote base at open, validating its block cache.
|
||||
pub(crate) base_mtime: i64,
|
||||
/// Whether anything diverged from the remote and needs an upload.
|
||||
pub(crate) dirty: bool,
|
||||
}
|
||||
|
||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
||||
///
|
||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||
/// a database round trip.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PendingRevision {
|
||||
/// Staged blob holding the written bytes.
|
||||
pub(crate) path: PathBuf,
|
||||
/// Which of those bytes are real, and what base the gaps refer to.
|
||||
pub(crate) meta: StagedWrite,
|
||||
}
|
||||
|
||||
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||
/// `Filesystem` trait through `&self`.
|
||||
pub(crate) struct State {
|
||||
/// inode -> node metadata.
|
||||
pub(crate) entries: HashMap<u64, Entry>,
|
||||
/// Dedupe inodes by node uid so a node keeps a stable inode across lookups.
|
||||
pub(crate) by_uid: HashMap<NodeUid, u64>,
|
||||
/// Cached directory listings: parent inode -> child inodes. Presence of a
|
||||
/// key means the directory has been enumerated.
|
||||
pub(crate) children: HashMap<u64, Vec<u64>>,
|
||||
pub(crate) next_ino: u64,
|
||||
/// Open write handles keyed by file handle id. Read-only opens use fh 0 and
|
||||
/// have no entry here.
|
||||
pub(crate) handles: HashMap<u64, WriteHandle>,
|
||||
pub(crate) next_fh: u64,
|
||||
/// Unified SQLite metadata cache. Every map mutation below writes through to
|
||||
/// it inside the `State` lock so the DB stays the authoritative copy across
|
||||
/// restarts (see plan.md P1).
|
||||
pub(crate) db: Arc<Db>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
|
||||
}
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
|
||||
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
|
||||
/// only. Every caller owes the DB a write-through — see the callers below;
|
||||
/// the split exists so a batch can pay for one transaction instead of `n`.
|
||||
pub(crate) fn intern_mem(&mut self, parent: u64, node: Node) -> u64 {
|
||||
if let Some(&ino) = self.by_uid.get(&node.uid) {
|
||||
if let Some(e) = self.entries.get_mut(&ino) {
|
||||
e.node = node;
|
||||
e.parent = parent;
|
||||
}
|
||||
return ino;
|
||||
}
|
||||
let ino = self.next_ino;
|
||||
self.next_ino += 1;
|
||||
self.by_uid.insert(node.uid.clone(), ino);
|
||||
self.entries.insert(
|
||||
ino,
|
||||
Entry {
|
||||
uid: node.uid.clone(),
|
||||
parent,
|
||||
node,
|
||||
},
|
||||
);
|
||||
ino
|
||||
}
|
||||
|
||||
/// Allocate (or reuse) a stable inode for a node that came *from* the
|
||||
/// database, which is why nothing is written back.
|
||||
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
|
||||
/// Allocate (or reuse) stable inodes for a whole listing, writing every node
|
||||
/// through in a single transaction.
|
||||
///
|
||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||
/// listing, rather than one autocommit — and one fsync — per child.
|
||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||
if let Err(e) = self.db.upsert_nodes(&nodes) {
|
||||
warn!(error = %e, "db upsert_nodes failed");
|
||||
}
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| self.intern_mem(parent, node))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
|
||||
/// listing, its slot in its parent's listing, and its DB row. Returns
|
||||
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
||||
/// the kernel.
|
||||
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
||||
let ino = self.by_uid.remove(uid)?;
|
||||
if let Err(e) = self.db.delete_node(uid) {
|
||||
warn!(%uid, error = %e, "db delete_node failed");
|
||||
}
|
||||
let entry = self.entries.remove(&ino)?;
|
||||
self.children.remove(&ino);
|
||||
if let Some(kids) = self.children.get_mut(&entry.parent) {
|
||||
kids.retain(|&k| k != ino);
|
||||
}
|
||||
Some((entry.parent, entry.node.name))
|
||||
}
|
||||
|
||||
/// Move a node to a new parent and/or name within the tree, writing it
|
||||
/// through like any other mutation.
|
||||
///
|
||||
/// The online rename instead forgets the node and lets the destination
|
||||
/// re-enumerate, which is the cheaper way to stay honest about what the
|
||||
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
||||
/// the server has not been told yet in any case — so this *is* the tree's
|
||||
/// new truth until the op drains (offline.md Phase 3b).
|
||||
pub(crate) fn rename_in_place(
|
||||
&mut self,
|
||||
ino: u64,
|
||||
new_parent: u64,
|
||||
new_parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
) {
|
||||
let Some(entry) = self.entries.get_mut(&ino) else {
|
||||
return;
|
||||
};
|
||||
let old_parent = entry.parent;
|
||||
entry.parent = new_parent;
|
||||
entry.node.name = name.to_string();
|
||||
entry.node.parent_uid = Some(new_parent_uid.clone());
|
||||
let node = entry.node.clone();
|
||||
if old_parent != new_parent {
|
||||
if let Some(kids) = self.children.get_mut(&old_parent) {
|
||||
kids.retain(|&k| k != ino);
|
||||
}
|
||||
// Only if the destination is listed: pushing into a listing that was
|
||||
// never enumerated would invent a one-child folder.
|
||||
if let Some(kids) = self.children.get_mut(&new_parent)
|
||||
&& !kids.contains(&ino)
|
||||
{
|
||||
kids.push(ino);
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a directory's cached child listing and mark it unlisted in the DB,
|
||||
/// so the next access re-enumerates instead of trusting a stale listing.
|
||||
///
|
||||
/// The DB flag is cleared whether or not the listing was resident. A folder
|
||||
/// trimmed from the hot cache but still `listed` in the DB is exactly the
|
||||
/// case that needs invalidating — returning early there would leave
|
||||
/// `ensure_children` free to rebuild the stale listing from disk.
|
||||
pub(crate) fn invalidate_listing(&mut self, ino: u64) {
|
||||
self.children.remove(&ino);
|
||||
if let Some(e) = self.entries.get(&ino) {
|
||||
let uid = e.uid.clone();
|
||||
if let Err(err) = self.db.set_listed(&uid, false) {
|
||||
warn!(%uid, error = %err, "db set_listed(false) failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle the local state after a node has been moved (and possibly renamed)
|
||||
/// on the remote: rewrite it in place, and drop **both** directories'
|
||||
/// listings so each re-enumerates.
|
||||
///
|
||||
/// Both, because each is stale for its own reason. The destination has
|
||||
/// gained a child it does not know about; the source has lost one. The
|
||||
/// source is the subtler half, and was audit A5: pruning the moved node from
|
||||
/// the source's in-memory children looks sufficient while that entry stays
|
||||
/// resident, but the source's DB row is left `listed = 1`. Once it is
|
||||
/// evicted from the hot cache, or the daemon restarts, `ensure_children`
|
||||
/// rebuilds the listing from the DB and declares it complete, so anything
|
||||
/// else that changed remotely under the source since is never seen.
|
||||
///
|
||||
/// What this must *not* do is `forget` the node. Forgetting drops its
|
||||
/// `by_uid` mapping, so the re-enumeration hands it a fresh inode — while
|
||||
/// the kernel has already carried the renamed dentry over to the *old* one.
|
||||
/// Every lookup through that dentry then resolves to an inode `entries` no
|
||||
/// longer holds and fails `ENOENT`, so the renamed directory reads as
|
||||
/// missing (`ls` on it errors while `ls` of its parent lists it) until the
|
||||
/// entry TTL expires. Keeping the inode keeps the kernel's dentry valid.
|
||||
///
|
||||
/// A pure rename (`from == to`) invalidates that one directory once.
|
||||
pub(crate) fn relocate(
|
||||
&mut self,
|
||||
ino: u64,
|
||||
from_parent: u64,
|
||||
to_parent: u64,
|
||||
to_parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
) {
|
||||
self.rename_in_place(ino, to_parent, to_parent_uid, name);
|
||||
self.invalidate_listing(to_parent);
|
||||
if from_parent != to_parent {
|
||||
self.invalidate_listing(from_parent);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a file entry's recorded plaintext size so `getattr` reflects an
|
||||
/// in-progress write before the new revision is sealed.
|
||||
pub(crate) fn set_size(&mut self, ino: u64, size: u64) {
|
||||
if let Some(e) = self.entries.get_mut(&ino)
|
||||
&& let NodeKind::File { claimed_size, .. } = &mut e.node.kind
|
||||
{
|
||||
*claimed_size = Some(size as i64);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a file entry's modification time (epoch seconds).
|
||||
pub(crate) fn touch_mtime(&mut self, ino: u64, secs: i64) {
|
||||
if let Some(e) = self.entries.get_mut(&ino) {
|
||||
e.node.modification_time = secs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the size and mtime of a write that has been accepted but not yet
|
||||
/// uploaded, persisting them like any other node mutation.
|
||||
///
|
||||
/// The write-through is what makes the new size outlive the process: until
|
||||
/// the op drains, the remote still holds the old revision (or, for a node
|
||||
/// created offline, nothing at all), so this row is the only record that the
|
||||
/// file is as long as the caller was told it is. Without it a restart serves
|
||||
/// the stale size and the file reads as truncated — or empty — while its
|
||||
/// bytes sit safely in staging (offline.md Phase 3).
|
||||
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) {
|
||||
self.set_size(ino, size);
|
||||
self.touch_mtime(ino, mtime);
|
||||
if let Some(e) = self.entries.get(&ino)
|
||||
&& let Err(err) = self.db.upsert_node(&e.node)
|
||||
{
|
||||
warn!(uid = %e.uid, error = %err, "db upsert_node failed for a queued write");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||
}
|
||||
|
||||
fn node(link: &str, parent: &str, name: &str, is_dir: bool) -> Node {
|
||||
Node {
|
||||
uid: uid(link),
|
||||
parent_uid: Some(uid(parent)),
|
||||
kind: if is_dir {
|
||||
NodeKind::Folder
|
||||
} else {
|
||||
NodeKind::File {
|
||||
media_type: "text/plain".into(),
|
||||
total_size_on_storage: 0,
|
||||
active_revision_state: None,
|
||||
claimed_size: Some(0),
|
||||
claimed_modification_time: None,
|
||||
}
|
||||
},
|
||||
name: name.into(),
|
||||
creation_time: 100,
|
||||
modification_time: 100,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency.
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pdfs-state-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Db::open_in_memory` is `#[cfg(test)]` inside `pdfs-core`, so it does not
|
||||
/// exist for this crate's tests — a temp file is the equivalent here. The
|
||||
/// directory outlives the state because the state holds the open database.
|
||||
fn state() -> (State, TempDir) {
|
||||
let dir = TempDir::new();
|
||||
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
||||
let st = State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino: 1,
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
db: Arc::new(db),
|
||||
};
|
||||
(st, dir)
|
||||
}
|
||||
|
||||
/// Two folders, each holding one file, both listed — the state a move
|
||||
/// starts from. Returns `(state, src_ino, dst_ino)`.
|
||||
fn two_folders() -> (State, TempDir, u64, u64) {
|
||||
let (mut st, dir) = state();
|
||||
let src = st.intern(0, node("src", "root", "src", true));
|
||||
let dst = st.intern(0, node("dst", "root", "dst", true));
|
||||
let f = st.intern(src, node("f", "src", "f.txt", false));
|
||||
st.children.insert(src, vec![f]);
|
||||
st.children.insert(dst, vec![]);
|
||||
st.db.set_listed(&uid("src"), true).unwrap();
|
||||
st.db.set_listed(&uid("dst"), true).unwrap();
|
||||
(st, dir, src, dst)
|
||||
}
|
||||
|
||||
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
||||
/// claiming a complete listing, so once the hot cache drops it, remote
|
||||
/// changes under it stop being seen. `relocate` has to clear both sides.
|
||||
#[test]
|
||||
fn relocate_invalidates_the_source_as_well_as_the_destination() {
|
||||
let (mut st, _dir, src, dst) = two_folders();
|
||||
let f = st.by_uid[&uid("f")];
|
||||
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
||||
|
||||
assert!(
|
||||
st.db.children_if_listed(&uid("dst")).unwrap().is_none(),
|
||||
"destination re-enumerates: it gained a child whose row was just deleted"
|
||||
);
|
||||
assert!(
|
||||
st.db.children_if_listed(&uid("src")).unwrap().is_none(),
|
||||
"source re-enumerates: its listing predates the move, and a stale \
|
||||
`listed` flag would hide every later remote change under it"
|
||||
);
|
||||
assert!(!st.children.contains_key(&src));
|
||||
assert!(!st.children.contains_key(&dst));
|
||||
assert_eq!(
|
||||
st.by_uid.get(&uid("f")),
|
||||
Some(&f),
|
||||
"the inode survives the move: the kernel has already pointed the \
|
||||
renamed dentry at it, so re-interning under a fresh one would make \
|
||||
every lookup through that dentry ENOENT"
|
||||
);
|
||||
assert_eq!(st.entries[&f].parent, dst);
|
||||
}
|
||||
|
||||
/// A pure rename never leaves the directory, so there is one listing to
|
||||
/// drop, not two — and dropping it must not depend on the parents differing.
|
||||
#[test]
|
||||
fn relocate_within_one_directory_still_invalidates_it() {
|
||||
let (mut st, _dir, src, _dst) = two_folders();
|
||||
let f = st.by_uid[&uid("f")];
|
||||
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
||||
assert!(st.db.children_if_listed(&uid("src")).unwrap().is_none());
|
||||
assert!(!st.children.contains_key(&src));
|
||||
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
||||
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
||||
}
|
||||
|
||||
/// The failure mode the fix exists for, stated directly: forgetting the node
|
||||
/// prunes it from the resident listing but leaves the DB claiming the source
|
||||
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
||||
#[test]
|
||||
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
||||
let (mut st, _dir, _src, _dst) = two_folders();
|
||||
st.forget(&uid("f"));
|
||||
let listed = st.db.children_if_listed(&uid("src")).unwrap();
|
||||
assert!(
|
||||
listed.is_some(),
|
||||
"forget does not clear the flag — which is exactly why relocate must"
|
||||
);
|
||||
assert!(
|
||||
listed.unwrap().is_empty(),
|
||||
"and the listing it would serve is the moved file's absence, \
|
||||
with no way to notice anything else changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
+703
-81
@@ -12,13 +12,13 @@
|
||||
//! action, so a file just uploaded or downloaded reads as "unchanged" on the
|
||||
//! next pass and never ping-pongs.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
@@ -30,8 +30,20 @@ use crate::{Core, now_secs, parse_uid};
|
||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||
use pdfs_core::db::{StoredSyncEntry, StoredSyncFolder};
|
||||
|
||||
/// Coalesce a burst of filesystem events into a single reconcile per folder.
|
||||
/// How long the watcher must go quiet before a burst counts as finished.
|
||||
///
|
||||
/// Measured from the *last* event, not the first: applications do not write a
|
||||
/// file once. An editor saves by writing a temp file and renaming it over the
|
||||
/// target; an export or a database dump writes continuously for as long as it
|
||||
/// takes. Settling a fixed interval after the first event walks the tree while
|
||||
/// the file is still growing, and uploads a torn snapshot as a real revision —
|
||||
/// corrected on the next pass, but only after spending the encrypt-and-upload
|
||||
/// on a file that was never in that state.
|
||||
const DEBOUNCE: Duration = Duration::from_secs(2);
|
||||
/// Ceiling on the settle wait, so a directory under sustained change still
|
||||
/// syncs. Without it, copying a large tree in — events arriving forever —
|
||||
/// would postpone the reconcile for as long as the copy ran.
|
||||
const MAX_SETTLE: Duration = Duration::from_secs(30);
|
||||
/// How often to re-walk remotes so changes made on other clients are pulled in.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(120);
|
||||
/// How many uploads/downloads/folder-creations a reconcile runs at once. The
|
||||
@@ -111,11 +123,7 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
||||
// Debounce: let a burst settle, then drain everything queued behind it.
|
||||
std::thread::sleep(DEBOUNCE);
|
||||
while let Ok(m) = rx.try_recv() {
|
||||
classify(m, &mut ids, &mut all, &mut do_rewatch);
|
||||
}
|
||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
||||
|
||||
if do_rewatch {
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
@@ -151,10 +159,7 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
let mut all = false;
|
||||
let mut do_rewatch = false;
|
||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
||||
std::thread::sleep(DEBOUNCE);
|
||||
while let Ok(m) = rx.try_recv() {
|
||||
classify(m, &mut ids, &mut all, &mut do_rewatch);
|
||||
}
|
||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
||||
if all {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
@@ -167,6 +172,46 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Absorb the rest of an event burst, returning once the watcher has been quiet
|
||||
/// for `quiet` or `cap` has elapsed since the first event — whichever comes
|
||||
/// first. The caller has already classified the event that opened the burst.
|
||||
///
|
||||
/// Split out with its timings as parameters so the settling behaviour can be
|
||||
/// tested in milliseconds rather than in the tens of seconds the real constants
|
||||
/// describe.
|
||||
fn settle_with(
|
||||
rx: &Receiver<SyncMsg>,
|
||||
ids: &mut HashSet<i64>,
|
||||
all: &mut bool,
|
||||
do_rewatch: &mut bool,
|
||||
quiet: Duration,
|
||||
cap: Duration,
|
||||
) {
|
||||
let deadline = Instant::now() + cap;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
// Never wait past the cap, however quiet the watcher goes.
|
||||
let wait = quiet.min(deadline - now);
|
||||
match rx.recv_timeout(wait) {
|
||||
// Another event: the burst is still going, so the quiet window
|
||||
// starts again from here.
|
||||
Ok(m) => classify(m, ids, all, do_rewatch),
|
||||
// Quiet for a full window: whatever was being written has stopped.
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
// Every sender is gone; the caller's loop is about to end anyway.
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`settle_with`] at the real timings.
|
||||
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
|
||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
|
||||
}
|
||||
|
||||
fn classify(msg: SyncMsg, ids: &mut HashSet<i64>, all: &mut bool, rewatch: &mut bool) {
|
||||
match msg {
|
||||
SyncMsg::Reconcile(id) => {
|
||||
@@ -209,7 +254,7 @@ fn rewatch(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut have = watched.lock().unwrap();
|
||||
let mut have = watched.lock();
|
||||
// Drop watches no longer wanted.
|
||||
have.retain(|(path, _)| {
|
||||
if want.iter().any(|(p, _)| p == path) {
|
||||
@@ -327,6 +372,9 @@ enum Pending {
|
||||
mtime: i64,
|
||||
size: i64,
|
||||
},
|
||||
/// Both sides changed during a [`push pass`](Core::push_pass): upload the local
|
||||
/// copy into `parent` under a conflict name, leaving the remote file as it is.
|
||||
PushConflict { rel: String, parent: NodeUid },
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
@@ -337,7 +385,8 @@ impl Pending {
|
||||
| Pending::UploadNew { rel, .. }
|
||||
| Pending::UploadRevision { rel, .. }
|
||||
| Pending::Download { rel, .. }
|
||||
| Pending::Conflict { rel, .. } => rel,
|
||||
| Pending::Conflict { rel, .. }
|
||||
| Pending::PushConflict { rel, .. } => rel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +394,9 @@ impl Pending {
|
||||
fn kind(&self) -> ActivityKind {
|
||||
match self {
|
||||
Pending::CreateDir { .. } => ActivityKind::CreateFolder,
|
||||
Pending::UploadNew { .. } | Pending::UploadRevision { .. } => ActivityKind::Upload,
|
||||
Pending::UploadNew { .. }
|
||||
| Pending::UploadRevision { .. }
|
||||
| Pending::PushConflict { .. } => ActivityKind::Upload,
|
||||
Pending::Download { .. } | Pending::Conflict { .. } => ActivityKind::Download,
|
||||
}
|
||||
}
|
||||
@@ -358,10 +409,59 @@ impl Pending {
|
||||
Pending::UploadRevision { .. } => "new version",
|
||||
Pending::Download { .. } => "from Drive",
|
||||
Pending::Conflict { .. } => "local changes kept as a conflict copy",
|
||||
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a full reconcile pass stopped without a result.
|
||||
enum PassAbort {
|
||||
/// An on-demand switch was queued while the pass was running. Everything left
|
||||
/// to do is work on a local copy about to be evicted, so the pass gives up its
|
||||
/// remaining work to a [`push pass`](Core::push_pass) instead. Not a failure —
|
||||
/// nothing is left half-applied, because each step is applied whole.
|
||||
Interrupted,
|
||||
/// The pass could not establish its diff (a walk or the baseline load failed).
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl From<String> for PassAbort {
|
||||
fn from(e: String) -> Self {
|
||||
PassAbort::Failed(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||
///
|
||||
/// A path in the baseline with no local copy is classified as "the user deleted
|
||||
/// this", and the pass answers by trashing it on Drive. That is right for one
|
||||
/// file and right for a handful. When it is true of *every* path the baseline
|
||||
/// knows, the likely cause is not a user: it is an unmounted external disk, a
|
||||
/// mountpoint whose FUSE mount died, a home directory that was not yet decrypted
|
||||
/// when the daemon started, or a bug in this client — and the answer to all of
|
||||
/// those is to trash the folder's entire contents on Drive.
|
||||
///
|
||||
/// So this pass abort exists to cap the blast radius rather than to catch a
|
||||
/// specific defect. Losing a pass to it costs a retry; the failure it prevents
|
||||
/// costs the folder. A user who really did empty the folder can express that by
|
||||
/// removing it from sync and re-adding it — the baseline goes with it.
|
||||
///
|
||||
/// One surviving path is enough to clear the guard, since the classification is
|
||||
/// only ever wrong about all of them at once.
|
||||
fn guard_local_wipe<B, L>(
|
||||
baseline: &HashMap<String, B>,
|
||||
local: &HashMap<String, L>,
|
||||
) -> Result<(), String> {
|
||||
if baseline.len() >= 2 && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
||||
return Err(format!(
|
||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
||||
them on Drive. Check that the folder is mounted and readable.",
|
||||
baseline.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||
/// engine thread (never inside a task).
|
||||
enum Applied {
|
||||
@@ -376,27 +476,42 @@ enum Applied {
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Reconcile one synced folder, updating its `state` column. `ondemand`
|
||||
/// folders are live FUSE mounts, not mirrored, so they are skipped here.
|
||||
/// Reconcile one synced folder, then apply any mode switch queued while it was
|
||||
/// busy. Both halves live here because the switch has to happen with the pass
|
||||
/// finished and its lock released, and the engine thread is the only place that
|
||||
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
||||
/// pass that may run for minutes.
|
||||
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
||||
if folder.mode != "mirror" {
|
||||
return;
|
||||
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
||||
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
||||
// so the settle below is not skipped with it.
|
||||
if folder.mode == "mirror" {
|
||||
self.reconcile_pass(folder);
|
||||
}
|
||||
self.settle_pending_mode(folder.id);
|
||||
}
|
||||
|
||||
/// One reconcile pass over a mirror folder, updating its `state` column.
|
||||
fn reconcile_pass(&self, folder: &StoredSyncFolder) {
|
||||
// Hold the folder's lock for the whole pass so a mode switch can't evict the
|
||||
// local tree (and mount FUSE over it) while we walk and upload it.
|
||||
let lock = self.sync_lock(folder.id);
|
||||
let _guard = lock.lock().unwrap();
|
||||
let _guard = lock.lock();
|
||||
// `folder` was read before the lock; a switch may have landed in between, so
|
||||
// re-read and re-check the mode rather than trusting the snapshot.
|
||||
match self.db.sync_folder_get(folder.id) {
|
||||
Ok(Some(current)) if current.mode == "mirror" => {}
|
||||
let current = match self.db.sync_folder_get(folder.id) {
|
||||
Ok(Some(current)) if current.mode == "mirror" => current,
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, error = ?e, "sync: cannot re-read folder; skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// A folder waiting to go on-demand is about to have its local copy deleted,
|
||||
// so pulling the remote side down is work whose only result is more to evict.
|
||||
// All that pass owes the user is getting local changes up.
|
||||
let mut push_only = current.pending_mode.as_deref() == Some("ondemand");
|
||||
let Some(remote_root) = parse_uid(&folder.remote_uid) else {
|
||||
warn!(id = folder.id, "sync: bad remote uid; skipping");
|
||||
return;
|
||||
@@ -415,16 +530,42 @@ impl Core {
|
||||
.sync_folder_set_state(folder.id, "syncing", folder.last_sync);
|
||||
let name = base_name(&folder.local_path);
|
||||
self.progress_begin(folder.id);
|
||||
let result = self.do_reconcile(folder.id, &local_root, &remote_root);
|
||||
let result = if push_only {
|
||||
self.push_pass(folder.id, &local_root, &remote_root)
|
||||
} else {
|
||||
match self.do_reconcile(folder.id, &local_root, &remote_root) {
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(PassAbort::Failed(e)) => Err(e),
|
||||
// The user asked for on-demand while this pass was running. Rather
|
||||
// than make them wait out a walk-and-download whose results are
|
||||
// about to be deleted, drop it here and do the only part that still
|
||||
// matters — getting local changes up — so the switch lands now
|
||||
// instead of after the pass and another poll.
|
||||
Err(PassAbort::Interrupted) => {
|
||||
info!(
|
||||
id = folder.id,
|
||||
"sync: on-demand queued mid-pass; pushing instead"
|
||||
);
|
||||
push_only = true;
|
||||
self.progress_begin(folder.id);
|
||||
self.push_pass(folder.id, &local_root, &remote_root)
|
||||
}
|
||||
}
|
||||
};
|
||||
self.progress_end(folder.id);
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
// A folder only reaches `idle` when every path applied cleanly —
|
||||
// an un-uploaded file must keep it out of `idle` so it can't be
|
||||
// switched to on-demand (which evicts the local copy).
|
||||
//
|
||||
// A push pass is the exception on conflicts: it resolves them by
|
||||
// uploading the local copy under a conflict name rather than leaving
|
||||
// one on disk, so nothing local is left needing attention. Parking it
|
||||
// in `conflict` would block the very switch the pass was run for.
|
||||
let state = if outcome.errors > 0 {
|
||||
"error"
|
||||
} else if outcome.conflicts > 0 {
|
||||
} else if outcome.conflicts > 0 && !push_only {
|
||||
"conflict"
|
||||
} else {
|
||||
"idle"
|
||||
@@ -478,6 +619,284 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a switch to on-demand is waiting on this folder — i.e. whether a
|
||||
/// pass in flight is still doing work worth doing. Read from the db rather than
|
||||
/// held in memory because the request arrives on the control-socket thread while
|
||||
/// the pass runs on the engine thread, and the db row is already the one place
|
||||
/// both agree on.
|
||||
fn ondemand_queued(&self, folder_id: i64) -> bool {
|
||||
matches!(
|
||||
self.db.sync_folder_get(folder_id),
|
||||
Ok(Some(f)) if f.pending_mode.as_deref() == Some("ondemand")
|
||||
)
|
||||
}
|
||||
|
||||
/// Get every local change up to Drive, and nothing else — the pass run for a
|
||||
/// folder waiting to go on-demand, whose local copy is about to be evicted.
|
||||
///
|
||||
/// This exists because a full [`do_reconcile`](Self::do_reconcile) is both too
|
||||
/// slow and too wasteful to stand between the user and the switch they asked
|
||||
/// for. It walks the whole remote tree (the minutes-long part of a pass over a
|
||||
/// large folder) to answer a question the switch makes irrelevant — what the
|
||||
/// remote side has that we don't — and then downloads those files onto a disk
|
||||
/// we are about to clear. A push pass answers only "is anything here not on
|
||||
/// Drive yet?", which the local tree and the baseline already know, so it needs
|
||||
/// no remote walk at all and finishes in the time the local walk takes.
|
||||
///
|
||||
/// The remote is consulted for exactly one thing: the files whose local copy
|
||||
/// changed. Those, and only those, could be a two-sided edit, so their nodes are
|
||||
/// fetched (cheaply, in one batch) to check whether the remote moved too. If it
|
||||
/// did, the local copy goes up under a conflict name rather than overwriting the
|
||||
/// other side. Everything else — remote edits, remote deletions, remote-only
|
||||
/// files — is left for the FUSE mount to show live once the switch lands.
|
||||
///
|
||||
/// Per-path failures are counted, never fatal, exactly as in `do_reconcile`; a
|
||||
/// non-zero [`Outcome::errors`] leaves the folder out of `idle`, which keeps the
|
||||
/// queued switch waiting rather than evicting a file that never made it up.
|
||||
fn push_pass(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
local_root: &Path,
|
||||
remote_root: &NodeUid,
|
||||
) -> Result<Outcome, String> {
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(folder_id)
|
||||
.map_err(|e| format!("load baseline: {e:?}"))?;
|
||||
// Only the local side is walked here, so each baseline path is checked once.
|
||||
self.progress_scan_total(folder_id, baseline.len());
|
||||
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
self.walk_local(folder_id, local_root, local_root, &mut local)?;
|
||||
guard_local_wipe(&baseline, &local)?;
|
||||
|
||||
// The remote folder uids come from the baseline instead of a walk: every
|
||||
// directory this device has synced recorded its uid there when it was
|
||||
// created or first matched up.
|
||||
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
||||
remote_dirs.insert(String::new(), remote_root.clone());
|
||||
for (rel, item) in &local {
|
||||
if !item.is_dir {
|
||||
continue;
|
||||
}
|
||||
if let Some(uid) = baseline
|
||||
.get(rel)
|
||||
.and_then(|e| e.remote_uid.as_deref())
|
||||
.and_then(parse_uid)
|
||||
{
|
||||
remote_dirs.insert(rel.clone(), uid);
|
||||
}
|
||||
}
|
||||
// A local directory the baseline has no uid for is either brand new or one
|
||||
// whose first pass never finished — and those look identical from here.
|
||||
// Creating it blindly would duplicate a remote folder we simply haven't
|
||||
// looked at, so when any exist, the remote folder tree is walked for real.
|
||||
// It costs one light listing per folder and no file keys, and a folder whose
|
||||
// baseline is complete — the case this pass is written for — skips it
|
||||
// entirely and touches the network only for the files it uploads.
|
||||
if local
|
||||
.iter()
|
||||
.any(|(rel, item)| item.is_dir && !remote_dirs.contains_key(rel))
|
||||
{
|
||||
self.walk_remote_dirs(folder_id, remote_root, "", &mut remote_dirs)?;
|
||||
}
|
||||
|
||||
// Files whose local copy has moved since the baseline: the only ones with
|
||||
// anything to push, and the only ones that could be a two-sided edit.
|
||||
let changed: Vec<&String> = local
|
||||
.iter()
|
||||
.filter(|(_, item)| !item.is_dir)
|
||||
.filter(|(rel, item)| {
|
||||
baseline
|
||||
.get(*rel)
|
||||
.is_none_or(|b| b.local_mtime != item.mtime || b.local_size != item.size)
|
||||
})
|
||||
.map(|(rel, _)| rel)
|
||||
.collect();
|
||||
|
||||
// One light batch tells us which of those also changed remotely. A light
|
||||
// node carries the modification time without unlocking the node key, and
|
||||
// mtime alone is what the baseline compares against.
|
||||
let check: Vec<NodeUid> = changed
|
||||
.iter()
|
||||
.filter_map(|rel| baseline.get(*rel))
|
||||
.filter_map(|e| e.remote_uid.as_deref())
|
||||
.filter_map(parse_uid)
|
||||
.collect();
|
||||
let remote_mtimes: HashMap<String, i64> = if check.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
self.rt
|
||||
.block_on(self.client.enumerate_nodes_light(&check))
|
||||
.map_err(|e| format!("resolve changed nodes: {e}"))?
|
||||
.into_iter()
|
||||
.filter(|n| !n.trashed)
|
||||
.map(|n| (n.uid.to_string(), n.modification_time))
|
||||
.collect()
|
||||
};
|
||||
let changed: HashSet<&String> = changed.into_iter().collect();
|
||||
|
||||
let mut outcome = Outcome::default();
|
||||
let mut order: Vec<&String> = local.keys().collect();
|
||||
order.sort_by_key(|p| p.matches('/').count());
|
||||
|
||||
// Same depth-ascending batching as `do_reconcile`: a folder is created
|
||||
// remotely before the paths inside it are classified, so their parent uid is
|
||||
// in `remote_dirs` by the time they need it.
|
||||
let mut batch: Vec<Pending> = Vec::new();
|
||||
let mut batch_depth = 0usize;
|
||||
for rel in order {
|
||||
let depth = rel.matches('/').count();
|
||||
if depth > batch_depth {
|
||||
self.flush_batch(
|
||||
folder_id,
|
||||
local_root,
|
||||
&mut remote_dirs,
|
||||
std::mem::take(&mut batch),
|
||||
&mut outcome,
|
||||
);
|
||||
batch_depth = depth;
|
||||
}
|
||||
let item = &local[rel];
|
||||
if item.is_dir {
|
||||
// The folder is already on Drive, so only its children carry work —
|
||||
// but if the walk above is what found it, record the uid now so the
|
||||
// next pass over this folder needs no walk at all.
|
||||
if let Some(uid) = remote_dirs.get(rel) {
|
||||
if baseline
|
||||
.get(rel)
|
||||
.and_then(|e| e.remote_uid.as_deref())
|
||||
.is_none()
|
||||
&& let Err(e) = self.baseline_dir(folder_id, rel, uid)
|
||||
{
|
||||
warn!(rel, error = %e, "sync: folder step failed; continuing");
|
||||
outcome.errors += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match remote_dirs.get(parent_rel(rel)) {
|
||||
Some(parent) => batch.push(Pending::CreateDir {
|
||||
rel: rel.clone(),
|
||||
parent: parent.clone(),
|
||||
}),
|
||||
None => {
|
||||
warn!(rel, "sync: no remote parent for folder; continuing");
|
||||
outcome.errors += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !changed.contains(rel) {
|
||||
// Local copy matches the baseline, so it is already on Drive.
|
||||
continue;
|
||||
}
|
||||
|
||||
let base = baseline.get(rel);
|
||||
let uid = base
|
||||
.and_then(|e| e.remote_uid.as_deref())
|
||||
.and_then(parse_uid);
|
||||
match uid {
|
||||
// Never uploaded (or the remote node is gone): it goes up as a new file.
|
||||
None => match remote_dirs.get(parent_rel(rel)) {
|
||||
Some(parent) => batch.push(Pending::UploadNew {
|
||||
rel: rel.clone(),
|
||||
parent: parent.clone(),
|
||||
}),
|
||||
None => {
|
||||
warn!(rel, "sync: no remote parent for file; continuing");
|
||||
outcome.errors += 1;
|
||||
}
|
||||
},
|
||||
Some(uid) => {
|
||||
let remote_moved = match remote_mtimes.get(&uid.to_string()) {
|
||||
Some(mtime) => base.and_then(remote_sig).is_none_or(|(m, _)| m != *mtime),
|
||||
// Trashed or unreadable remotely — treat as gone and re-upload
|
||||
// the local copy as a new file rather than losing it.
|
||||
None => {
|
||||
match remote_dirs.get(parent_rel(rel)) {
|
||||
Some(parent) => batch.push(Pending::UploadNew {
|
||||
rel: rel.clone(),
|
||||
parent: parent.clone(),
|
||||
}),
|
||||
None => {
|
||||
warn!(rel, "sync: no remote parent for file; continuing");
|
||||
outcome.errors += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if remote_moved {
|
||||
match remote_dirs.get(parent_rel(rel)) {
|
||||
Some(parent) => batch.push(Pending::PushConflict {
|
||||
rel: rel.clone(),
|
||||
parent: parent.clone(),
|
||||
}),
|
||||
None => {
|
||||
warn!(rel, "sync: no remote parent for file; continuing");
|
||||
outcome.errors += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
batch.push(Pending::UploadRevision {
|
||||
rel: rel.clone(),
|
||||
uid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.flush_batch(
|
||||
folder_id,
|
||||
local_root,
|
||||
&mut remote_dirs,
|
||||
std::mem::take(&mut batch),
|
||||
&mut outcome,
|
||||
);
|
||||
|
||||
// Paths the baseline knows that are no longer on disk: the user deleted them
|
||||
// locally, and the deletion has to reach Drive before the mount starts
|
||||
// showing the folder's remote contents — otherwise everything they deleted
|
||||
// comes back the moment the switch lands. Shallowest first, skipping anything
|
||||
// under a folder already trashed (its children went with it).
|
||||
let mut missing: Vec<&String> = baseline
|
||||
.keys()
|
||||
.filter(|rel| !local.contains_key(*rel))
|
||||
.collect();
|
||||
missing.sort_by_key(|p| p.matches('/').count());
|
||||
let mut trashed: Vec<String> = Vec::new();
|
||||
for rel in missing {
|
||||
if trashed
|
||||
.iter()
|
||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||
{
|
||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = baseline[rel].remote_uid.as_deref().and_then(parse_uid) else {
|
||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid])) {
|
||||
warn!(rel, error = %e, "sync: trash remote failed");
|
||||
self.log_activity(ActivityKind::Trash, base_name(rel), e.to_string(), false);
|
||||
outcome.errors += 1;
|
||||
continue;
|
||||
}
|
||||
trashed.push(rel.clone());
|
||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||
self.log_activity(
|
||||
ActivityKind::Trash,
|
||||
base_name(rel),
|
||||
"removed on Drive",
|
||||
true,
|
||||
);
|
||||
outcome.deleted += 1;
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// The diff-and-apply core. A single path failing to apply (a transient
|
||||
/// upload error, an unreadable file, a name collision) must not abort the
|
||||
/// whole pass and strand every other path — otherwise one bad file in a
|
||||
@@ -490,21 +909,33 @@ impl Core {
|
||||
folder_id: i64,
|
||||
local_root: &Path,
|
||||
remote_root: &NodeUid,
|
||||
) -> Result<Outcome, String> {
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
walk_local(local_root, local_root, &mut local)?;
|
||||
|
||||
// Loaded before the walk, which uses it to tell which remote files are
|
||||
// unchanged and so can skip decrypting their claimed size.
|
||||
) -> Result<Outcome, PassAbort> {
|
||||
// Loaded before the walks: the remote one uses it to tell which files are
|
||||
// unchanged and so can skip decrypting their claimed size, and its size is
|
||||
// the only estimate available of how much this pass has to check — the walks
|
||||
// discover the real figure only by finishing.
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(folder_id)
|
||||
.map_err(|e| format!("load baseline: {e:?}"))?;
|
||||
// Both sides are walked, so each baseline path is checked about twice.
|
||||
self.progress_scan_total(folder_id, baseline.len() * 2);
|
||||
|
||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||
self.walk_local(folder_id, local_root, local_root, &mut local)?;
|
||||
guard_local_wipe(&baseline, &local)?;
|
||||
|
||||
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
||||
remote_dirs.insert(String::new(), remote_root.clone());
|
||||
self.walk_remote(remote_root, "", &mut remote, &mut remote_dirs, &baseline)?;
|
||||
self.walk_remote(
|
||||
folder_id,
|
||||
remote_root,
|
||||
"",
|
||||
&mut remote,
|
||||
&mut remote_dirs,
|
||||
&baseline,
|
||||
)?;
|
||||
|
||||
// Union of every path across the three states, shallow paths first so a
|
||||
// parent folder is created before its children are placed inside it.
|
||||
@@ -545,6 +976,11 @@ impl Core {
|
||||
&mut outcome,
|
||||
);
|
||||
batch_depth = depth;
|
||||
// A depth boundary is the pass's natural checkpoint — everything
|
||||
// queued has been applied, so stopping here leaves nothing half done.
|
||||
if self.ondemand_queued(folder_id) {
|
||||
return Err(PassAbort::Interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
let l = local.get(rel);
|
||||
@@ -745,6 +1181,94 @@ impl Core {
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative
|
||||
/// path. Symlinks and other special files are skipped. Reports each entry to the
|
||||
/// pass's scan progress.
|
||||
fn walk_local(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
out: &mut HashMap<String, LocalItem>,
|
||||
) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Ok(stripped) = path.strip_prefix(root) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rel) = stripped.to_str() else {
|
||||
continue;
|
||||
};
|
||||
// Ignore our own in-flight download temp files.
|
||||
if rel.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
out.insert(
|
||||
rel.to_string(),
|
||||
LocalItem {
|
||||
is_dir: true,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
},
|
||||
);
|
||||
self.progress_scanned(folder_id, base_name(rel));
|
||||
self.walk_local(folder_id, root, &path, out)?;
|
||||
} else if meta.is_file() {
|
||||
out.insert(
|
||||
rel.to_string(),
|
||||
LocalItem {
|
||||
is_dir: false,
|
||||
mtime: system_mtime(&meta),
|
||||
size: meta.len() as i64,
|
||||
},
|
||||
);
|
||||
self.progress_scanned(folder_id, base_name(rel));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map every remote folder under `folder` to its uid by rel path, ignoring
|
||||
/// files. The cheap half of [`walk_remote`](Self::walk_remote): folders carry no
|
||||
/// content signature, so a light listing answers this without unlocking a single
|
||||
/// node key. Used by [`push_pass`](Self::push_pass) to tell a folder it must
|
||||
/// create from one it just hasn't recorded yet.
|
||||
fn walk_remote_dirs(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
folder: &NodeUid,
|
||||
prefix: &str,
|
||||
dirs: &mut HashMap<String, NodeUid>,
|
||||
) -> Result<(), String> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(folder))
|
||||
.map_err(|e| format!("enumerate {folder}: {e}"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes_light(&uids))
|
||||
.map_err(|e| format!("resolve nodes: {e}"))?;
|
||||
for node in nodes {
|
||||
if node.trashed || !node.is_folder() {
|
||||
continue;
|
||||
}
|
||||
let rel = join_rel(prefix, &node.name);
|
||||
self.progress_scanned(folder_id, base_name(&rel));
|
||||
dirs.insert(rel.clone(), node.uid.clone());
|
||||
self.walk_remote_dirs(folder_id, &node.uid, &rel, dirs)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively walk a remote folder into `out`, recording every descendant's
|
||||
/// relative path, and mapping each subfolder's rel path to its uid in `dirs`.
|
||||
///
|
||||
@@ -758,12 +1282,22 @@ impl Core {
|
||||
/// [`enumerate_nodes_light`]: proton_drive_rs::ProtonDriveClient::enumerate_nodes_light
|
||||
fn walk_remote(
|
||||
&self,
|
||||
folder_id: i64,
|
||||
folder: &NodeUid,
|
||||
prefix: &str,
|
||||
out: &mut HashMap<String, RemoteItem>,
|
||||
dirs: &mut HashMap<String, NodeUid>,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), PassAbort> {
|
||||
// Checked per folder, not per pass: this walk is the long pole on a large
|
||||
// tree, and a user who asks for on-demand three minutes into it should not
|
||||
// wait out the rest of a survey of files they want deleted. Bailing out here
|
||||
// is safe precisely because the walk has applied nothing — a *partial*
|
||||
// remote map, on the other hand, would be read as "the remote deleted
|
||||
// everything we haven't reached yet", so it must never reach the diff.
|
||||
if self.ondemand_queued(folder_id) {
|
||||
return Err(PassAbort::Interrupted);
|
||||
}
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(folder))
|
||||
@@ -804,6 +1338,7 @@ impl Core {
|
||||
continue;
|
||||
}
|
||||
let rel = join_rel(prefix, &node.name);
|
||||
self.progress_scanned(folder_id, base_name(&rel));
|
||||
if node.is_folder() {
|
||||
dirs.insert(rel.clone(), node.uid.clone());
|
||||
out.insert(
|
||||
@@ -815,7 +1350,7 @@ impl Core {
|
||||
size: 0,
|
||||
},
|
||||
);
|
||||
self.walk_remote(&node.uid, &rel, out, dirs, baseline)?;
|
||||
self.walk_remote(folder_id, &node.uid, &rel, out, dirs, baseline)?;
|
||||
} else {
|
||||
let size = if stale.contains(&node.uid.to_string()) {
|
||||
match sized.get(&node.uid.to_string()) {
|
||||
@@ -998,6 +1533,26 @@ impl Core {
|
||||
.await?;
|
||||
Ok(Applied::Conflict)
|
||||
}
|
||||
Pending::PushConflict { rel, parent } => {
|
||||
// The ordinary conflict resolution — rename the local copy aside and
|
||||
// let the next pass upload it — loses the file here: the folder is
|
||||
// going on-demand, so "next pass" comes after the local tree has been
|
||||
// evicted. Push the conflict copy now, while it still exists, and
|
||||
// leave the remote file holding the original path.
|
||||
let path = local_root.join(rel_to_path(rel));
|
||||
let conflict = conflict_path(&path, now_secs());
|
||||
std::fs::rename(&path, &conflict)
|
||||
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
||||
let name = conflict
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.ok_or_else(|| format!("conflict copy for {rel} has no name"))?;
|
||||
let conflict_rel = join_rel(parent_rel(rel), &name);
|
||||
self.upload_new(folder_id, local_root, &conflict_rel, parent)
|
||||
.await?;
|
||||
info!(rel, "sync: uploaded local changes as a conflict copy");
|
||||
Ok(Applied::Conflict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,7 +1579,11 @@ impl Core {
|
||||
);
|
||||
let uid = self
|
||||
.client
|
||||
.upload_file_from(
|
||||
// Mirror push: the local file is authoritative. If a prior attempt was
|
||||
// interrupted mid-upload it left an unsealed draft of this name; recover
|
||||
// it even across a daemon restart (which rotates our client uid), so the
|
||||
// folder can reach idle instead of `AlreadyExists`-looping forever.
|
||||
.upload_file_replacing_draft_from(
|
||||
parent,
|
||||
name,
|
||||
crate::media_type_for(name),
|
||||
@@ -1162,50 +1721,6 @@ impl Core {
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative
|
||||
/// path. Symlinks and other special files are skipped.
|
||||
fn walk_local(root: &Path, dir: &Path, out: &mut HashMap<String, LocalItem>) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Ok(stripped) = path.strip_prefix(root) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rel) = stripped.to_str() else {
|
||||
continue;
|
||||
};
|
||||
// Ignore our own in-flight download temp files.
|
||||
if rel.contains(".pdfs-tmp-") {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
out.insert(
|
||||
rel.to_string(),
|
||||
LocalItem {
|
||||
is_dir: true,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
},
|
||||
);
|
||||
walk_local(root, &path, out)?;
|
||||
} else if meta.is_file() {
|
||||
out.insert(
|
||||
rel.to_string(),
|
||||
LocalItem {
|
||||
is_dir: false,
|
||||
mtime: system_mtime(&meta),
|
||||
size: meta.len() as i64,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A remote node's `(mtime, size)` change signature. Size prefers the plaintext
|
||||
/// `claimed_size`; otherwise the encrypted storage size (stable either way, and
|
||||
/// only ever compared against the same node's own baseline).
|
||||
@@ -1273,7 +1788,7 @@ fn parent_rel(rel: &str) -> &str {
|
||||
}
|
||||
|
||||
/// The final component of a `/`-joined relative path.
|
||||
fn base_name(rel: &str) -> &str {
|
||||
pub(crate) fn base_name(rel: &str) -> &str {
|
||||
match rel.rfind('/') {
|
||||
Some(i) => &rel[i + 1..],
|
||||
None => rel,
|
||||
@@ -1304,6 +1819,113 @@ fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
|
||||
/// immediately. Returns the sender's join handle so the test can keep it
|
||||
/// alive (dropping the sender early would look like a disconnect).
|
||||
fn drip(n: usize, gap: Duration) -> (Receiver<SyncMsg>, std::thread::JoinHandle<()>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let h = std::thread::spawn(move || {
|
||||
for i in 0..n {
|
||||
if tx.send(SyncMsg::Reconcile(i as i64)).is_err() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(gap);
|
||||
}
|
||||
});
|
||||
(rx, h)
|
||||
}
|
||||
|
||||
fn collect(rx: &Receiver<SyncMsg>, quiet: Duration, cap: Duration) -> (HashSet<i64>, Duration) {
|
||||
let mut ids = HashSet::new();
|
||||
let (mut all, mut rewatch) = (false, false);
|
||||
// The caller of `settle` has always classified the opening event first.
|
||||
let first = rx.recv().unwrap();
|
||||
classify(first, &mut ids, &mut all, &mut rewatch);
|
||||
let started = Instant::now();
|
||||
settle_with(rx, &mut ids, &mut all, &mut rewatch, quiet, cap);
|
||||
(ids, started.elapsed())
|
||||
}
|
||||
|
||||
/// The bug this replaced a fixed sleep for: a save that keeps writing past
|
||||
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
|
||||
/// real revision. The window has to restart on every event, so settling
|
||||
/// waits for the writer to actually stop.
|
||||
#[test]
|
||||
fn settling_waits_for_the_last_event_not_the_first() {
|
||||
// Eight events 20ms apart — a burst lasting ~160ms, far longer than the
|
||||
// 60ms quiet window a fixed sleep would have used.
|
||||
let (rx, h) = drip(8, Duration::from_millis(20));
|
||||
let (ids, elapsed) = collect(&rx, Duration::from_millis(60), Duration::from_secs(30));
|
||||
h.join().unwrap();
|
||||
|
||||
assert_eq!(ids.len(), 8, "every event in the burst is absorbed");
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(120),
|
||||
"settling returned after {elapsed:?}: it stopped waiting while events \
|
||||
were still arriving, which is the torn-revision bug"
|
||||
);
|
||||
}
|
||||
|
||||
/// A burst that ends is not waited on any longer than the quiet window.
|
||||
#[test]
|
||||
fn settling_returns_once_the_burst_stops() {
|
||||
let (rx, h) = drip(3, Duration::from_millis(5));
|
||||
let (ids, elapsed) = collect(&rx, Duration::from_millis(50), Duration::from_secs(30));
|
||||
h.join().unwrap();
|
||||
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"a finished burst must settle on the quiet window, not the cap"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sustained change — copying a large tree in — must not postpone the
|
||||
/// reconcile for as long as the copy runs. The cap ends the wait.
|
||||
#[test]
|
||||
fn settling_is_capped_under_continuous_change() {
|
||||
// Events every 10ms for far longer than the 150ms cap allows.
|
||||
let (rx, h) = drip(200, Duration::from_millis(10));
|
||||
let (_ids, elapsed) = collect(&rx, Duration::from_millis(100), Duration::from_millis(150));
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"settled after {elapsed:?}; the cap should have ended the wait near 150ms"
|
||||
);
|
||||
drop(rx);
|
||||
h.join().unwrap();
|
||||
}
|
||||
|
||||
/// The guard that keeps a vanished local tree (dead mount, unplugged disk)
|
||||
/// from being read as "the user deleted everything" and trashing the folder
|
||||
/// on Drive.
|
||||
#[test]
|
||||
fn guard_local_wipe_blocks_only_a_total_disappearance() {
|
||||
let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"]
|
||||
.iter()
|
||||
.map(|r| ((*r).to_string(), ()))
|
||||
.collect();
|
||||
let none: HashMap<String, ()> = HashMap::new();
|
||||
let one: HashMap<String, ()> = [("b.txt".to_string(), ())].into_iter().collect();
|
||||
|
||||
assert!(
|
||||
guard_local_wipe(&base, &none).is_err(),
|
||||
"wholesale loss aborts"
|
||||
);
|
||||
assert!(
|
||||
guard_local_wipe(&base, &one).is_ok(),
|
||||
"one survivor means the tree is readable; the rest are real deletions"
|
||||
);
|
||||
// A one-entry baseline is a genuine single delete, not a wipe to detect.
|
||||
let single: HashMap<String, ()> = [("a.txt".to_string(), ())].into_iter().collect();
|
||||
assert!(guard_local_wipe(&single, &none).is_ok());
|
||||
// Nothing synced yet: a first pass over an empty folder is not a wipe.
|
||||
assert!(guard_local_wipe(&none, &none).is_ok());
|
||||
// Local paths that are not in the baseline are new uploads, and do not
|
||||
// count as survivors.
|
||||
let fresh: HashMap<String, ()> = [("new.txt".to_string(), ())].into_iter().collect();
|
||||
assert!(guard_local_wipe(&base, &fresh).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_and_base_split_relative_paths() {
|
||||
assert_eq!(parent_rel("a"), "");
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
//!
|
||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||
@@ -76,7 +77,7 @@ impl TransferRegistry {
|
||||
done: AtomicU64::new(0),
|
||||
started: Instant::now(),
|
||||
});
|
||||
self.inner.lock().unwrap().insert(id, entry.clone());
|
||||
self.inner.lock().insert(id, entry.clone());
|
||||
TransferGuard {
|
||||
reg: self.clone(),
|
||||
id,
|
||||
@@ -95,7 +96,7 @@ impl TransferRegistry {
|
||||
done: AtomicU64::new(0),
|
||||
total: AtomicU64::new(0),
|
||||
});
|
||||
self.jobs.lock().unwrap().insert(id, entry.clone());
|
||||
self.jobs.lock().insert(id, entry.clone());
|
||||
JobGuard {
|
||||
reg: self.clone(),
|
||||
id,
|
||||
@@ -108,7 +109,7 @@ impl TransferRegistry {
|
||||
///
|
||||
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
|
||||
pub fn jobs_snapshot(&self) -> Vec<JobItem> {
|
||||
let map = self.jobs.lock().unwrap();
|
||||
let map = self.jobs.lock();
|
||||
let mut ids: Vec<_> = map.keys().copied().collect();
|
||||
ids.sort_unstable();
|
||||
ids.iter()
|
||||
@@ -116,7 +117,7 @@ impl TransferRegistry {
|
||||
let e = &map[id];
|
||||
JobItem {
|
||||
title: e.title.clone(),
|
||||
detail: e.detail.lock().unwrap().clone(),
|
||||
detail: e.detail.lock().clone(),
|
||||
done: e.done.load(Ordering::Relaxed),
|
||||
total: e.total.load(Ordering::Relaxed),
|
||||
}
|
||||
@@ -131,7 +132,7 @@ impl TransferRegistry {
|
||||
///
|
||||
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
|
||||
pub fn snapshot(&self) -> Vec<TransferItem> {
|
||||
let map = self.inner.lock().unwrap();
|
||||
let map = self.inner.lock();
|
||||
let mut ids: Vec<_> = map.keys().copied().collect();
|
||||
ids.sort_unstable();
|
||||
ids.iter()
|
||||
@@ -176,7 +177,7 @@ impl TransferGuard {
|
||||
|
||||
impl Drop for TransferGuard {
|
||||
fn drop(&mut self) {
|
||||
self.reg.inner.lock().unwrap().remove(&self.id);
|
||||
self.reg.inner.lock().remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +198,7 @@ pub struct JobGuard {
|
||||
impl JobGuard {
|
||||
/// Say what the job is working on right now; empty clears it.
|
||||
pub fn detail(&self, what: impl Into<String>) {
|
||||
*self.entry.detail.lock().unwrap() = what.into();
|
||||
*self.entry.detail.lock() = what.into();
|
||||
}
|
||||
|
||||
/// Set how many steps the job now knows it has. Safe to raise mid-job as more
|
||||
@@ -214,7 +215,7 @@ impl JobGuard {
|
||||
|
||||
impl Drop for JobGuard {
|
||||
fn drop(&mut self) {
|
||||
self.reg.jobs.lock().unwrap().remove(&self.id);
|
||||
self.reg.jobs.lock().remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! The thread pool that serves the FUSE handlers which touch the network.
|
||||
//!
|
||||
//! fuser drives `Filesystem` from a single dispatch loop, so a cold read that
|
||||
//! goes to the wire would block every cheap metadata call behind it. Those
|
||||
//! handlers hand their work here instead and reply from the worker.
|
||||
//!
|
||||
//! The pool is split into two lanes because moving the work off the dispatch
|
||||
//! loop is not by itself enough: with one shared queue, eight concurrent block
|
||||
//! downloads occupy every worker, and a `lookup` that needs one network round
|
||||
//! trip waits behind megabytes of transfer (audit A6). Some threads are
|
||||
//! therefore reserved for metadata and never accept a transfer.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use tracing::warn;
|
||||
|
||||
/// How many FUSE handlers may run off the dispatch loop at once.
|
||||
///
|
||||
/// `fuser`'s session loop is non-concurrent: it reads one request, calls the
|
||||
/// handler, and only then reads the next. A handler that touches the network
|
||||
/// therefore stalls every `getattr`/`lookup` on the mount behind it. The slow
|
||||
/// handlers hand their `Reply` to this pool instead and answer from a worker,
|
||||
/// which frees the loop immediately.
|
||||
///
|
||||
/// Bounded on purpose: one worker can hold a 4 MiB block in flight, and an
|
||||
/// unbounded pool would let read-ahead on a big file spawn threads without
|
||||
/// limit. Sized so the SDK's in-flight block semaphore, not thread count, is
|
||||
/// what bounds download memory.
|
||||
///
|
||||
/// Counted as `META_WORKERS` reserved threads *plus* the eight that transfers
|
||||
/// had before the lanes were split. Taking the reservation out of the original
|
||||
/// eight instead would have cut concurrent reads to five — a throughput
|
||||
/// regression smuggled in with a latency fix. Threads are the cheap resource
|
||||
/// here; block buffers are the expensive one, and those are capped in the SDK.
|
||||
pub(crate) const FUSE_WORKERS: usize = 11;
|
||||
|
||||
/// How many of [`FUSE_WORKERS`] serve metadata *only*.
|
||||
///
|
||||
/// This is the whole guarantee: these threads never accept a [`Lane::Transfer`]
|
||||
/// job, so no number of concurrent downloads can leave a `lookup` or `readdir`
|
||||
/// without a thread to run on. Three is enough because metadata jobs are short
|
||||
/// — one round trip, no block fetch — so they queue behind each other briefly
|
||||
/// rather than for the length of a transfer.
|
||||
///
|
||||
/// The remaining workers are general: they prefer transfers and fall back to
|
||||
/// metadata when there is no transfer waiting. That direction is safe (a
|
||||
/// general thread picking up a cheap job frees itself again quickly) while the
|
||||
/// reverse — metadata threads helping with transfers — would reintroduce
|
||||
/// exactly the blocking this split exists to prevent.
|
||||
const META_WORKERS: usize = 3;
|
||||
|
||||
type Job = Box<dyn FnOnce() + Send + 'static>;
|
||||
|
||||
/// Which lane a job belongs in.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Lane {
|
||||
/// Short, network-bound metadata work: `lookup`, `readdir`. Guaranteed a
|
||||
/// thread by the reserved workers.
|
||||
Meta,
|
||||
/// Bulk data movement: block reads. May occupy every general worker.
|
||||
Transfer,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Queues {
|
||||
meta: VecDeque<Job>,
|
||||
transfer: VecDeque<Job>,
|
||||
/// Set when the pool is dropped, to wake and retire every worker.
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// The queues and the two wait sets over them.
|
||||
///
|
||||
/// Two condvars, not one, because the workers are not interchangeable: a
|
||||
/// meta-only thread cannot take a transfer. With a single wait set,
|
||||
/// `notify_one` for a transfer could wake a meta-only thread, which would find
|
||||
/// nothing it is allowed to run and go back to sleep — leaving the transfer
|
||||
/// unclaimed while a general worker sat idle. Waking *every* thread instead
|
||||
/// would work but costs a thundering herd per job. So each lane notifies only
|
||||
/// threads that can actually serve it.
|
||||
struct Pool {
|
||||
queues: Mutex<Queues>,
|
||||
/// Waited on by the reserved metadata workers.
|
||||
meta_cv: Condvar,
|
||||
/// Waited on by the general workers.
|
||||
general_cv: Condvar,
|
||||
}
|
||||
|
||||
/// Bounded thread pool behind the network-touching FUSE handlers.
|
||||
///
|
||||
/// Shared by every session forked off one [`Core`] (the main mount plus each
|
||||
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
||||
pub(crate) struct Workers {
|
||||
inner: Arc<Pool>,
|
||||
}
|
||||
|
||||
impl Workers {
|
||||
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
|
||||
let inner = Arc::new(Pool {
|
||||
queues: Mutex::new(Queues::default()),
|
||||
meta_cv: Condvar::new(),
|
||||
general_cv: Condvar::new(),
|
||||
});
|
||||
// Never leave the pool without a general worker, however small `n` is.
|
||||
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
||||
for i in 0..n {
|
||||
let inner = inner.clone();
|
||||
let meta_only = i < meta_workers;
|
||||
std::thread::Builder::new()
|
||||
.name(format!(
|
||||
"pdfs-fuse-{}{i}",
|
||||
if meta_only { "meta-" } else { "" }
|
||||
))
|
||||
.spawn(move || {
|
||||
let cv = if meta_only {
|
||||
&inner.meta_cv
|
||||
} else {
|
||||
&inner.general_cv
|
||||
};
|
||||
loop {
|
||||
let mut q = inner.queues.lock();
|
||||
let job = loop {
|
||||
// A general worker takes transfers first: metadata
|
||||
// already has threads of its own, so draining the
|
||||
// bulk queue is the useful thing for it to do.
|
||||
let picked = if meta_only {
|
||||
q.meta.pop_front()
|
||||
} else {
|
||||
q.transfer.pop_front().or_else(|| q.meta.pop_front())
|
||||
};
|
||||
if let Some(job) = picked {
|
||||
break Some(job);
|
||||
}
|
||||
if q.closed {
|
||||
break None;
|
||||
}
|
||||
cv.wait(&mut q);
|
||||
};
|
||||
drop(q);
|
||||
let Some(job) = job else { break };
|
||||
// A panicking handler must not cost the pool a worker
|
||||
// for the rest of the run. The dropped `Reply` answers
|
||||
// EIO on its own, so the caller of the failed op is
|
||||
// told; the next job is unaffected.
|
||||
//
|
||||
// This only holds while no shared state a job touches
|
||||
// sits behind a *poisoning* lock. A `std::sync::Mutex`
|
||||
// held at the point of the panic comes back poisoned,
|
||||
// and the worker we just rescued dies on its next
|
||||
// acquisition — as does every other thread. The rescue
|
||||
// would then convert one recoverable EIO into a
|
||||
// permanently broken daemon: strictly worse than not
|
||||
// catching at all.
|
||||
//
|
||||
// This crate's state is behind `parking_lot`, which
|
||||
// does not poison. **The requirement crosses the crate
|
||||
// boundary**, though: a job runs deep into
|
||||
// `proton-sdk`, whose entity cache is a `std` mutex. It
|
||||
// recovers the guard rather than unwrapping (see
|
||||
// `InMemoryCacheRepository::state`); its session and
|
||||
// HTTP state are `tokio::sync::Mutex`, which has no
|
||||
// poisoning at all. Anything new reached from a job owes
|
||||
// the same check — an SDK-side `.lock().unwrap()`
|
||||
// silently invalidates this comment.
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// Queue `job` in `lane`.
|
||||
///
|
||||
/// Neither queue is bounded, and deliberately so: the only thing that could
|
||||
/// apply backpressure here is the fuser dispatch loop, and blocking *that*
|
||||
/// is the stall this module exists to avoid. Queue depth is cheap anyway —
|
||||
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
|
||||
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
|
||||
/// semaphore is what bounds how many of those exist at once.
|
||||
pub(crate) fn run(&self, lane: Lane, job: impl FnOnce() + Send + 'static) {
|
||||
let mut q = self.inner.queues.lock();
|
||||
if q.closed {
|
||||
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
||||
// rather than a mount that answers every read with EIO.
|
||||
drop(q);
|
||||
warn!("fuse worker pool is gone; serving inline");
|
||||
job();
|
||||
return;
|
||||
}
|
||||
match lane {
|
||||
Lane::Meta => q.meta.push_back(Box::new(job)),
|
||||
Lane::Transfer => q.transfer.push_back(Box::new(job)),
|
||||
}
|
||||
drop(q);
|
||||
match lane {
|
||||
// Either class can serve metadata, and only one of them needs to:
|
||||
// whichever wakes first takes it, and the other finds the queue
|
||||
// empty and waits again.
|
||||
Lane::Meta => {
|
||||
self.inner.meta_cv.notify_one();
|
||||
self.inner.general_cv.notify_one();
|
||||
}
|
||||
// Only general workers may take a transfer, so waking a reserved
|
||||
// one would be the lost wakeup this split is careful to avoid.
|
||||
Lane::Transfer => {
|
||||
self.inner.general_cv.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Workers {
|
||||
fn drop(&mut self) {
|
||||
self.inner.queues.lock().closed = true;
|
||||
self.inner.meta_cv.notify_all();
|
||||
self.inner.general_cv.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Audit A6. Transfers filling every general worker must not delay a
|
||||
/// metadata job: that is what the reserved threads are for.
|
||||
#[test]
|
||||
fn saturated_transfers_do_not_block_metadata() {
|
||||
let pool = Workers::new(FUSE_WORKERS).unwrap();
|
||||
let general = FUSE_WORKERS - META_WORKERS;
|
||||
|
||||
// Occupy every general worker with a transfer that will not finish
|
||||
// until we say so, and wait until they are all actually running.
|
||||
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||
let release_rx = Arc::new(Mutex::new(release_rx));
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
for _ in 0..general {
|
||||
let release_rx = release_rx.clone();
|
||||
let started_tx = started_tx.clone();
|
||||
pool.run(Lane::Transfer, move || {
|
||||
started_tx.send(()).unwrap();
|
||||
let _ = release_rx.lock().recv();
|
||||
});
|
||||
}
|
||||
for _ in 0..general {
|
||||
started_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("every general worker picks up a transfer");
|
||||
}
|
||||
|
||||
// Queue more transfers than there are threads, so the lane is backed up
|
||||
// in the way that used to starve metadata.
|
||||
for _ in 0..16 {
|
||||
let release_rx = release_rx.clone();
|
||||
pool.run(Lane::Transfer, move || {
|
||||
let _ = release_rx.lock().recv();
|
||||
});
|
||||
}
|
||||
|
||||
let (meta_tx, meta_rx) = mpsc::channel();
|
||||
pool.run(Lane::Meta, move || meta_tx.send(()).unwrap());
|
||||
meta_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("a metadata job runs while every transfer thread is stuck");
|
||||
|
||||
for _ in 0..(general + 16) {
|
||||
let _ = release_tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
/// The reverse direction is allowed and matters for throughput: with no
|
||||
/// transfers in flight, a general worker serves metadata rather than idling.
|
||||
#[test]
|
||||
fn general_workers_fall_back_to_metadata() {
|
||||
// One general worker and no reserved ones, so anything that completes
|
||||
// must have been served by the general worker.
|
||||
let pool = Workers::new(1).unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
||||
rx.recv_timeout(Duration::from_secs(5))
|
||||
.expect("a general worker takes metadata when no transfer is waiting");
|
||||
}
|
||||
|
||||
/// A panicking job costs its caller an EIO, never the worker.
|
||||
#[test]
|
||||
fn a_panicking_job_does_not_retire_its_worker() {
|
||||
let pool = Workers::new(1).unwrap();
|
||||
pool.run(Lane::Meta, || panic!("boom"));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
||||
rx.recv_timeout(Duration::from_secs(5))
|
||||
.expect("the pool still serves work after a panic");
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "pdfs-app"
|
||||
path = "src/app.rs"
|
||||
path = "src/app/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "pdfs-prompt"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,311 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct ActivityState {
|
||||
// Activity page: one section, newest-first, rebuilt wholesale on each load.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
|
||||
/// page polls every couple of seconds and usually gets back exactly what it
|
||||
/// is already showing; rebuilding all 200 rows for that would throw away the
|
||||
/// user's scroll position several times a minute.
|
||||
pub(crate) key: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
/// Widgets the Activity page's load/repaint touch.
|
||||
pub(crate) struct ActivityWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Activity page: a newest-first feed of the mutations and transfers the
|
||||
/// daemon performed this session (uploads, deletes, shares, …).
|
||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Activity")
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let refresh = refresh_button();
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
header.append(&title);
|
||||
header.append(&refresh);
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("document-open-recent-symbolic")
|
||||
.vexpand(true)
|
||||
.child(&retry)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&content);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
ActivityWidgets {
|
||||
content,
|
||||
status,
|
||||
group,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Activity page's retry button.
|
||||
pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_activity(&ui_retry);
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Activity list.
|
||||
pub(crate) fn activity_status(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
retry: bool,
|
||||
) {
|
||||
ui.activity.status.set_icon_name(Some(icon));
|
||||
ui.activity.status.set_title(title);
|
||||
ui.activity.status.set_description(Some(description));
|
||||
ui.activity.retry.set_visible(retry);
|
||||
ui.activity.content.set_visible_child_name("status");
|
||||
// The list is no longer what is on screen, so the next repaint must not skip
|
||||
// itself as a no-op and leave this status view up.
|
||||
*ui.activity.key.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Refresh the Activity feed in place, with no status flash and no spinner.
|
||||
/// Driven by the periodic tick while the page is on screen, so a running sync
|
||||
/// pass fills the feed as it works rather than only once it is done. Anything
|
||||
/// other than a good answer leaves the rows alone until the next tick.
|
||||
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
||||
if ui.activity.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.activity.inflight.set(true);
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::ListActivity { limit: 200 },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.activity.inflight.set(false);
|
||||
// The page may have been navigated away from while the request was in
|
||||
// flight.
|
||||
if let Ok(Ok(Response::Activity { items })) = result
|
||||
&& ui.stack.visible_child_name().as_deref() == Some("activity")
|
||||
{
|
||||
repaint_activity(&ui, &items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
if ui.activity.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.activity.inflight.set(true);
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
"Loading…",
|
||||
"Reading recent activity.",
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::ListActivity { limit: 200 },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.activity.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items),
|
||||
Ok(Ok(Response::Error { message, .. })) => activity_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read activity",
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => activity_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read activity",
|
||||
"Unexpected reply from the mount service.",
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Activity page.
|
||||
pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
activity_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
activity_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("activity") {
|
||||
load_activity(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A cheap fingerprint of a feed: how many entries, and what the newest one is.
|
||||
/// The log is append-only and newest-first, so a feed with the same length and
|
||||
/// the same newest entry is the same feed.
|
||||
pub(crate) fn activity_key(items: &[ActivityEntry]) -> String {
|
||||
match items.first() {
|
||||
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the Activity section from a fresh log, unless it is already showing
|
||||
/// exactly this feed.
|
||||
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
let key = activity_key(items);
|
||||
if ui.activity.key.borrow().as_deref() == Some(key.as_str()) {
|
||||
return;
|
||||
}
|
||||
*ui.activity.key.borrow_mut() = Some(key);
|
||||
for row in ui.activity.rows.borrow_mut().drain(..) {
|
||||
ui.activity.group.remove(&row);
|
||||
}
|
||||
if items.is_empty() {
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
"Nothing yet",
|
||||
"Uploads, moves, shares and other changes appear here as they happen.",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.activity.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for a in items {
|
||||
let mut subtitle = activity_time(a.time);
|
||||
if !a.detail.is_empty() {
|
||||
subtitle = format!("{subtitle} · {}", a.detail);
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(format!("{} {}", activity_verb(a.kind), a.target))
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
let icon = if a.ok {
|
||||
activity_icon(a.kind)
|
||||
} else {
|
||||
"dialog-warning-symbolic"
|
||||
};
|
||||
row.add_prefix(>k4::Image::from_icon_name(icon));
|
||||
row.set_activatable(false);
|
||||
ui.activity.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*ui.activity.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// A human verb for an activity kind, used as the row title's lead word.
|
||||
pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
match kind {
|
||||
ActivityKind::Upload => "Uploaded",
|
||||
ActivityKind::Download => "Downloaded",
|
||||
ActivityKind::Sync => "Synced",
|
||||
ActivityKind::Rename => "Renamed",
|
||||
ActivityKind::Move => "Moved",
|
||||
ActivityKind::CreateFolder => "Created folder",
|
||||
ActivityKind::Trash => "Trashed",
|
||||
ActivityKind::Restore => "Restored",
|
||||
ActivityKind::DeleteForever => "Deleted",
|
||||
ActivityKind::EmptyTrash => "Emptied trash —",
|
||||
ActivityKind::Share => "Shared",
|
||||
ActivityKind::PublicLink => "Linked",
|
||||
ActivityKind::Unshare => "Unshared",
|
||||
}
|
||||
}
|
||||
|
||||
/// A themed icon for an activity kind.
|
||||
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
match kind {
|
||||
ActivityKind::Upload => "document-send-symbolic",
|
||||
ActivityKind::Download => "document-save-symbolic",
|
||||
ActivityKind::Sync => "emblem-synchronizing-symbolic",
|
||||
ActivityKind::Rename => "document-edit-symbolic",
|
||||
ActivityKind::Move => "go-jump-symbolic",
|
||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
|
||||
"user-trash-symbolic"
|
||||
}
|
||||
ActivityKind::Restore => "edit-undo-symbolic",
|
||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an epoch-seconds timestamp for the Activity feed, in local time.
|
||||
pub(crate) fn activity_time(secs: i64) -> String {
|
||||
glib::DateTime::from_unix_local(secs)
|
||||
.and_then(|dt| dt.format("%b %-d, %H:%M"))
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,724 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct DevicesState {
|
||||
// Devices page: two sections (synced folders + other devices), rebuilt
|
||||
// wholesale on each load.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// Widgets the Devices page's load/repaint touch.
|
||||
pub(crate) struct DevicesWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
/// "This computer" — the local folders synced to this machine's device.
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
/// "Other computers" — the account's *other* registered devices. This
|
||||
/// machine's own device is deliberately not among them; see
|
||||
/// [`repaint_devices`].
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) add_folder: gtk4::Button,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Computers page: a "This computer" section listing the local folders synced
|
||||
/// to this machine's device (each row offering a mode toggle and Remove), plus an
|
||||
/// "Other devices" section listing the account's other registered devices.
|
||||
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Computers")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.label("Add Folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&add_folder);
|
||||
|
||||
// The description spells out what the per-row On-demand switch does to the
|
||||
// local copy, because the switch itself can't: turning it on *deletes* the
|
||||
// files from this disk, which is not something to discover afterwards.
|
||||
let sync_group = adw::PreferencesGroup::builder()
|
||||
.title("This computer")
|
||||
.description(
|
||||
"Local folders backed up to this device. Synced folders keep a full copy on \
|
||||
this disk; on-demand folders keep the files in Proton Drive only and fetch \
|
||||
them as you open them.",
|
||||
)
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Other computers")
|
||||
.description("Other devices backing up to this account.")
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&sync_group);
|
||||
groups.append(&group);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("computer-symbolic")
|
||||
.vexpand(true)
|
||||
.child(&retry)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&content);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
DevicesWidgets {
|
||||
content,
|
||||
status,
|
||||
sync_group,
|
||||
group,
|
||||
add_folder,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Devices page's retry button and the "Add Folder" action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_devices(&ui_retry);
|
||||
});
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Devices list.
|
||||
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.devices.status.set_icon_name(Some(icon));
|
||||
ui.devices.status.set_title(title);
|
||||
ui.devices.status.set_description(Some(description));
|
||||
ui.devices.retry.set_visible(retry);
|
||||
ui.devices.content.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Fetch this machine's synced folders and the account's other devices, then
|
||||
/// repaint both sections. The two requests are chained so a single unreachable
|
||||
/// daemon collapses the whole page to a status view.
|
||||
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
if ui.devices.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.devices.inflight.set(true);
|
||||
devices_status(
|
||||
ui,
|
||||
"computer-symbolic",
|
||||
"Loading…",
|
||||
"Reading your devices.",
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
// The two lists are independent and the daemon serves requests concurrently,
|
||||
// so fire both up front and collect them, rather than paying two round trips
|
||||
// back to back.
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let sync = rx.recv().await;
|
||||
let devices_reply = rx2.recv().await;
|
||||
let folders = match sync {
|
||||
Ok(Ok(Response::SyncFolders { items })) => items,
|
||||
// The daemon answered but not as expected — treat as empty and carry
|
||||
// on to the devices list rather than failing the whole page.
|
||||
Ok(Ok(_)) => Vec::new(),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
ui.busy_end();
|
||||
ui.devices.inflight.set(false);
|
||||
ui.devices.loaded_at.set(None);
|
||||
devices_unreachable(&ui);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Daemon reachable: show the list and paint the synced-folders section.
|
||||
ui.devices.content.set_visible_child_name("list");
|
||||
repaint_sync_folders(&ui, &folders);
|
||||
|
||||
let devices = match devices_reply {
|
||||
Ok(Ok(Response::Devices { items })) => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ui.busy_end();
|
||||
ui.devices.inflight.set(false);
|
||||
repaint_devices(&ui, &devices);
|
||||
ui.devices.loaded_at.set(Some(Instant::now()));
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh just the synced-folders section, with no status flash, no spinner and
|
||||
/// no devices round-trip. Driven by the periodic tick while the Devices page is
|
||||
/// on screen: a folder's sync progress is only live if something re-reads it.
|
||||
///
|
||||
/// A hiccup leaves the page as it is — the next tick tries again, and a real
|
||||
/// outage is reported by [`load_devices`] on the next visit.
|
||||
pub(crate) fn refresh_sync_folders(ui: &Rc<Ui>) {
|
||||
if ui.devices.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.devices.inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.devices.inflight.set(false);
|
||||
let Ok(Ok(Response::SyncFolders { items })) = result else {
|
||||
return;
|
||||
};
|
||||
// The page may have been navigated away from, or collapsed to a status
|
||||
// view, while the request was in flight.
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices")
|
||||
&& ui.devices.content.visible_child_name().as_deref() == Some("list")
|
||||
{
|
||||
repaint_sync_folders(&ui, &items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Devices page.
|
||||
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
devices_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
devices_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
||||
load_devices(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
|
||||
/// state (this machine may be the only device), so it shows a placeholder row
|
||||
/// rather than collapsing the page.
|
||||
///
|
||||
/// This machine's own device is filtered out. Deleting a device deletes its root
|
||||
/// folder — every file backed up from it — and for *this* machine that would also
|
||||
/// pull the ground out from under the synced folders listed directly above, so it
|
||||
/// is not offered as a peer of "remove some other laptop". Removing this
|
||||
/// computer's backup means removing its folders, each of which asks about its
|
||||
/// cloud copy on its own terms.
|
||||
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
for row in ui.devices.rows.borrow_mut().drain(..) {
|
||||
ui.devices.group.remove(&row);
|
||||
}
|
||||
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
|
||||
if others.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No other computers")
|
||||
.subtitle("Desktop apps syncing to this account appear here.")
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
ui.devices.group.add(&row);
|
||||
*ui.devices.rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for dev in others {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&dev.name)
|
||||
.subtitle(device_subtitle(dev))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove this computer and everything it backed up")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let rename = gtk4::Button::builder()
|
||||
.icon_name("document-edit-symbolic")
|
||||
.tooltip_text("Rename computer")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
rename.add_css_class("flat");
|
||||
let ui_ren = ui.clone();
|
||||
let uid_ren = dev.uid.clone();
|
||||
let name_ren = dev.name.clone();
|
||||
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
|
||||
let ui_rm = ui.clone();
|
||||
let uid_rm = dev.uid.clone();
|
||||
let name_rm = dev.name.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
|
||||
row.add_suffix(&rename);
|
||||
row.add_suffix(&remove);
|
||||
ui.devices.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*ui.devices.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// A device row's subtitle: its platform, and when it last synced. A device that
|
||||
/// has never synced says so — an unexplained missing date reads as a bug, and
|
||||
/// "never" is the fact that tells the user this computer isn't backing anything up.
|
||||
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||
match dev.last_sync {
|
||||
Some(secs) if secs > 0 => {
|
||||
format!("{} · last synced {}", dev.device_type, activity_time(secs))
|
||||
}
|
||||
_ => format!("{} · never synced", dev.device_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the "This computer" section from a fresh synced-folders listing.
|
||||
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
||||
ui.devices.sync_group.remove(&row);
|
||||
}
|
||||
if folders.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No synced folders")
|
||||
.subtitle("Add a folder to back it up to this device.")
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for f in folders {
|
||||
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
|
||||
// otherwise fall back to the folder's resting state.
|
||||
let status = match &f.progress {
|
||||
Some(p) => sync_progress_label(p),
|
||||
None => sync_state_label(&f.state).to_string(),
|
||||
};
|
||||
// A queued switch is where the folder is *going*, which is what the user
|
||||
// just asked for and is waiting to see happen — so it leads the subtitle
|
||||
// instead of the mode the folder is still technically in.
|
||||
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
|
||||
(Some("ondemand"), _) => format!("Going on-demand · {status}"),
|
||||
(Some(_), _) => format!("Switching to synced · {status}"),
|
||||
(None, "ondemand") => format!("On-demand · {status}"),
|
||||
(None, _) => format!("Synced · {status}"),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&f.local_path)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
||||
let id = f.id;
|
||||
|
||||
// A pass that knows how far it has got shows it: the subtitle alone
|
||||
// ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This
|
||||
// holds for scanning as much as applying — a big folder spends minutes there
|
||||
// — but a folder syncing for the first time has no estimate to draw against
|
||||
// (`total == 0`), and this row is repainted on the 2s tick, too slow for a
|
||||
// pulse to read as motion. So it stays text-only until real counts exist.
|
||||
if let Some(p) = &f.progress
|
||||
&& p.total > 0
|
||||
{
|
||||
let bar = gtk4::ProgressBar::builder()
|
||||
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
|
||||
.valign(gtk4::Align::Center)
|
||||
.width_request(120)
|
||||
.build();
|
||||
row.add_suffix(&bar);
|
||||
}
|
||||
|
||||
// Sync now — only meaningful for mirror folders (on-demand has no local
|
||||
// copy to reconcile). The engine also syncs on file changes and on a
|
||||
// 120s poll; this is the "don't wait" button.
|
||||
if f.mode != "ondemand" {
|
||||
let sync_now = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Sync this folder now")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
sync_now.add_css_class("flat");
|
||||
let ui_sync = ui.clone();
|
||||
sync_now.connect_clicked(move |_| {
|
||||
let rx = spawn_request(
|
||||
ui_sync.dirs.control_socket(),
|
||||
Request::SyncNow { id: Some(id) },
|
||||
);
|
||||
let ui_sync = ui_sync.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui_sync, "Couldn't sync", &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui_sync,
|
||||
"Couldn't sync",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
row.add_suffix(&sync_now);
|
||||
}
|
||||
|
||||
// On-demand toggle: off = full local copy (mirror), on = FUSE mount that
|
||||
// frees the disk. Set the state before wiring the handler so painting the
|
||||
// current mode doesn't fire a spurious request.
|
||||
//
|
||||
// A queued switch paints as already flipped: the daemon accepted the
|
||||
// request and will act on it, so snapping the switch back to the current
|
||||
// mode would read as "it didn't take" and invite the user to toggle again.
|
||||
let target = f.pending_mode.as_deref().unwrap_or(&f.mode);
|
||||
let ondemand = gtk4::Switch::builder()
|
||||
.tooltip_text(
|
||||
"On-demand: free this disk by keeping the files in Proton Drive only, \
|
||||
fetching each as you open it. Turn off to download them back and keep a \
|
||||
full local copy.",
|
||||
)
|
||||
.valign(gtk4::Align::Center)
|
||||
.active(target == "ondemand")
|
||||
.build();
|
||||
let ui_mode = ui.clone();
|
||||
ondemand.connect_state_set(move |_, on| {
|
||||
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
row.add_suffix(&ondemand);
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Stop syncing this folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let ui_rm = ui.clone();
|
||||
let path = f.local_path.clone();
|
||||
// The folder's *current* mode, not a queued one: a switch that hasn't
|
||||
// landed yet has not moved the files anywhere.
|
||||
let ondemand = f.mode == "ondemand";
|
||||
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand));
|
||||
row.add_suffix(&remove);
|
||||
ui.devices.sync_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*ui.devices.sync_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Human label for a synced folder's `state` column.
|
||||
pub(crate) fn sync_state_label(state: &str) -> &str {
|
||||
match state {
|
||||
"syncing" => "syncing…",
|
||||
"error" => "sync error",
|
||||
"conflict" => "needs attention",
|
||||
_ => "up to date",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human label for a sync pass in flight: what it is doing, to which file, and
|
||||
/// how far along it is. Neither phase's total is exact — the scan's is an estimate
|
||||
/// from the last pass, and the applying total grows as deeper paths are classified
|
||||
/// — so both read "12 of 40" rather than a percentage that could go backwards.
|
||||
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||
match p.phase {
|
||||
// Before the first pass finishes there is no estimate, so the count would
|
||||
// be "checked 12 of 12" — worse than saying nothing.
|
||||
SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(),
|
||||
SyncPhase::Scanning => format!(
|
||||
"checking for changes — {} of {}",
|
||||
p.done,
|
||||
p.total.max(p.done)
|
||||
),
|
||||
SyncPhase::Applying => {
|
||||
let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1));
|
||||
if p.current.is_empty() {
|
||||
format!("syncing {count}")
|
||||
} else {
|
||||
format!("syncing {} — {count}", p.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a local folder and hand it to the daemon to sync to this device.
|
||||
pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Add Folder to Sync")
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||
let Ok(folder) = res else { return };
|
||||
let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
||||
return;
|
||||
};
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::AddSyncFolder {
|
||||
local_path: local_path.clone(),
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
// The daemon acks before the row exists — registering the
|
||||
// device and creating the remote folder is off-socket network
|
||||
// work. A fixed delay here either fires too early (empty list)
|
||||
// or too late (row flashes in). Instead let the periodic
|
||||
// `refresh_sync_folders` tick pick the row up whenever it
|
||||
// actually lands, which is what keeps a running pass live too.
|
||||
toast(&ui, "Syncing folder…");
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't add folder", &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't add folder",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
|
||||
/// row's subtitle and switch reflect the daemon's real state (the request may be
|
||||
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
|
||||
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::SetSyncFolderMode {
|
||||
id,
|
||||
mode: mode.to_string(),
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't change mode", &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't change mode",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
||||
load_devices(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
|
||||
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
|
||||
let win = ui_window(ui);
|
||||
// The two modes leave the user in opposite places, so they can't share a
|
||||
// sentence. A mirror folder's files are already on this disk and stay there.
|
||||
// An on-demand folder's are not: the path is a mount over content that lives
|
||||
// in Proton Drive, so unmounting it leaves an empty directory — which is a
|
||||
// nasty surprise if the dialog claimed the local files were safe, and is
|
||||
// recoverable only by turning On-demand off *first* and letting it download.
|
||||
let body = if ondemand {
|
||||
format!(
|
||||
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \
|
||||
Drive, not on this disk, so the folder will be empty once it is unmounted. To \
|
||||
keep a local copy, cancel, turn off On-demand, and wait for the download to \
|
||||
finish before removing it."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \
|
||||
being synced. Choose whether to also delete the copy in Proton Drive."
|
||||
)
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Stop syncing folder")
|
||||
.body(body)
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let delete_remote = adw::SwitchRow::builder()
|
||||
.title("Also delete from Proton Drive")
|
||||
.subtitle(if ondemand {
|
||||
"Deletes the only copy of these files."
|
||||
} else {
|
||||
"The local copy is unaffected."
|
||||
})
|
||||
.active(false)
|
||||
.build();
|
||||
group.add(&delete_remote);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Stop Syncing");
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "remove" {
|
||||
run_devices_mutation(
|
||||
&ui,
|
||||
Request::RemoveSyncFolder {
|
||||
id,
|
||||
delete_remote: delete_remote.is_active(),
|
||||
},
|
||||
"Stopped syncing folder",
|
||||
"Couldn't stop syncing the folder",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Prompt for a new device name and rename it.
|
||||
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Rename device")
|
||||
.body(format!("Rename “{current}”."))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("New name")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(current);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("rename", "Rename");
|
||||
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("rename"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "rename" {
|
||||
return;
|
||||
}
|
||||
let name = row.text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
toast_error(&ui, "Couldn't rename device", "A name is required.");
|
||||
return;
|
||||
}
|
||||
run_devices_mutation(
|
||||
&ui,
|
||||
Request::RenameDevice {
|
||||
uid: uid.clone(),
|
||||
name,
|
||||
},
|
||||
"Device renamed",
|
||||
"Couldn't rename the device",
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then remove (deregister) a device.
|
||||
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove computer")
|
||||
.body(format!(
|
||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
|
||||
deleted along with it. The files on that computer itself are not touched — but \
|
||||
this cannot be undone from here."
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Remove");
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "remove" {
|
||||
run_devices_mutation(
|
||||
&ui,
|
||||
Request::DeleteDevice { uid: uid.clone() },
|
||||
"Device removed",
|
||||
"Couldn't remove the device",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from the Devices page and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
let done = done.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
load_devices(&ui);
|
||||
toast(&ui, &done);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct LoginState {
|
||||
// Login page.
|
||||
pub(crate) email: adw::EntryRow,
|
||||
pub(crate) password: adw::PasswordEntryRow,
|
||||
pub(crate) login_button: gtk4::Button,
|
||||
pub(crate) login_status: gtk4::Label,
|
||||
}
|
||||
|
||||
/// The login page: an email row, password row, a primary "Sign in" button and a
|
||||
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
|
||||
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn build_login_page() -> (
|
||||
gtk4::Widget,
|
||||
(
|
||||
adw::EntryRow,
|
||||
adw::PasswordEntryRow,
|
||||
gtk4::Button,
|
||||
gtk4::Label,
|
||||
),
|
||||
) {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Sign in to Proton")
|
||||
.description("Use your Proton account to connect Drive.")
|
||||
.build();
|
||||
|
||||
let email = adw::EntryRow::builder().title("Email or username").build();
|
||||
let password = adw::PasswordEntryRow::builder().title("Password").build();
|
||||
group.add(&email);
|
||||
group.add(&password);
|
||||
|
||||
let login_button = gtk4::Button::builder()
|
||||
.label("Sign in")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
login_button.add_css_class("suggested-action");
|
||||
login_button.add_css_class("pill");
|
||||
|
||||
let login_status = gtk4::Label::builder()
|
||||
.wrap(true)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.build();
|
||||
login_status.add_css_class("dim-label");
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
||||
logo.set_pixel_size(64);
|
||||
logo.add_css_class("brand-icon");
|
||||
let title = gtk4::Label::new(Some("Proton Drive"));
|
||||
title.add_css_class("brand-title");
|
||||
header.append(&logo);
|
||||
header.append(&title);
|
||||
header.set_margin_bottom(12);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
|
||||
inner.set_margin_top(32);
|
||||
inner.set_margin_bottom(32);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&group);
|
||||
inner.append(&login_button);
|
||||
inner.append(&login_status);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(420)
|
||||
.child(&inner)
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
||||
|
||||
(
|
||||
scroll.upcast(),
|
||||
(email, password, login_button, login_status),
|
||||
)
|
||||
}
|
||||
|
||||
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
|
||||
/// thread, and report the outcome back on the main loop.
|
||||
pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
// Pressing Enter in either field submits the form, so signing in never needs
|
||||
// a reach for the mouse. Both fields route to the same button.
|
||||
let btn = ui.login.login_button.clone();
|
||||
ui.login
|
||||
.email
|
||||
.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
let btn = ui.login.login_button.clone();
|
||||
ui.login
|
||||
.password
|
||||
.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
let ui = ui.clone();
|
||||
let button = ui.login.login_button.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
let username = ui.login.email.text().to_string();
|
||||
let password = ui.login.password.text().to_string();
|
||||
if username.is_empty() || password.is_empty() {
|
||||
ui.login
|
||||
.login_status
|
||||
.set_text("Enter your email and password.");
|
||||
return;
|
||||
}
|
||||
|
||||
ui.login.login_button.set_sensitive(false);
|
||||
ui.login.login_status.set_text("Signing in…");
|
||||
let (rx, totp_req_rx) = spawn_login(username, password);
|
||||
|
||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||
// back a code (or is cancelled, dropping the sender).
|
||||
let ui_2fa = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok(code_tx) = totp_req_rx.recv().await {
|
||||
prompt_2fa(&ui_2fa, code_tx);
|
||||
}
|
||||
});
|
||||
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx
|
||||
.recv()
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("login cancelled".into()));
|
||||
ui.login.login_button.set_sensitive(true);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
ui.login.login_status.set_text("");
|
||||
ui.login.password.set_text("");
|
||||
// Cache the new identity so `refresh` never hits the keyring.
|
||||
*ui.session.borrow_mut() = auth::load().ok();
|
||||
// Enable+start the mount service now that we have a session.
|
||||
service::enable_start();
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(e) => ui
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&format!("Sign-in failed: {e}")),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||
/// runtime. Returns two channels: the first yields the final login result once;
|
||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
|
||||
/// closure blocks the worker on that sender until the dialog answers, so the
|
||||
/// code is requested lazily and can't expire before the password proof.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn spawn_login(
|
||||
username: String,
|
||||
password: String,
|
||||
) -> (
|
||||
async_channel::Receiver<Result<(), String>>,
|
||||
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||
) {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = tx.send_blocking(Err(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let result = rt.block_on(async move {
|
||||
auth::login(&username, &password, || {
|
||||
// 2FA required: hand a one-shot sender to the UI and block until
|
||||
// the dialog supplies the code. A dropped sender (cancelled
|
||||
// dialog) surfaces as a cancelled login.
|
||||
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
||||
totp_req_tx
|
||||
.send_blocking(code_tx)
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
|
||||
code_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
(rx, totp_req_rx)
|
||||
}
|
||||
|
||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||
/// login worker via `code_tx`. Cancelling (or closing) drops the sender, which
|
||||
/// the worker reads as a cancelled login.
|
||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Two-factor authentication")
|
||||
.body("Enter the code from your authenticator app.")
|
||||
.build();
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let entry = adw::EntryRow::builder()
|
||||
.title("Authentication code")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&entry);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("confirm", "Confirm");
|
||||
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("confirm"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let code_tx = RefCell::new(Some(code_tx));
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
// On cancel/close we take + drop `tx` without sending, so the worker's
|
||||
// recv errors out and the login is reported as cancelled.
|
||||
if let Some(tx) = code_tx.borrow_mut().take()
|
||||
&& resp == "confirm"
|
||||
{
|
||||
let _ = tx.send(entry.text().trim().to_string());
|
||||
}
|
||||
});
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Connect the sign-out button: disable+stop the mount service (so the daemon
|
||||
/// isn't left running without credentials), forget the stored session, and drop
|
||||
/// back to the login page.
|
||||
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
||||
let ui = ui.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
service::disable_stop();
|
||||
if let Err(e) = auth::logout() {
|
||||
tracing::error!("logout failed: {e}");
|
||||
}
|
||||
*ui.session.borrow_mut() = None;
|
||||
refresh(&ui);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub(crate) mod activity;
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod devices;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
pub(crate) mod trash;
|
||||
@@ -0,0 +1,698 @@
|
||||
use crate::*;
|
||||
|
||||
/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and
|
||||
/// the button/key handlers so each one takes a single handle instead of a dozen
|
||||
/// widget arguments.
|
||||
pub(crate) struct Viewer {
|
||||
pub(crate) picture: gtk4::Picture,
|
||||
pub(crate) spinner: gtk4::Spinner,
|
||||
pub(crate) status: gtk4::Label,
|
||||
pub(crate) title: gtk4::Label,
|
||||
pub(crate) counter: gtk4::Label,
|
||||
pub(crate) prev: gtk4::Button,
|
||||
pub(crate) next: gtk4::Button,
|
||||
/// Details drawer: the toggle that reveals it, the rows it fills in, and the
|
||||
/// "Show on map" button (hidden when the photo carries no GPS tags).
|
||||
pub(crate) info_toggle: gtk4::ToggleButton,
|
||||
pub(crate) info_revealer: gtk4::Revealer,
|
||||
pub(crate) info_rows: gtk4::Box,
|
||||
pub(crate) info_map: gtk4::Button,
|
||||
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
||||
pub(crate) coords: RefCell<Option<(f64, f64)>>,
|
||||
/// uid of the photo currently on screen.
|
||||
pub(crate) uid: RefCell<String>,
|
||||
/// On-disk path of the full-size photo, once it has been downloaded.
|
||||
pub(crate) path: RefCell<Option<String>>,
|
||||
/// True while the full-size photo is still downloading and what's on screen is
|
||||
/// the upscaled thumbnail — a click-outside-to-close hit test has to size the
|
||||
/// image from the *thumbnail's* ratio in that window, but more importantly a
|
||||
/// late reply for a photo the user has already navigated away from must not
|
||||
/// overwrite the new one. Guarded by comparing against `uid`.
|
||||
pub(crate) loading: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||
/// label/value pairs for the details drawer.
|
||||
pub(crate) struct ExifInfo {
|
||||
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no
|
||||
/// EXIF at all, which is normal for screenshots and re-encoded images.
|
||||
pub(crate) fields: Vec<(&'static str, String)>,
|
||||
/// Decimal degrees, if the photo is geotagged.
|
||||
pub(crate) coords: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
|
||||
/// so the lightbox never opens on a blank screen, ask the daemon for the
|
||||
/// full-size file, and swap it in — plus its EXIF — when it lands.
|
||||
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.spinner.set_visible(true);
|
||||
viewer.spinner.start();
|
||||
viewer.status.set_visible(false);
|
||||
viewer.loading.set(true);
|
||||
*viewer.path.borrow_mut() = None;
|
||||
clear_info(viewer);
|
||||
|
||||
// The thumbnail the gallery already decoded stands in for the full photo
|
||||
// while it downloads: blurry for a moment beats black for a second.
|
||||
match ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
Some(texture) => viewer.picture.set_paintable(Some(texture)),
|
||||
None => viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE),
|
||||
}
|
||||
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::OpenPhoto { uid: uid.clone() },
|
||||
);
|
||||
let viewer = viewer.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
// The user may have moved on while this was in flight; that photo's own
|
||||
// request owns the viewer now.
|
||||
if *viewer.uid.borrow() != uid {
|
||||
return;
|
||||
}
|
||||
viewer.spinner.stop();
|
||||
viewer.spinner.set_visible(false);
|
||||
viewer.loading.set(false);
|
||||
|
||||
let fail = |message: &str| {
|
||||
viewer.status.set_label(message);
|
||||
viewer.status.set_visible(true);
|
||||
};
|
||||
match result {
|
||||
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => {
|
||||
viewer.picture.set_paintable(Some(&texture));
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_info(&viewer, &path, read_exif(&path));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to load texture for {path}: {e}");
|
||||
fail("Couldn't render this photo.");
|
||||
}
|
||||
},
|
||||
Ok(Ok(Response::Error { message, .. })) => fail(&message),
|
||||
Ok(Ok(_)) => fail("Unexpected reply from the mount service."),
|
||||
Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Warm the cache with the photo `delta` steps away, so stepping there is
|
||||
/// instant. Fire-and-forget: the daemon serves control connections concurrently,
|
||||
/// so this rides alongside whatever the user does next, and a failure here is
|
||||
/// simply a photo that downloads on arrival like it used to.
|
||||
pub(crate) fn prefetch_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
|
||||
let model = &ui.gallery.model;
|
||||
let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0) as i32;
|
||||
let index = current + delta;
|
||||
if index < 0 || index >= model.n_items() as i32 {
|
||||
return;
|
||||
}
|
||||
let Some(photo) = model
|
||||
.item(index as u32)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// Never prefetch a video: the lightbox skips over it, and warming it would
|
||||
// mean downloading a whole clip the user won't watch from here.
|
||||
if photo.kind == PhotoKind::Video {
|
||||
return;
|
||||
}
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::OpenPhoto { uid: photo.uid },
|
||||
);
|
||||
glib::spawn_future_local(async move {
|
||||
let _ = rx.recv().await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset the details drawer while the next photo is in flight, so it never shows
|
||||
/// the previous photo's camera against the new image.
|
||||
pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
|
||||
*viewer.coords.borrow_mut() = None;
|
||||
viewer.info_map.set_visible(false);
|
||||
while let Some(row) = viewer.info_rows.first_child() {
|
||||
viewer.info_rows.remove(&row);
|
||||
}
|
||||
let label = gtk4::Label::builder()
|
||||
.label("Reading photo details…")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
viewer.info_rows.append(&label);
|
||||
}
|
||||
|
||||
/// Fill the details drawer for the photo now on screen: its own file facts
|
||||
/// (size, dimensions) plus whatever EXIF it carries.
|
||||
pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
||||
while let Some(row) = viewer.info_rows.first_child() {
|
||||
viewer.info_rows.remove(&row);
|
||||
}
|
||||
|
||||
let mut fields: Vec<(&str, String)> = Vec::new();
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
fields.push(("Size", human_bytes(meta.len())));
|
||||
}
|
||||
fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone())));
|
||||
|
||||
if fields.is_empty() {
|
||||
let label = gtk4::Label::builder()
|
||||
.label("This photo carries no metadata.")
|
||||
.halign(gtk4::Align::Start)
|
||||
.wrap(true)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
viewer.info_rows.append(&label);
|
||||
} else {
|
||||
let group = gtk4::ListBox::new();
|
||||
group.set_selection_mode(gtk4::SelectionMode::None);
|
||||
group.add_css_class("boxed-list");
|
||||
for (label, value) in fields {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(label)
|
||||
.subtitle(value)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
row.add_css_class("property");
|
||||
group.append(&row);
|
||||
}
|
||||
viewer.info_rows.append(&group);
|
||||
}
|
||||
|
||||
viewer.info_map.set_visible(info.coords.is_some());
|
||||
*viewer.coords.borrow_mut() = info.coords;
|
||||
}
|
||||
|
||||
/// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on
|
||||
/// disk. Anything missing is simply left out — phone screenshots and re-encoded
|
||||
/// images legitimately carry no EXIF at all.
|
||||
pub(crate) fn read_exif(path: &str) -> ExifInfo {
|
||||
let mut fields: Vec<(&'static str, String)> = Vec::new();
|
||||
let mut coords = None;
|
||||
|
||||
let reader = match std::fs::File::open(path) {
|
||||
Ok(file) => {
|
||||
match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) {
|
||||
Ok(reader) => reader,
|
||||
Err(e) => {
|
||||
tracing::debug!("no exif in {path}: {e}");
|
||||
return ExifInfo { fields, coords };
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot open {path} for exif: {e}");
|
||||
return ExifInfo { fields, coords };
|
||||
}
|
||||
};
|
||||
|
||||
let field = |tag: exif::Tag| {
|
||||
reader
|
||||
.get_field(tag, exif::In::PRIMARY)
|
||||
.map(|f| f.display_value().with_unit(&reader).to_string())
|
||||
};
|
||||
|
||||
if let Some(size) = field(exif::Tag::PixelXDimension)
|
||||
.zip(field(exif::Tag::PixelYDimension))
|
||||
.map(|(w, h)| format!("{w} × {h}"))
|
||||
{
|
||||
fields.push(("Dimensions", size));
|
||||
}
|
||||
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
|
||||
fields.push(("Taken", taken));
|
||||
}
|
||||
|
||||
let camera = [exif::Tag::Make, exif::Tag::Model]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !camera.is_empty() {
|
||||
fields.push(("Camera", camera));
|
||||
}
|
||||
if let Some(lens) = field(exif::Tag::LensModel) {
|
||||
fields.push(("Lens", lens));
|
||||
}
|
||||
|
||||
let exposure: Vec<String> = [
|
||||
exif::Tag::FNumber,
|
||||
exif::Tag::ExposureTime,
|
||||
exif::Tag::PhotographicSensitivity,
|
||||
exif::Tag::FocalLength,
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|tag| field(*tag))
|
||||
.collect();
|
||||
if !exposure.is_empty() {
|
||||
fields.push(("Exposure", exposure.join(" · ")));
|
||||
}
|
||||
|
||||
if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef)
|
||||
&& let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef)
|
||||
{
|
||||
fields.push(("Location", format!("{lat:.5}, {lon:.5}")));
|
||||
coords = Some((lat, lon));
|
||||
}
|
||||
|
||||
ExifInfo { fields, coords }
|
||||
}
|
||||
|
||||
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
|
||||
/// decimal degrees, negating for the S/W hemispheres.
|
||||
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
|
||||
let field = reader.get_field(tag, exif::In::PRIMARY)?;
|
||||
let exif::Value::Rational(dms) = &field.value else {
|
||||
return None;
|
||||
};
|
||||
let [deg, min, sec] = dms.get(..3)? else {
|
||||
return None;
|
||||
};
|
||||
let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0;
|
||||
|
||||
let hemisphere = reader
|
||||
.get_field(ref_tag, exif::In::PRIMARY)
|
||||
.map(|f| f.display_value().to_string())
|
||||
.unwrap_or_default();
|
||||
let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W');
|
||||
Some(if negative { -degrees } else { degrees })
|
||||
}
|
||||
|
||||
pub(crate) fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &str) {
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save Photo")
|
||||
.initial_name(original_name)
|
||||
.build();
|
||||
let source_path_str = source_path.to_string();
|
||||
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
|
||||
if let Ok(file) = res
|
||||
&& let Some(dest_path) = file.path()
|
||||
{
|
||||
if let Err(e) = std::fs::copy(&source_path_str, &dest_path) {
|
||||
tracing::error!("Failed to copy file to {:?}: {}", dest_path, e);
|
||||
} else {
|
||||
tracing::info!("Saved photo to {:?}", dest_path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Step `delta` photos through the flat timeline model and load what lands there.
|
||||
pub(crate) fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
|
||||
let model = &ui.gallery.model;
|
||||
let n = model.n_items();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let uid_val = viewer.uid.borrow().clone();
|
||||
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
|
||||
|
||||
// Where the raw step lands (prev/next are ±1; Home/End are huge deltas that
|
||||
// clamp to the ends), and which way it was travelling.
|
||||
let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1);
|
||||
let dir = if delta < 0 { -1 } else { 1 };
|
||||
|
||||
// The lightbox renders stills only — a video plays in an external player, so
|
||||
// it has no frame here. Land on the nearest renderable item, preferring the
|
||||
// travel direction and falling back to scanning inward from a boundary (so
|
||||
// Home/End still reach the first/last still even when videos sit at the end).
|
||||
let renderable = |idx: u32| -> Option<PhotoItem> {
|
||||
model
|
||||
.item(idx)
|
||||
.and_then(|obj| {
|
||||
obj.downcast_ref::<BoxedAnyObject>()
|
||||
.map(|b| b.borrow::<PhotoItem>().clone())
|
||||
})
|
||||
.filter(|photo| photo.kind != PhotoKind::Video)
|
||||
};
|
||||
let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> {
|
||||
let mut probe = from;
|
||||
while (0..n as i32).contains(&probe) {
|
||||
if let Some(photo) = renderable(probe as u32) {
|
||||
return Some((probe as u32, photo));
|
||||
}
|
||||
probe += step;
|
||||
}
|
||||
None
|
||||
};
|
||||
// Scan the travel direction first. Only fall back to scanning inward when the
|
||||
// step landed on a boundary — that is the Home/End case (jump to first/last,
|
||||
// then step inward past any videos there); a mid-list prev/next with only
|
||||
// videos ahead should simply stay put rather than reverse.
|
||||
let at_boundary = start == 0 || start == n as i32 - 1;
|
||||
let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten());
|
||||
let Some((next_idx, photo)) = landing else {
|
||||
return;
|
||||
};
|
||||
|
||||
*viewer.uid.borrow_mut() = photo.uid.clone();
|
||||
show_photo_position(viewer, next_idx, n, photo.capture_time);
|
||||
load_photo(ui, viewer, photo.uid.clone());
|
||||
// Keep walking in the same direction: the next one is likely where they're
|
||||
// headed, so have it in the cache before they ask.
|
||||
prefetch_photo(ui, viewer, delta.signum());
|
||||
}
|
||||
|
||||
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
|
||||
pub(crate) fn show_photo_position(viewer: &Rc<Viewer>, index: u32, total: u32, capture_time: i64) {
|
||||
viewer.prev.set_sensitive(index > 0);
|
||||
viewer.next.set_sensitive(index + 1 < total);
|
||||
viewer.title.set_label(&format_capture_time(capture_time));
|
||||
viewer
|
||||
.counter
|
||||
.set_label(&format!("{} of {total}", index + 1));
|
||||
}
|
||||
|
||||
/// The in-app lightbox: the photo, edge-to-edge on a dark backdrop, with a
|
||||
/// floating top bar, prev/next affordances and a details drawer.
|
||||
///
|
||||
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
|
||||
/// button, or a click on the backdrop beside the photo all dismiss it.
|
||||
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
|
||||
|
||||
let window = gtk4::Window::builder()
|
||||
.title("Photo")
|
||||
.modal(true)
|
||||
.transient_for(&parent)
|
||||
.default_width(1100)
|
||||
.default_height(760)
|
||||
.build();
|
||||
window.add_css_class("photo-viewer-window");
|
||||
|
||||
let overlay = gtk4::Overlay::new();
|
||||
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
overlay.set_child(Some(&picture));
|
||||
|
||||
let prev_btn = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Previous (←)")
|
||||
.halign(gtk4::Align::Start)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
prev_btn.add_css_class("circular");
|
||||
prev_btn.add_css_class("flat");
|
||||
prev_btn.add_css_class("viewer-nav-btn");
|
||||
overlay.add_overlay(&prev_btn);
|
||||
|
||||
let next_btn = gtk4::Button::builder()
|
||||
.icon_name("go-next-symbolic")
|
||||
.tooltip_text("Next (→)")
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
next_btn.add_css_class("circular");
|
||||
next_btn.add_css_class("flat");
|
||||
next_btn.add_css_class("viewer-nav-btn");
|
||||
overlay.add_overlay(&next_btn);
|
||||
|
||||
// Top bar: the photo's date and position on the left, actions on the right,
|
||||
// over a gradient so white controls stay legible on a bright photo.
|
||||
let title_label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
title_label.add_css_class("viewer-title");
|
||||
|
||||
let counter_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
counter_label.add_css_class("viewer-counter");
|
||||
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 1);
|
||||
titles.set_hexpand(true);
|
||||
titles.set_valign(gtk4::Align::Center);
|
||||
titles.append(&title_label);
|
||||
titles.append(&counter_label);
|
||||
|
||||
let action = |icon: &str, tooltip: &str| {
|
||||
let button = gtk4::Button::builder()
|
||||
.icon_name(icon)
|
||||
.tooltip_text(tooltip)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
button.add_css_class("flat");
|
||||
button.add_css_class("viewer-action-btn");
|
||||
button
|
||||
};
|
||||
|
||||
let info_toggle = gtk4::ToggleButton::builder()
|
||||
.icon_name("info-outline-symbolic")
|
||||
.tooltip_text("Details (i)")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
info_toggle.add_css_class("flat");
|
||||
info_toggle.add_css_class("viewer-action-btn");
|
||||
|
||||
let download_btn = action("document-save-symbolic", "Save a copy…");
|
||||
let open_ext_btn = action("document-open-symbolic", "Open with another app");
|
||||
let close_btn = action("window-close-symbolic", "Close (Esc)");
|
||||
close_btn.add_css_class("viewer-close-btn");
|
||||
|
||||
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
top_bar.add_css_class("viewer-top-bar");
|
||||
top_bar.set_valign(gtk4::Align::Start);
|
||||
top_bar.set_hexpand(true);
|
||||
top_bar.append(&titles);
|
||||
top_bar.append(&info_toggle);
|
||||
top_bar.append(&download_btn);
|
||||
top_bar.append(&open_ext_btn);
|
||||
top_bar.append(&close_btn);
|
||||
overlay.add_overlay(&top_bar);
|
||||
|
||||
// Details drawer: slides in from the right as a real surface (not a wash of
|
||||
// black over the photo), with its own header and its own way out.
|
||||
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
|
||||
let info_map = gtk4::Button::builder()
|
||||
.label("Show on map")
|
||||
.icon_name("map-symbolic")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
info_map.add_css_class("pill");
|
||||
info_map.set_visible(false);
|
||||
|
||||
let info_title = gtk4::Label::builder()
|
||||
.label("Details")
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
info_title.add_css_class("heading");
|
||||
|
||||
let info_close = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Hide details")
|
||||
.build();
|
||||
info_close.add_css_class("flat");
|
||||
info_close.add_css_class("circular");
|
||||
|
||||
let info_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
info_header.append(&info_title);
|
||||
info_header.append(&info_close);
|
||||
|
||||
let info_body = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
info_body.set_margin_top(16);
|
||||
info_body.set_margin_bottom(16);
|
||||
info_body.set_margin_start(16);
|
||||
info_body.set_margin_end(16);
|
||||
info_body.append(&info_header);
|
||||
info_body.append(&info_rows);
|
||||
info_body.append(&info_map);
|
||||
|
||||
// Scrolled, because a photo with a full EXIF block plus a location can
|
||||
// outgrow a short window.
|
||||
let info_scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_height(true)
|
||||
.child(&info_body)
|
||||
.build();
|
||||
|
||||
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
info_panel.add_css_class("viewer-info-panel");
|
||||
info_panel.set_width_request(320);
|
||||
info_panel.append(&info_scroll);
|
||||
|
||||
let info_revealer = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Fill)
|
||||
.child(&info_panel)
|
||||
.build();
|
||||
overlay.add_overlay(&info_revealer);
|
||||
|
||||
let spinner = gtk4::Spinner::builder()
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.width_request(48)
|
||||
.height_request(48)
|
||||
.build();
|
||||
spinner.add_css_class("viewer-spinner");
|
||||
overlay.add_overlay(&spinner);
|
||||
|
||||
let status_label = gtk4::Label::builder()
|
||||
.wrap(true)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
status_label.add_css_class("viewer-status");
|
||||
overlay.add_overlay(&status_label);
|
||||
|
||||
window.set_child(Some(&overlay));
|
||||
|
||||
let viewer = Rc::new(Viewer {
|
||||
picture: picture.clone(),
|
||||
spinner,
|
||||
status: status_label,
|
||||
title: title_label,
|
||||
counter: counter_label,
|
||||
prev: prev_btn.clone(),
|
||||
next: next_btn.clone(),
|
||||
info_toggle: info_toggle.clone(),
|
||||
info_revealer: info_revealer.clone(),
|
||||
info_rows,
|
||||
info_map: info_map.clone(),
|
||||
coords: RefCell::new(None),
|
||||
uid: RefCell::new(initial_uid.clone()),
|
||||
path: RefCell::new(None),
|
||||
loading: Cell::new(false),
|
||||
});
|
||||
|
||||
let n = ui.gallery.model.n_items();
|
||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||
let capture_time = ui
|
||||
.gallery
|
||||
.model
|
||||
.item(initial_idx)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.map_or(0, |boxed| boxed.borrow::<PhotoItem>().capture_time);
|
||||
show_photo_position(&viewer, initial_idx, n, capture_time);
|
||||
|
||||
load_photo(ui, &viewer, initial_uid);
|
||||
prefetch_photo(ui, &viewer, 1);
|
||||
|
||||
let w_close = window.clone();
|
||||
close_btn.connect_clicked(move |_| {
|
||||
w_close.close();
|
||||
});
|
||||
|
||||
let viewer_info = viewer.clone();
|
||||
info_toggle.connect_toggled(move |toggle| {
|
||||
viewer_info
|
||||
.info_revealer
|
||||
.set_reveal_child(toggle.is_active());
|
||||
});
|
||||
|
||||
let toggle_off = info_toggle.clone();
|
||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||
|
||||
let viewer_map = viewer.clone();
|
||||
info_map.connect_clicked(move |_| {
|
||||
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
|
||||
open_path(&format!(
|
||||
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
let w_download = window.clone();
|
||||
let viewer_download = viewer.clone();
|
||||
download_btn.connect_clicked(move |_| {
|
||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||
let name = format!("{}.jpg", viewer_download.uid.borrow());
|
||||
save_photo_to_disk(&w_download, path, &name);
|
||||
}
|
||||
});
|
||||
|
||||
let viewer_ext = viewer.clone();
|
||||
open_ext_btn.connect_clicked(move |_| {
|
||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||
open_path(path);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_prev = ui.clone();
|
||||
let viewer_prev = viewer.clone();
|
||||
prev_btn.connect_clicked(move |_| {
|
||||
navigate_photo(&ui_prev, &viewer_prev, -1);
|
||||
});
|
||||
|
||||
let ui_next = ui.clone();
|
||||
let viewer_next = viewer.clone();
|
||||
next_btn.connect_clicked(move |_| {
|
||||
navigate_photo(&ui_next, &viewer_next, 1);
|
||||
});
|
||||
|
||||
// Click the backdrop — the dark area beside the photo — to dismiss, the way
|
||||
// every other lightbox behaves. Clicks on the photo itself are left alone, so
|
||||
// reaching for the image doesn't fling the window shut.
|
||||
let backdrop = gtk4::GestureClick::new();
|
||||
let viewer_click = viewer.clone();
|
||||
let w_click = window.clone();
|
||||
backdrop.connect_released(move |_, _, x, y| {
|
||||
if !over_photo(&viewer_click.picture, x, y) {
|
||||
w_click.close();
|
||||
}
|
||||
});
|
||||
picture.add_controller(backdrop);
|
||||
|
||||
let key_controller = gtk4::EventControllerKey::new();
|
||||
let ui_key = ui.clone();
|
||||
let viewer_key = viewer.clone();
|
||||
let w_key = window.clone();
|
||||
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
match key.name().as_deref() {
|
||||
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
||||
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
||||
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
||||
Some("End") => navigate_photo(&ui_key, &viewer_key, i32::MAX / 2),
|
||||
Some("i") => viewer_key
|
||||
.info_toggle
|
||||
.set_active(!viewer_key.info_toggle.is_active()),
|
||||
Some("f" | "F11") => {
|
||||
if w_key.is_fullscreen() {
|
||||
w_key.unfullscreen();
|
||||
} else {
|
||||
w_key.fullscreen();
|
||||
}
|
||||
}
|
||||
Some("Escape" | "q") => w_key.close(),
|
||||
Some("w") if ctrl => w_key.close(),
|
||||
_ => return glib::Propagation::Proceed,
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
window.add_controller(key_controller);
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
|
||||
/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the
|
||||
/// image and letterboxes the rest, so the drawn rectangle is the widget scaled
|
||||
/// down by whichever axis binds.
|
||||
pub(crate) fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool {
|
||||
let (width, height) = (f64::from(picture.width()), f64::from(picture.height()));
|
||||
let Some(paintable) = picture.paintable() else {
|
||||
return false;
|
||||
};
|
||||
let (iw, ih) = (
|
||||
f64::from(paintable.intrinsic_width()),
|
||||
f64::from(paintable.intrinsic_height()),
|
||||
);
|
||||
if iw <= 0.0 || ih <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let scale = (width / iw).min(height / ih);
|
||||
let (drawn_w, drawn_h) = (iw * scale, ih * scale);
|
||||
let (left, top) = ((width - drawn_w) / 2.0, (height - drawn_h) / 2.0);
|
||||
x >= left && x <= left + drawn_w && y >= top && y <= top + drawn_h
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,497 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct SharedState {
|
||||
// Shared page. Three sections rebuilt wholesale on each load: shared-with-me,
|
||||
// incoming invitations, and bookmarks. Rows are tracked so a reload can remove
|
||||
// the old ones before appending the new (adw groups have no clear-all).
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) with_me_group: adw::PreferencesGroup,
|
||||
pub(crate) invitations_group: adw::PreferencesGroup,
|
||||
pub(crate) bookmarks_group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<(adw::PreferencesGroup, gtk4::Widget)>>,
|
||||
/// Guards the Shared page's load so overlapping navigations don't stack.
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// When the Shared page last painted good data. `None` = never / invalidated,
|
||||
/// forcing a fetch on next visit. See [`PAGE_TTL`].
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// Widgets the Shared page's load/repaint touch.
|
||||
pub(crate) struct SharedWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) shared_with_me: adw::PreferencesGroup,
|
||||
pub(crate) invitations: adw::PreferencesGroup,
|
||||
pub(crate) bookmarks: adw::PreferencesGroup,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) add_bookmark: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Shared page: three stacked sections — items shared *with* me (each with a
|
||||
/// Leave action), invitations addressed to me pending accept/reject, and public
|
||||
/// links I've bookmarked (open / remove). All three live outside the mount tree,
|
||||
/// so the page addresses them by uid/id/token and always re-lists from the daemon.
|
||||
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Shared with me")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let add_bookmark = gtk4::Button::builder()
|
||||
.label("Add Bookmark")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
add_bookmark.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&add_bookmark);
|
||||
|
||||
let shared_with_me = adw::PreferencesGroup::builder()
|
||||
.title("Shared with me")
|
||||
.build();
|
||||
let invitations = adw::PreferencesGroup::builder()
|
||||
.title("Invitations")
|
||||
.build();
|
||||
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&shared_with_me);
|
||||
groups.append(&invitations);
|
||||
groups.append(&bookmarks);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("emblem-shared-symbolic")
|
||||
.vexpand(true)
|
||||
.child(&retry)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&content);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
SharedWidgets {
|
||||
content,
|
||||
status,
|
||||
shared_with_me,
|
||||
invitations,
|
||||
bookmarks,
|
||||
retry,
|
||||
add_bookmark,
|
||||
refresh,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Shared page's retry and Add-Bookmark buttons.
|
||||
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: >k4::Button, add_bookmark: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_shared(&ui_retry);
|
||||
});
|
||||
let ui_add = ui.clone();
|
||||
add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add));
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Shared sections.
|
||||
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.shared.status.set_icon_name(Some(icon));
|
||||
ui.shared.status.set_title(title);
|
||||
ui.shared.status.set_description(Some(description));
|
||||
ui.shared.retry.set_visible(retry);
|
||||
ui.shared.content.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in
|
||||
/// parallel and repaint the page once all three land.
|
||||
pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
if ui.shared.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.shared.inflight.set(true);
|
||||
shared_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Loading…",
|
||||
"Reading your shared items.",
|
||||
false,
|
||||
);
|
||||
|
||||
ui.busy_begin();
|
||||
let socket = ui.dirs.control_socket();
|
||||
let shared_rx = spawn_request(socket.clone(), Request::ListSharedWithMe);
|
||||
let invites_rx = spawn_request(socket.clone(), Request::ListInvitations);
|
||||
let bookmarks_rx = spawn_request(socket, Request::ListBookmarks);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let shared = shared_rx.recv().await;
|
||||
let invites = invites_rx.recv().await;
|
||||
let bookmarks = bookmarks_rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.shared.inflight.set(false);
|
||||
|
||||
// A transport failure on any of the three means the daemon isn't up.
|
||||
if matches!(shared, Ok(Err(_)) | Err(_))
|
||||
|| matches!(invites, Ok(Err(_)) | Err(_))
|
||||
|| matches!(bookmarks, Ok(Err(_)) | Err(_))
|
||||
{
|
||||
ui.shared.loaded_at.set(None);
|
||||
shared_unreachable(&ui);
|
||||
return;
|
||||
}
|
||||
|
||||
let shared_items = match shared {
|
||||
Ok(Ok(Response::Entries { entries })) => entries,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let invitations = match invites {
|
||||
Ok(Ok(Response::Invitations { items })) => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let bookmark_items = match bookmarks {
|
||||
Ok(Ok(Response::Bookmarks { items })) => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
repaint_shared(&ui, &shared_items, &invitations, &bookmark_items);
|
||||
ui.shared.loaded_at.set(Some(Instant::now()));
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Shared page. Same still-starting vs. down split
|
||||
/// as the other pages.
|
||||
pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
shared_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
shared_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("shared") {
|
||||
load_shared(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the three Shared sections. Rows added last time are removed first —
|
||||
/// adw groups have no clear-all — then each section is repopulated, showing a
|
||||
/// dim placeholder row when empty.
|
||||
pub(crate) fn repaint_shared(
|
||||
ui: &Rc<Ui>,
|
||||
shared: &[DirEntry],
|
||||
invitations: &[InvitationInfo],
|
||||
bookmarks: &[BookmarkInfo],
|
||||
) {
|
||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||
group.remove(&row);
|
||||
}
|
||||
ui.shared.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
||||
|
||||
// Shared with me: name + Leave.
|
||||
if shared.is_empty() {
|
||||
let row = dim_row("Nothing is shared with you.");
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for entry in shared {
|
||||
let row = adw::ActionRow::builder().title(&entry.name).build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let leave = gtk4::Button::builder()
|
||||
.label("Leave")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
leave.add_css_class("flat");
|
||||
let ui_leave = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name));
|
||||
row.add_suffix(&leave);
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
// Invitations: inviter + item, Accept / Reject.
|
||||
if invitations.is_empty() {
|
||||
let row = dim_row("No pending invitations.");
|
||||
ui.shared.invitations_group.add(&row);
|
||||
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for inv in invitations {
|
||||
let item = inv
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "a shared item".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item)
|
||||
.subtitle(format!("from {}", inv.inviter_email))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let reject = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Reject")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
reject.add_css_class("flat");
|
||||
let accept = gtk4::Button::builder()
|
||||
.label("Accept")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
accept.add_css_class("suggested-action");
|
||||
let ui_acc = ui.clone();
|
||||
let id_acc = inv.id.clone();
|
||||
accept.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_acc, &id_acc, true);
|
||||
});
|
||||
let ui_rej = ui.clone();
|
||||
let id_rej = inv.id.clone();
|
||||
reject.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_rej, &id_rej, false);
|
||||
});
|
||||
row.add_suffix(&accept);
|
||||
row.add_suffix(&reject);
|
||||
ui.shared.invitations_group.add(&row);
|
||||
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks: name/URL, Open / Remove.
|
||||
if bookmarks.is_empty() {
|
||||
let row = dim_row("No saved bookmarks.");
|
||||
ui.shared.bookmarks_group.add(&row);
|
||||
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for bm in bookmarks {
|
||||
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&title)
|
||||
.subtitle(&bm.url)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove bookmark")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open in browser")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = bm.url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
let ui_rm = ui.clone();
|
||||
let token = bm.token.clone();
|
||||
let name_rm = title.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
|
||||
row.add_suffix(&open);
|
||||
row.add_suffix(&remove);
|
||||
ui.shared.bookmarks_group.add(&row);
|
||||
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
*ui.shared.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Accept or reject an invitation, then reload the Shared page.
|
||||
pub(crate) fn respond_invitation(ui: &Rc<Ui>, id: &str, accept: bool) {
|
||||
let req = if accept {
|
||||
Request::AcceptInvitation { id: id.to_string() }
|
||||
} else {
|
||||
Request::RejectInvitation { id: id.to_string() }
|
||||
};
|
||||
let (done, failed) = if accept {
|
||||
("Invitation accepted", "Couldn't accept the invitation")
|
||||
} else {
|
||||
("Invitation rejected", "Couldn't reject the invitation")
|
||||
};
|
||||
run_shared_mutation(ui, req, done, failed);
|
||||
}
|
||||
|
||||
/// Confirm, then leave a node shared with me.
|
||||
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Leave shared item")
|
||||
.body(format!(
|
||||
"Leave “{name}”? You'll lose access until you're invited again."
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("leave", "Leave");
|
||||
dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "leave" {
|
||||
run_shared_mutation(
|
||||
&ui,
|
||||
Request::LeaveShared { uid: uid.clone() },
|
||||
"Left shared item",
|
||||
"Couldn't leave the shared item",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then remove a saved bookmark.
|
||||
pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove bookmark")
|
||||
.body(format!("Remove the bookmark for “{name}”?"))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Remove");
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let token = token.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "remove" {
|
||||
run_shared_mutation(
|
||||
&ui,
|
||||
Request::DeleteBookmark {
|
||||
token: token.clone(),
|
||||
},
|
||||
"Bookmark removed",
|
||||
"Couldn't remove the bookmark",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Prompt for a public-link URL (and optional password) and save it as a bookmark.
|
||||
pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Add bookmark")
|
||||
.body("Paste a Proton Drive public link to save it here.")
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("Public link URL")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (if the link has one)")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&url_row);
|
||||
group.add(&pw_row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("add", "Add");
|
||||
dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("add"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "add" {
|
||||
return;
|
||||
}
|
||||
let url = url_row.text().trim().to_string();
|
||||
if url.is_empty() {
|
||||
toast_error(&ui, "Couldn't add bookmark", "A URL is required.");
|
||||
return;
|
||||
}
|
||||
let pw = pw_row.text().to_string();
|
||||
let password = if pw.is_empty() { None } else { Some(pw) };
|
||||
run_shared_mutation(
|
||||
&ui,
|
||||
Request::CreateBookmark { url, password },
|
||||
"Bookmark saved",
|
||||
"Couldn't add the bookmark",
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from the Shared page and reload the page on success.
|
||||
pub(crate) fn run_shared_mutation(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
let done = done.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
load_shared(&ui);
|
||||
toast(&ui, &done);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct SharedByMeState {
|
||||
// Shared (by me) page: one section listing the items I've shared, each with a
|
||||
// copy-link / manage affordance. Rebuilt wholesale on each load.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// Widgets the Shared (by-me) page's load/repaint touch.
|
||||
pub(crate) struct SharedByMeWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Shared page: one section listing the items I have shared with others —
|
||||
/// each row summarizing who has access and carrying its public link (copy/open)
|
||||
/// and a Manage shortcut into the per-node Share dialog.
|
||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Shared")
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let refresh = refresh_button();
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
header.append(&title);
|
||||
header.append(&refresh);
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("emblem-shared-symbolic")
|
||||
.vexpand(true)
|
||||
.child(&retry)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&content);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
SharedByMeWidgets {
|
||||
content,
|
||||
status,
|
||||
group,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Shared (by-me) page's retry button.
|
||||
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_shared_by_me(&ui_retry);
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Shared (by-me) list.
|
||||
pub(crate) fn shared_by_me_status(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
retry: bool,
|
||||
) {
|
||||
ui.shared_by_me.status.set_icon_name(Some(icon));
|
||||
ui.shared_by_me.status.set_title(title);
|
||||
ui.shared_by_me.status.set_description(Some(description));
|
||||
ui.shared_by_me.retry.set_visible(retry);
|
||||
ui.shared_by_me.content.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Fetch the shared-by-me listing and repaint the page.
|
||||
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
if ui.shared_by_me.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.shared_by_me.inflight.set(true);
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Loading…",
|
||||
"Reading what you've shared.",
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.shared_by_me.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::SharedByMe { items })) => {
|
||||
repaint_shared_by_me(&ui, &items);
|
||||
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read your shares",
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read your shares",
|
||||
"Unexpected reply from the mount service.",
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
ui.shared_by_me.loaded_at.set(None);
|
||||
shared_by_me_unreachable(&ui);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Shared (by-me) page.
|
||||
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") {
|
||||
load_shared_by_me(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the Shared (by-me) section from a fresh listing.
|
||||
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
|
||||
ui.shared_by_me.group.remove(&row);
|
||||
}
|
||||
if items.is_empty() {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Nothing shared yet",
|
||||
"Items you share with people or by link show up here.",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.shared_by_me.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for item in items {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item.name)
|
||||
.subtitle(shared_item_summary(item))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if item.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
|
||||
// Copy / open the public link, when there is one with a recovered URL.
|
||||
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text("Copy link")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
let ui_copy = ui.clone();
|
||||
let url_copy = url.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
toast(&ui_copy, "Link copied");
|
||||
});
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open link")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
row.add_suffix(©);
|
||||
row.add_suffix(&open);
|
||||
}
|
||||
|
||||
// Manage opens the per-node Share dialog — only when the node's path is
|
||||
// known (it has been browsed to this session); the dialog is path-keyed.
|
||||
if !item.path.is_empty() {
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Manage")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_manage = ui.clone();
|
||||
let entry = shared_item_as_entry(item);
|
||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||
row.add_suffix(&manage);
|
||||
}
|
||||
|
||||
ui.shared_by_me.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if item.member_count > 0 {
|
||||
parts.push(format!(
|
||||
"{} {}",
|
||||
item.member_count,
|
||||
if item.member_count == 1 {
|
||||
"person"
|
||||
} else {
|
||||
"people"
|
||||
}
|
||||
));
|
||||
}
|
||||
if item.invite_count > 0 {
|
||||
parts.push(format!("{} pending", item.invite_count));
|
||||
}
|
||||
if item.link.is_some() {
|
||||
parts.push("Public link".to_string());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"Shared".to_string()
|
||||
} else {
|
||||
parts.join(" · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
|
||||
/// open on it. Only used when the item's path is known.
|
||||
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
DirEntry {
|
||||
name: item.name.clone(),
|
||||
is_dir: item.is_dir,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
path: item.path.clone(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct StatusState {
|
||||
/// Whether a [`Request::Status`] round-trip is already in flight, so the 2s
|
||||
/// refresh tick doesn't pile worker threads up on a slow/wedged daemon.
|
||||
pub(crate) status_inflight: Cell<bool>,
|
||||
/// Guards the [`Request::GetQueueStatus`] poll the same way: at most one
|
||||
/// in-flight at a time so a wedged daemon can't stack worker threads.
|
||||
pub(crate) transfers_inflight: Cell<bool>,
|
||||
/// Activity group + its current rows, hidden when no transfer is in flight.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
pub(crate) transfer_rows: RefCell<Vec<TransferRow>>,
|
||||
// Main page.
|
||||
pub(crate) account_row: adw::ActionRow,
|
||||
/// Read-only mount status line. The mount is driven by the systemd user
|
||||
/// service (enabled on login), not by the user — so this only reports.
|
||||
pub(crate) mount_row: adw::ActionRow,
|
||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||
pub(crate) cache_label: gtk4::Label,
|
||||
/// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic
|
||||
/// sets so reflecting the systemd state doesn't fire the toggle handler.
|
||||
pub(crate) autostart_row: adw::SwitchRow,
|
||||
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
||||
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) budget_row: adw::SpinRow,
|
||||
/// Shows the active mountpoint in its subtitle; updated when the user picks a
|
||||
/// new folder.
|
||||
pub(crate) mountpoint_row: adw::ActionRow,
|
||||
/// Set while a settings widget is being populated programmatically, so its
|
||||
/// change handler skips the IPC/systemd side effect.
|
||||
pub(crate) settings_suppress: Cell<bool>,
|
||||
pub(crate) pins_group: adw::PreferencesGroup,
|
||||
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
|
||||
/// diff against them and only rebuild when the pin set actually changes.
|
||||
pub(crate) pin_rows: RefCell<Vec<PinRow>>,
|
||||
/// The pin paths last rendered, the diff baseline for [`repaint_pins`].
|
||||
/// `None` = nothing built yet; `Some(empty)` = the placeholder is shown.
|
||||
pub(crate) pins_state: RefCell<Option<Vec<String>>>,
|
||||
/// The mount state the *last* desktop notification reported, so a flap only
|
||||
/// notifies on the edge. `None` until the first status reply, so a cold start
|
||||
/// doesn't announce "disconnected" before the service has had a chance to come
|
||||
/// up.
|
||||
pub(crate) notified_mounted: Cell<Option<bool>>,
|
||||
/// How many transfers were in flight on the previous poll. A drop to zero is
|
||||
/// what "sync complete" means; there's no completion event on the wire.
|
||||
pub(crate) active_transfers: Cell<usize>,
|
||||
}
|
||||
|
||||
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
|
||||
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
|
||||
pub(crate) struct PinRow {
|
||||
pub(crate) row: adw::ActionRow,
|
||||
/// The unpin button, absent on the placeholder row.
|
||||
pub(crate) unpin: Option<gtk4::Button>,
|
||||
}
|
||||
|
||||
/// One rendered row in the Activity group: a description over a progress bar.
|
||||
/// Retained so [`repaint_transfers`] can update the bar and label in place each
|
||||
/// tick when the active set is unchanged, instead of rebuilding.
|
||||
pub(crate) struct TransferRow {
|
||||
pub(crate) row: adw::PreferencesRow,
|
||||
pub(crate) label: gtk4::Label,
|
||||
pub(crate) bar: gtk4::ProgressBar,
|
||||
}
|
||||
|
||||
/// What one Activity row should say this tick, and how far along it is —
|
||||
/// `None` meaning "no total known", which the bar shows by pulsing. Jobs and
|
||||
/// transfers both render to this, so the group is one list in the order the
|
||||
/// daemon reports: the jobs that frame the work, then the files moving under it.
|
||||
pub(crate) struct ActivityLine {
|
||||
pub(crate) text: String,
|
||||
pub(crate) fraction: Option<f64>,
|
||||
}
|
||||
|
||||
/// Widgets the settings page hands back for the refresh loop and action wiring.
|
||||
pub(crate) struct MainWidgets {
|
||||
pub(crate) account_row: adw::ActionRow,
|
||||
pub(crate) mount_row: adw::ActionRow,
|
||||
/// Live upload/download progress, populated by the refresh loop.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||
pub(crate) cache_label: gtk4::Label,
|
||||
pub(crate) pins_group: adw::PreferencesGroup,
|
||||
pub(crate) logout_button: gtk4::Button,
|
||||
/// "Start on login" toggle, reflecting the systemd unit's enabled state.
|
||||
pub(crate) autostart_row: adw::SwitchRow,
|
||||
/// Cache soft-cap editor, in GiB; `0` = unlimited.
|
||||
pub(crate) budget_row: adw::SpinRow,
|
||||
/// Purges all unpinned cached content.
|
||||
pub(crate) purge_button: gtk4::Button,
|
||||
/// Shows the active mountpoint; its suffix button opens a folder chooser.
|
||||
pub(crate) mountpoint_row: adw::ActionRow,
|
||||
pub(crate) mountpoint_button: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The main (logged-in) page: a libadwaita settings surface — account header,
|
||||
/// mount status, storage controls (cache budget + purge), system integration
|
||||
/// (start-on-login, mountpoint), the pin list, and developer overrides. Returns
|
||||
/// the widgets the refresh loop updates plus the controls to wire.
|
||||
pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
// Account group: identity + sign-out.
|
||||
let account_group = adw::PreferencesGroup::new();
|
||||
let account_row = adw::ActionRow::builder().title("Not signed in").build();
|
||||
let avatar = adw::Avatar::new(40, None, true);
|
||||
account_row.add_prefix(&avatar);
|
||||
let logout_button = gtk4::Button::builder()
|
||||
.label("Sign out")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
logout_button.add_css_class("flat");
|
||||
account_row.add_suffix(&logout_button);
|
||||
account_group.add(&account_row);
|
||||
|
||||
// Mount group: a read-only status line. The mount is managed automatically
|
||||
// by the systemd user service; there is no toggle to fiddle with.
|
||||
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
|
||||
let mount_row = adw::ActionRow::builder()
|
||||
.title("Proton Drive")
|
||||
.subtitle("Not mounted")
|
||||
.build();
|
||||
mount_group.add(&mount_row);
|
||||
|
||||
// Activity group: live upload/download progress. Hidden until the refresh
|
||||
// loop sees an in-flight transfer from `Request::GetQueueStatus`.
|
||||
let transfers_group = adw::PreferencesGroup::builder()
|
||||
.title("Activity")
|
||||
.description("Files moving to and from Proton Drive.")
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
// Storage group: a progress bar + "X of Y used" label, plus the cache-budget
|
||||
// editor and a purge button. `budget_row`/`purge_button` are wired in
|
||||
// `wire_settings`; the bar + label are repainted by the refresh loop.
|
||||
let storage_group = adw::PreferencesGroup::builder()
|
||||
.title("Storage")
|
||||
.description("Local cache for pinned and recently opened files.")
|
||||
.build();
|
||||
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
storage_box.set_margin_top(6);
|
||||
storage_box.set_margin_bottom(6);
|
||||
let cache_bar = gtk4::ProgressBar::new();
|
||||
let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
cache_label.add_css_class("dim-label");
|
||||
storage_box.append(&cache_bar);
|
||||
storage_box.append(&cache_label);
|
||||
let usage_row = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&storage_box)
|
||||
.build();
|
||||
storage_group.add(&usage_row);
|
||||
|
||||
// Cache budget, expressed in GiB. 0 = unlimited; the daemon applies a 0 cap
|
||||
// as "no eviction". Step in 0.5 GiB; the upper bound is generous.
|
||||
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
|
||||
let budget_row = adw::SpinRow::builder()
|
||||
.title("Cache budget (GiB)")
|
||||
.subtitle("Soft cap for cached content; 0 = unlimited.")
|
||||
.adjustment(&budget_adj)
|
||||
.digits(1)
|
||||
.build();
|
||||
storage_group.add(&budget_row);
|
||||
let purge_row = adw::ActionRow::builder()
|
||||
.title("Purge cache")
|
||||
.subtitle("Delete cached content. Pinned files are kept.")
|
||||
.build();
|
||||
let purge_button = gtk4::Button::builder()
|
||||
.label("Purge")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
purge_button.add_css_class("destructive-action");
|
||||
purge_row.add_suffix(&purge_button);
|
||||
storage_group.add(&purge_row);
|
||||
|
||||
// System integration: start-on-login + mountpoint chooser.
|
||||
let system_group = adw::PreferencesGroup::builder()
|
||||
.title("System integration")
|
||||
.build();
|
||||
let autostart_row = adw::SwitchRow::builder()
|
||||
.title("Start on login")
|
||||
.subtitle("Mount Proton Drive automatically when you log in.")
|
||||
.build();
|
||||
system_group.add(&autostart_row);
|
||||
let mountpoint_row = adw::ActionRow::builder()
|
||||
.title("Mountpoint")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
let mountpoint_button = gtk4::Button::builder()
|
||||
.label("Change")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
mountpoint_button.add_css_class("flat");
|
||||
mountpoint_row.add_suffix(&mountpoint_button);
|
||||
system_group.add(&mountpoint_row);
|
||||
|
||||
// Pins group: filled in by refresh.
|
||||
let pins_group = adw::PreferencesGroup::builder()
|
||||
.title("Pinned files")
|
||||
.description("Kept available offline on this device.")
|
||||
.build();
|
||||
|
||||
// Developer overrides: read-only client identity, for support/debugging.
|
||||
let dev_group = adw::PreferencesGroup::builder().title("Developer").build();
|
||||
let version_row = adw::ActionRow::builder()
|
||||
.title("App version")
|
||||
.subtitle(pdfs_core::config::APP_VERSION)
|
||||
.build();
|
||||
version_row.add_css_class("property");
|
||||
let agent_row = adw::ActionRow::builder()
|
||||
.title("User agent")
|
||||
.subtitle(pdfs_core::config::USER_AGENT)
|
||||
.build();
|
||||
agent_row.add_css_class("property");
|
||||
dev_group.add(&version_row);
|
||||
dev_group.add(&agent_row);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&account_group);
|
||||
inner.append(&mount_group);
|
||||
inner.append(&transfers_group);
|
||||
inner.append(&storage_group);
|
||||
inner.append(&system_group);
|
||||
inner.append(&pins_group);
|
||||
inner.append(&dev_group);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(560)
|
||||
.child(&inner)
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
||||
|
||||
(
|
||||
scroll.upcast(),
|
||||
MainWidgets {
|
||||
account_row,
|
||||
mount_row,
|
||||
transfers_group,
|
||||
cache_bar,
|
||||
cache_label,
|
||||
pins_group,
|
||||
logout_button,
|
||||
autostart_row,
|
||||
budget_row,
|
||||
purge_button,
|
||||
mountpoint_row,
|
||||
mountpoint_button,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Bytes per GiB, for the cache-budget editor's unit conversion.
|
||||
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// Wire the Settings-page controls: the cache-budget editor, the purge button,
|
||||
/// the start-on-login switch and the mountpoint chooser. Initial widget state is
|
||||
/// read once from config / systemd here (the refresh loop owns only the live
|
||||
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
|
||||
/// programmatic populate so the change handlers don't fire on it.
|
||||
pub(crate) fn wire_settings(
|
||||
ui: &Rc<Ui>,
|
||||
purge_button: >k4::Button,
|
||||
mountpoint_button: >k4::Button,
|
||||
) {
|
||||
let config = ui.dirs.load_config();
|
||||
|
||||
// Populate from persisted config + the systemd unit state, suppressed.
|
||||
ui.status.settings_suppress.set(true);
|
||||
ui.status
|
||||
.budget_row
|
||||
.set_value(config.resolved_cache_budget() as f64 / GIB);
|
||||
ui.status
|
||||
.mountpoint_row
|
||||
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
|
||||
ui.status.autostart_row.set_active(service::is_enabled());
|
||||
ui.status.settings_suppress.set(false);
|
||||
|
||||
// Cache budget: a user edit applies the new soft cap on the daemon (which
|
||||
// also persists it to config). 0 GiB = unlimited.
|
||||
let ui_budget = ui.clone();
|
||||
ui.status.budget_row.connect_value_notify(move |row| {
|
||||
if ui_budget.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let bytes = (row.value() * GIB).round() as u64;
|
||||
settings_request(
|
||||
&ui_budget,
|
||||
Request::SetCacheBudget { bytes },
|
||||
"Cache budget updated",
|
||||
"Couldn't set cache budget",
|
||||
);
|
||||
});
|
||||
|
||||
// Purge: confirm, then drop all unpinned cached content via the daemon.
|
||||
let ui_purge = ui.clone();
|
||||
purge_button.connect_clicked(move |_| {
|
||||
let ui = ui_purge.clone();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Purge cache")
|
||||
.body("Delete all cached content that isn't pinned? Pinned files stay offline.")
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("purge", "Purge");
|
||||
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "purge" {
|
||||
settings_request(
|
||||
&ui,
|
||||
Request::PurgeCache,
|
||||
"Cache purged",
|
||||
"Couldn't purge cache",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(ui_window(&ui_purge).as_ref());
|
||||
});
|
||||
|
||||
// Start on login: enable/disable the systemd unit without stopping a live
|
||||
// mount (the user can disconnect separately).
|
||||
let ui_auto = ui.clone();
|
||||
ui.status.autostart_row.connect_active_notify(move |row| {
|
||||
if ui_auto.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
if row.is_active() {
|
||||
service::enable();
|
||||
} else {
|
||||
service::disable();
|
||||
}
|
||||
});
|
||||
|
||||
// Mountpoint: pick a folder, persist it, and offer to restart the mount so
|
||||
// the change takes effect (the daemon reads the path on mount).
|
||||
let ui_mp = ui.clone();
|
||||
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||
}
|
||||
|
||||
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
||||
/// confirming with `done` or reporting the daemon's error under `failed`. Unlike
|
||||
/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the
|
||||
/// cache read-out.
|
||||
pub(crate) fn settings_request(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, done),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
|
||||
/// the mount service so the daemon picks it up.
|
||||
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Choose mountpoint folder")
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||
let Ok(folder) = res else { return };
|
||||
let Some(path) = folder.path() else { return };
|
||||
let path_str = path.display().to_string();
|
||||
|
||||
// Persist the choice to config so the next mount uses it.
|
||||
let mut config = ui.dirs.load_config();
|
||||
config.mountpoint = Some(path_str.clone());
|
||||
if let Err(e) = ui.dirs.save_config(&config) {
|
||||
toast_error(&ui, "Couldn't save mountpoint", &e.to_string());
|
||||
return;
|
||||
}
|
||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||
|
||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||
let confirm = adw::AlertDialog::builder()
|
||||
.heading("Restart to apply")
|
||||
.body(format!(
|
||||
"The mountpoint is now “{path_str}”. Restart the Drive mount to use it?"
|
||||
))
|
||||
.build();
|
||||
confirm.add_response("later", "Later");
|
||||
confirm.add_response("restart", "Restart now");
|
||||
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
|
||||
confirm.set_default_response(Some("restart"));
|
||||
confirm.set_close_response("later");
|
||||
confirm.connect_response(None, |_, resp| {
|
||||
if resp == "restart" {
|
||||
service::restart();
|
||||
}
|
||||
});
|
||||
confirm.present(ui_window(&ui).as_ref());
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
||||
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
|
||||
/// reload the page.
|
||||
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||
let ui_browser = ui.clone();
|
||||
ui.browser.retry.clone().connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_browser(&ui_browser);
|
||||
});
|
||||
let ui_gallery = ui.clone();
|
||||
ui.gallery.retry.clone().connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_gallery(&ui_gallery, false);
|
||||
});
|
||||
let ui_trash = ui.clone();
|
||||
ui.trash.retry.clone().connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_trash(&ui_trash);
|
||||
});
|
||||
}
|
||||
|
||||
/// Repaint the window from the cached login identity, then kick an async mount-
|
||||
/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring),
|
||||
/// and the status round-trip is offloaded to a worker so the main loop never
|
||||
/// blocks on a slow or wedged daemon.
|
||||
pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
// Login identity decides which page is shown. Read the cached session — set
|
||||
// at startup and on login/logout — never the keyring.
|
||||
{
|
||||
let session = ui.session.borrow();
|
||||
match session.as_ref() {
|
||||
Some(s) => {
|
||||
// Only pull the user onto a destination when they're sitting on the
|
||||
// login page; otherwise leave whichever page they navigated to.
|
||||
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
}
|
||||
ui.nav.set_collapsed(false);
|
||||
ui.status.account_row.set_title(&s.username);
|
||||
ui.status.account_row.set_subtitle("Proton account");
|
||||
}
|
||||
None => {
|
||||
ui.stack.set_visible_child_name("login");
|
||||
// Collapsed + showing content = the login page owns the window and
|
||||
// no destination is reachable without a session.
|
||||
ui.nav.set_collapsed(true);
|
||||
ui.nav.set_show_content(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refresh_status(ui);
|
||||
refresh_transfers(ui);
|
||||
// Both of these pages show work as it happens, so they follow the tick while
|
||||
// they are on screen. Every other page loads on navigation only.
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("devices") => refresh_sync_folders(ui),
|
||||
Some("activity") => refresh_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the mount state seen by the last status poll: gate every control that
|
||||
/// needs a live daemon, and notify the desktop when the state actually flips.
|
||||
///
|
||||
/// The gating is the point — without it, New Folder / Upload / the details pane's
|
||||
/// actions stay clickable while the mount is down and each click buys a round-trip
|
||||
/// that can only fail. A greyed control says so up front.
|
||||
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||
*ui.mounted.borrow_mut() = mounted;
|
||||
ui.browser.new_folder.set_sensitive(mounted);
|
||||
ui.browser.upload.set_sensitive(mounted);
|
||||
ui.browser.upload_folder.set_sensitive(mounted);
|
||||
ui.gallery.upload.set_sensitive(mounted);
|
||||
ui.details.details.pin_row.set_sensitive(mounted);
|
||||
ui.details.details.rename_button.set_sensitive(mounted);
|
||||
ui.details.details.trash_button.set_sensitive(mounted);
|
||||
ui.details.details.open_button.set_sensitive(mounted);
|
||||
|
||||
// Only notify on a real edge, and never for the first reading: at startup the
|
||||
// service is usually still coming up, and "disconnected" would be a lie.
|
||||
if ui.status.notified_mounted.get() == Some(mounted) {
|
||||
return;
|
||||
}
|
||||
let first = ui.status.notified_mounted.replace(Some(mounted)).is_none();
|
||||
if first {
|
||||
return;
|
||||
}
|
||||
if mounted {
|
||||
notify(
|
||||
"mount-state",
|
||||
"Proton Drive connected",
|
||||
"Your Drive is mounted and available.",
|
||||
);
|
||||
} else {
|
||||
notify(
|
||||
"mount-state",
|
||||
"Proton Drive disconnected",
|
||||
"The mount service stopped. Files aren't available until it restarts.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll the daemon's in-flight transfers on a worker thread and repaint the
|
||||
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
|
||||
/// two cheap polls on the 2s tick don't gate each other. The group hides itself
|
||||
/// when nothing is moving, so an idle account shows no Activity section.
|
||||
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
|
||||
if ui.status.transfers_inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.status.transfers_inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.status.transfers_inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs),
|
||||
// Daemon unreachable or odd reply: clear the section rather than
|
||||
// leave stale progress bars frozen on screen.
|
||||
_ => repaint_transfers(&ui, &[], &[]),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the Activity group from a work snapshot: the daemon's jobs (scans,
|
||||
/// batch counts, the local index) above the files moving under them. Rebuilds the
|
||||
/// rows only when the set changes (count differs); on the common steady tick it
|
||||
/// updates each bar's fraction and the label in place, so progress animates
|
||||
/// without flicker. Hides the whole group when the daemon is idle.
|
||||
pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[JobItem]) {
|
||||
let lines: Vec<ActivityLine> = jobs
|
||||
.iter()
|
||||
.map(job_line)
|
||||
.chain(items.iter().map(transfer_line))
|
||||
.collect();
|
||||
|
||||
// The wire carries in-flight transfers only, with no completion event: the
|
||||
// count falling to zero is what "the sync finished" looks like from here.
|
||||
// Jobs are deliberately not counted — a bulk upload retires its scan job and
|
||||
// starts its upload job mid-flight, which is not a thing finishing.
|
||||
let previous = ui.status.active_transfers.replace(items.len());
|
||||
if items.is_empty() && previous > 0 {
|
||||
let files = if previous == 1 {
|
||||
"1 file".to_string()
|
||||
} else {
|
||||
format!("{previous} files")
|
||||
};
|
||||
notify(
|
||||
"sync-complete",
|
||||
"Sync complete",
|
||||
&format!("{files} finished transferring."),
|
||||
);
|
||||
// A just-finished batch may have added files (bulk upload) the current
|
||||
// listing doesn't show yet; refresh whichever listing is on screen.
|
||||
reload_listing(ui);
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
if !ui.status.transfer_rows.borrow().is_empty() {
|
||||
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
|
||||
ui.status.transfers_group.remove(&tr.row);
|
||||
}
|
||||
}
|
||||
ui.status.transfers_group.set_visible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ui.status.transfers_group.set_visible(true);
|
||||
|
||||
// Rebuild rows only when the count changes; otherwise reuse them in place.
|
||||
if ui.status.transfer_rows.borrow().len() != lines.len() {
|
||||
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
|
||||
ui.status.transfers_group.remove(&tr.row);
|
||||
}
|
||||
for _ in &lines {
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
row_box.set_margin_top(8);
|
||||
row_box.set_margin_bottom(8);
|
||||
let label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
label.add_css_class("dim-label");
|
||||
let bar = gtk4::ProgressBar::new();
|
||||
row_box.append(&label);
|
||||
row_box.append(&bar);
|
||||
let row = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&row_box)
|
||||
.build();
|
||||
ui.status.transfers_group.add(&row);
|
||||
ui.status
|
||||
.transfer_rows
|
||||
.borrow_mut()
|
||||
.push(TransferRow { row, label, bar });
|
||||
}
|
||||
}
|
||||
|
||||
for (line, tr) in lines.iter().zip(ui.status.transfer_rows.borrow().iter()) {
|
||||
tr.label.set_text(&line.text);
|
||||
match line.fraction {
|
||||
Some(f) => tr.bar.set_fraction(f),
|
||||
// No total to divide by: pulse so the bar still reads as "working".
|
||||
None => tr.bar.pulse(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One Activity row for a daemon job: its title, plus whatever it can say about
|
||||
/// where it is — a count when it has one, else what it is currently chewing on.
|
||||
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
|
||||
let text = match (j.total > 0, j.detail.is_empty()) {
|
||||
(true, true) => format!("{} — {} of {}", j.title, j.done, j.total),
|
||||
(true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total),
|
||||
(false, true) => format!("{}…", j.title),
|
||||
(false, false) => format!("{} — {}…", j.title, j.detail),
|
||||
};
|
||||
ActivityLine {
|
||||
text,
|
||||
fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One Activity row for a file in flight: which way it's going, how far, how fast.
|
||||
pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
|
||||
let arrow = match t.direction {
|
||||
TransferDirection::Download => "↓",
|
||||
TransferDirection::Upload => "↑",
|
||||
};
|
||||
if t.bytes_total == 0 {
|
||||
ActivityLine {
|
||||
text: format!(
|
||||
"{arrow} {} — {} ({}/s)",
|
||||
t.name,
|
||||
human_bytes(t.bytes_completed),
|
||||
human_bytes(t.speed_bytes_sec),
|
||||
),
|
||||
fraction: None,
|
||||
}
|
||||
} else {
|
||||
ActivityLine {
|
||||
text: format!(
|
||||
"{arrow} {} — {} of {} ({}/s)",
|
||||
t.name,
|
||||
human_bytes(t.bytes_completed),
|
||||
human_bytes(t.bytes_total),
|
||||
human_bytes(t.speed_bytes_sec),
|
||||
),
|
||||
fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch mount status + cache stats from the daemon on a worker thread and repaint
|
||||
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
|
||||
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
|
||||
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
|
||||
/// the tick can't stack threads on a stalled daemon.
|
||||
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
if ui.status.status_inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.status.status_inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::Status);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.status.status_inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Status {
|
||||
mountpoint,
|
||||
used,
|
||||
budget,
|
||||
pins,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
})) => {
|
||||
set_mounted(&ui, true);
|
||||
// The queue is the more useful thing to say when it has anything
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = pending_summary(pending_uploads, pending_changes);
|
||||
ui.status.mount_row.set_subtitle(&match (online, queued) {
|
||||
(true, None) => format!("Mounted at {mountpoint}"),
|
||||
(true, Some(q)) => format!("Mounted at {mountpoint} — {q}"),
|
||||
(false, None) => {
|
||||
format!("Mounted at {mountpoint} — offline, cached files only")
|
||||
}
|
||||
(false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"),
|
||||
});
|
||||
let fraction = if budget == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used as f64 / budget as f64).min(1.0)
|
||||
};
|
||||
ui.status.cache_bar.set_fraction(fraction);
|
||||
ui.status.cache_label.set_text(&format!(
|
||||
"{} of {} used",
|
||||
human_bytes(used),
|
||||
human_bytes(budget)
|
||||
));
|
||||
repaint_pins(&ui, &pins, true);
|
||||
}
|
||||
// Daemon unreachable (still starting, or down): report not-mounted and
|
||||
// grey out the unpin buttons in place, but leave the last-known pin
|
||||
// rows and cache read-out so the page doesn't flicker on a blip.
|
||||
_ => {
|
||||
set_mounted(&ui, false);
|
||||
ui.status.mount_row.set_subtitle("Not mounted");
|
||||
for r in ui.status.pin_rows.borrow().iter() {
|
||||
if let Some(b) = &r.unpin {
|
||||
b.set_sensitive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
|
||||
/// mount daemon is running (`mounted`). Diffs against the last batch by path: when
|
||||
/// the set is unchanged (the common case on the 2s tick) it only flips the unpin
|
||||
/// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list
|
||||
/// and drop scroll/selection every tick.
|
||||
pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted: bool) {
|
||||
let desired: Vec<String> = pins.iter().map(|p| p.path.clone()).collect();
|
||||
if ui.status.pins_state.borrow().as_ref() == Some(&desired) {
|
||||
for r in ui.status.pin_rows.borrow().iter() {
|
||||
if let Some(b) = &r.unpin {
|
||||
b.set_sensitive(mounted);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for pr in ui.status.pin_rows.borrow_mut().drain(..) {
|
||||
ui.status.pins_group.remove(&pr.row);
|
||||
}
|
||||
*ui.status.pins_state.borrow_mut() = Some(desired);
|
||||
|
||||
if pins.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No pinned files")
|
||||
.subtitle("Right-click a file in the mount to keep it offline.")
|
||||
.build();
|
||||
ui.status.pins_group.add(&row);
|
||||
ui.status
|
||||
.pin_rows
|
||||
.borrow_mut()
|
||||
.push(PinRow { row, unpin: None });
|
||||
return;
|
||||
}
|
||||
|
||||
for pin in pins {
|
||||
let name = Path::new(&pin.path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&pin.path)
|
||||
.to_string();
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&name)
|
||||
.subtitle(&pin.path)
|
||||
.build();
|
||||
let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic");
|
||||
row.add_prefix(&icon);
|
||||
|
||||
let unpin = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Unpin (remove offline copy)")
|
||||
.sensitive(mounted)
|
||||
.build();
|
||||
unpin.add_css_class("flat");
|
||||
let ui_btn = ui.clone();
|
||||
let path = pin.path.clone();
|
||||
unpin.connect_clicked(move |_| {
|
||||
let socket = ui_btn.dirs.control_socket();
|
||||
match send(&socket, &Request::Unpin { path: path.clone() }) {
|
||||
Ok(Response::Error { message, .. }) => tracing::error!("unpin failed: {message}"),
|
||||
Ok(_) => refresh(&ui_btn),
|
||||
Err(e) => tracing::error!("unpin request failed: {e}"),
|
||||
}
|
||||
});
|
||||
row.add_suffix(&unpin);
|
||||
|
||||
ui.status.pins_group.add(&row);
|
||||
ui.status.pin_rows.borrow_mut().push(PinRow {
|
||||
row,
|
||||
unpin: Some(unpin),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct TrashState {
|
||||
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
|
||||
// keeps no current-directory state — it re-lists from the daemon on show.
|
||||
pub(crate) model: gio::ListStore,
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
/// Empties the trash; insensitive while it is empty (or unread).
|
||||
pub(crate) empty: gtk4::Button,
|
||||
/// "12 items" under the page title.
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
}
|
||||
|
||||
/// The widgets of the Trash page that a load repaints.
|
||||
pub(crate) struct TrashWidgets {
|
||||
pub(crate) model: gio::ListStore,
|
||||
pub(crate) list: gtk4::ListView,
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) empty: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
}
|
||||
|
||||
/// The Trash page: a flat list of everything Drive is holding in the trash, each
|
||||
/// row offering Restore and Delete Forever, with Empty Trash in the header.
|
||||
///
|
||||
/// A trashed node has no path inside the mount — the daemon forgets it when it is
|
||||
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
||||
/// re-lists from the server rather than from a cached listing. Row rendering needs
|
||||
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
|
||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Trash")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
subtitle.add_css_class("dim-label");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
titles.append(&subtitle);
|
||||
|
||||
let empty = gtk4::Button::builder()
|
||||
.label("Empty Trash")
|
||||
.valign(gtk4::Align::Center)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
empty.add_css_class("destructive-action");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&empty);
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
retry.add_css_class("suggested-action");
|
||||
retry.set_visible(false);
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.vexpand(true)
|
||||
.child(&retry)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
// No selection model: every action lives on the row it acts on, so a
|
||||
// mis-aimed Delete Forever isn't one click away from a stale selection.
|
||||
let list = gtk4::ListView::builder()
|
||||
.model(>k4::NoSelection::new(Some(model.clone())))
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&list)
|
||||
.build();
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&content);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
TrashWidgets {
|
||||
model,
|
||||
list,
|
||||
content,
|
||||
status,
|
||||
retry,
|
||||
empty,
|
||||
refresh,
|
||||
subtitle,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
||||
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
|
||||
/// captured copy, so a recycled row always acts on the item it currently shows.
|
||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Button) {
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
let ui_setup = ui.clone();
|
||||
factory.connect_setup(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
|
||||
let icon = gtk4::Image::builder().pixel_size(32).build();
|
||||
let name = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
meta.add_css_class("dim-label");
|
||||
meta.add_css_class("caption");
|
||||
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
text.set_hexpand(true);
|
||||
text.set_valign(gtk4::Align::Center);
|
||||
text.append(&name);
|
||||
text.append(&meta);
|
||||
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Restore to its original folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let purge = gtk4::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
.tooltip_text("Delete permanently")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
purge.add_css_class("flat");
|
||||
|
||||
let ui_restore = ui_setup.clone();
|
||||
let item_restore = item.clone();
|
||||
restore.connect_clicked(move |_| {
|
||||
if let Some(entry) = bound_entry(&item_restore) {
|
||||
restore_entry(&ui_restore, &entry);
|
||||
}
|
||||
});
|
||||
let ui_purge = ui_setup.clone();
|
||||
let item_purge = item.clone();
|
||||
purge.connect_clicked(move |_| {
|
||||
if let Some(entry) = bound_entry(&item_purge) {
|
||||
prompt_delete_forever(&ui_purge, &entry);
|
||||
}
|
||||
});
|
||||
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
row.set_margin_top(6);
|
||||
row.set_margin_bottom(6);
|
||||
row.set_margin_start(6);
|
||||
row.set_margin_end(6);
|
||||
row.append(&icon);
|
||||
row.append(&text);
|
||||
row.append(&restore);
|
||||
row.append(&purge);
|
||||
item.set_child(Some(&row));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(entry) = bound_entry(item) else {
|
||||
return;
|
||||
};
|
||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
|
||||
return;
|
||||
};
|
||||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
||||
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||
name.set_label(&entry.name);
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
let kind = if entry.is_dir {
|
||||
"Folder".to_string()
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
};
|
||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
||||
}
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
|
||||
let ui_empty = ui.clone();
|
||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||
}
|
||||
|
||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
||||
/// (recycled) row.
|
||||
pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
|
||||
let entry = obj.borrow::<DirEntry>().clone();
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Fetch the trash listing and repaint the page.
|
||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
// Drop the old rows first: a stale row here would offer Restore on something
|
||||
// that may already be gone.
|
||||
ui.trash.model.remove_all();
|
||||
ui.trash.empty.set_sensitive(false);
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
"Loading…",
|
||||
"Reading the trash.",
|
||||
false,
|
||||
);
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
|
||||
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read the trash",
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => trash_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read the trash",
|
||||
"Unexpected reply from the mount service.",
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer. Same split as the Files page: still starting (poll
|
||||
/// again, no button) versus actually down (Retry, which restarts the service).
|
||||
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
trash_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
trash_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("trash") {
|
||||
load_trash(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a status page in place of the trash list.
|
||||
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.trash.status.set_icon_name(Some(icon));
|
||||
ui.trash.status.set_title(title);
|
||||
ui.trash.status.set_description(Some(description));
|
||||
ui.trash.retry.set_visible(retry);
|
||||
ui.trash.content.set_visible_child_name("status");
|
||||
ui.trash.subtitle.set_label("");
|
||||
}
|
||||
|
||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
||||
/// user looks for what they just deleted.
|
||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.trash.model.remove_all();
|
||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
||||
if entries.is_empty() {
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
"Trash is empty",
|
||||
"Items you delete from Proton Drive show up here.",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.trash.content.set_visible_child_name("list");
|
||||
ui.trash.subtitle.set_label(&match entries.len() {
|
||||
1 => "1 item".to_string(),
|
||||
n => format!("{n} items"),
|
||||
});
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
||||
for entry in sorted {
|
||||
ui.trash.model.append(&BoxedAnyObject::new(entry));
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore one trashed entry to the folder it was trashed from.
|
||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let name = entry.name.clone();
|
||||
run_mutation(
|
||||
ui,
|
||||
Request::Restore {
|
||||
uids: vec![entry.uid.clone()],
|
||||
},
|
||||
format!("Restored “{name}”"),
|
||||
"Couldn't restore",
|
||||
);
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
|
||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let win = ui_window(ui);
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Delete permanently")
|
||||
.body(format!(
|
||||
"Permanently delete “{name}”? This cannot be undone."
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete Permanently");
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "delete" {
|
||||
run_mutation(
|
||||
&ui,
|
||||
Request::DeleteForever {
|
||||
uids: vec![uid.clone()],
|
||||
},
|
||||
format!("Deleted “{name}” permanently"),
|
||||
"Couldn't delete",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete everything in the trash.
|
||||
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let count = ui.trash.model.n_items();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Empty Trash")
|
||||
.body(format!(
|
||||
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("empty", "Empty Trash");
|
||||
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "empty" {
|
||||
run_mutation(
|
||||
&ui,
|
||||
Request::EmptyTrash,
|
||||
"Trash emptied".to_string(),
|
||||
"Couldn't empty the trash",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct DetailsState {
|
||||
/// Widgets in the details pane, repainted from the selected entry.
|
||||
pub(crate) details: DetailsWidgets,
|
||||
/// The entry the details pane is currently showing, so its buttons act on the
|
||||
/// same one the user is looking at even after the model is repopulated.
|
||||
pub(crate) details_entry: RefCell<Option<DirEntry>>,
|
||||
/// Set while the details pane is being populated, so setting the offline
|
||||
/// switch programmatically doesn't fire a pin/unpin round-trip.
|
||||
pub(crate) details_suppress: Cell<bool>,
|
||||
/// The grid's and list's selection models. Both wrap the one browser model, so
|
||||
/// a selection in either drives the details pane.
|
||||
pub(crate) grid_selection: gtk4::SingleSelection,
|
||||
pub(crate) list_selection: gtk4::SingleSelection,
|
||||
}
|
||||
|
||||
/// The widgets in the browser's details pane that a selection repaints.
|
||||
pub(crate) struct DetailsWidgets {
|
||||
pub(crate) icon: gtk4::Image,
|
||||
pub(crate) name: gtk4::Label,
|
||||
pub(crate) kind: gtk4::Label,
|
||||
pub(crate) size_row: adw::ActionRow,
|
||||
pub(crate) modified_row: adw::ActionRow,
|
||||
pub(crate) path_row: adw::ActionRow,
|
||||
pub(crate) pin_row: adw::SwitchRow,
|
||||
pub(crate) open_button: gtk4::Button,
|
||||
pub(crate) rename_button: gtk4::Button,
|
||||
pub(crate) trash_button: gtk4::Button,
|
||||
pub(crate) close_button: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The details pane shown beside the file views: a big type icon over the entry's
|
||||
/// name, its properties, an offline (pin) toggle and the primary actions. Built
|
||||
/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and
|
||||
/// [`wire_details`] connects the buttons.
|
||||
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
let close_button = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Close details")
|
||||
.halign(gtk4::Align::End)
|
||||
.build();
|
||||
close_button.add_css_class("flat");
|
||||
close_button.add_css_class("circular");
|
||||
|
||||
let icon = gtk4::Image::builder()
|
||||
.icon_name("text-x-generic-symbolic")
|
||||
.pixel_size(64)
|
||||
.build();
|
||||
let name = gtk4::Label::builder()
|
||||
.wrap(true)
|
||||
.wrap_mode(gtk4::pango::WrapMode::WordChar)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.build();
|
||||
name.add_css_class("title-4");
|
||||
let kind = gtk4::Label::new(None);
|
||||
kind.add_css_class("dim-label");
|
||||
kind.add_css_class("caption");
|
||||
|
||||
let head = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
head.set_margin_bottom(6);
|
||||
head.append(&icon);
|
||||
head.append(&name);
|
||||
head.append(&kind);
|
||||
|
||||
let props = adw::PreferencesGroup::new();
|
||||
let size_row = adw::ActionRow::builder()
|
||||
.title("Size")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
size_row.add_css_class("property");
|
||||
let modified_row = adw::ActionRow::builder()
|
||||
.title("Modified")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
modified_row.add_css_class("property");
|
||||
let path_row = adw::ActionRow::builder()
|
||||
.title("Location")
|
||||
.subtitle("—")
|
||||
.subtitle_lines(3)
|
||||
.build();
|
||||
path_row.add_css_class("property");
|
||||
props.add(&size_row);
|
||||
props.add(&modified_row);
|
||||
props.add(&path_row);
|
||||
|
||||
let offline = adw::PreferencesGroup::new();
|
||||
let pin_row = adw::SwitchRow::builder()
|
||||
.title("Available offline")
|
||||
.subtitle("Keep a local copy on this device.")
|
||||
.build();
|
||||
offline.add(&pin_row);
|
||||
|
||||
let open_button = gtk4::Button::builder().label("Open").build();
|
||||
open_button.add_css_class("suggested-action");
|
||||
open_button.add_css_class("pill");
|
||||
let rename_button = gtk4::Button::builder().label("Rename").build();
|
||||
rename_button.add_css_class("pill");
|
||||
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
|
||||
trash_button.add_css_class("destructive-action");
|
||||
trash_button.add_css_class("pill");
|
||||
|
||||
let actions = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
actions.set_margin_top(6);
|
||||
actions.append(&open_button);
|
||||
actions.append(&rename_button);
|
||||
actions.append(&trash_button);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&close_button);
|
||||
inner.append(&head);
|
||||
inner.append(&props);
|
||||
inner.append(&offline);
|
||||
inner.append(&actions);
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&inner)
|
||||
.build();
|
||||
let pane = adw::ToolbarView::new();
|
||||
pane.set_content(Some(&scroll));
|
||||
pane.add_css_class("background");
|
||||
|
||||
(
|
||||
pane.upcast(),
|
||||
DetailsWidgets {
|
||||
icon,
|
||||
name,
|
||||
kind,
|
||||
size_row,
|
||||
modified_row,
|
||||
path_row,
|
||||
pin_row,
|
||||
open_button,
|
||||
rename_button,
|
||||
trash_button,
|
||||
close_button,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Connect the details pane: mirror the grid's and list's selection into it, and
|
||||
/// wire its buttons back onto the entry it's showing.
|
||||
pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
// Both views share one model but have their own selection, so watch both.
|
||||
for selection in [
|
||||
ui.details.grid_selection.clone(),
|
||||
ui.details.list_selection.clone(),
|
||||
] {
|
||||
let ui_sel = ui.clone();
|
||||
selection.connect_selection_changed(move |sel, _, _| {
|
||||
match entry_at(sel.model().as_ref(), sel.selected()) {
|
||||
Some(entry) => show_details(&ui_sel, &entry),
|
||||
None => hide_details(&ui_sel),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let ui_close = ui.clone();
|
||||
ui.details.details.close_button.connect_clicked(move |_| {
|
||||
ui_close
|
||||
.details
|
||||
.grid_selection
|
||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
||||
ui_close
|
||||
.details
|
||||
.list_selection
|
||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
||||
hide_details(&ui_close);
|
||||
});
|
||||
|
||||
let ui_open = ui.clone();
|
||||
ui.details.details.open_button.connect_clicked(move |_| {
|
||||
// Bind the clone to a local so the `Ref` from `borrow()` is dropped before
|
||||
// `activate_entry` runs; it navigates and repaints the details pane, which
|
||||
// takes `details_entry.borrow_mut()` and would panic against a live borrow.
|
||||
let entry = ui_open.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
activate_entry(&ui_open, &entry);
|
||||
}
|
||||
});
|
||||
let ui_rename = ui.clone();
|
||||
ui.details.details.rename_button.connect_clicked(move |_| {
|
||||
let entry = ui_rename.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
prompt_rename(&ui_rename, &entry);
|
||||
}
|
||||
});
|
||||
let ui_trash = ui.clone();
|
||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
prompt_delete(&ui_trash, &entry);
|
||||
}
|
||||
});
|
||||
let ui_pin = ui.clone();
|
||||
ui.details
|
||||
.details
|
||||
.pin_row
|
||||
.connect_active_notify(move |row| {
|
||||
if ui_pin.details.details_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let Some(entry) = ui_pin.details.details_entry.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
// The switch reads the *desired* state; `toggle_pin` derives the request
|
||||
// from the entry's current one, so only act when they actually differ.
|
||||
if row.is_active() != entry.pinned {
|
||||
toggle_pin(&ui_pin, &entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal the details pane and paint it from `entry`.
|
||||
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
ui.details.details_suppress.set(true);
|
||||
let d = &ui.details.details;
|
||||
d.icon.set_icon_name(Some(icon_base_for(entry)));
|
||||
d.name.set_label(&entry.name);
|
||||
d.kind
|
||||
.set_label(if entry.is_dir { "Folder" } else { "File" });
|
||||
d.size_row.set_subtitle(&if entry.is_dir {
|
||||
"—".to_string()
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
});
|
||||
d.size_row.set_visible(!entry.is_dir);
|
||||
d.modified_row
|
||||
.set_subtitle(&format_modified(entry.modified));
|
||||
let rel = entry_rel(ui, entry);
|
||||
let parent = match rel.rfind('/') {
|
||||
Some(i) => &rel[..i],
|
||||
None => "",
|
||||
};
|
||||
d.path_row.set_subtitle(if parent.is_empty() {
|
||||
"Proton Drive"
|
||||
} else {
|
||||
parent
|
||||
});
|
||||
d.pin_row.set_active(entry.pinned);
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.open_button
|
||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||
ui.details.details_suppress.set(false);
|
||||
|
||||
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
||||
// Reveal the pane on idle, not inline. This runs from `selection_changed`,
|
||||
// which fires on the *first* press of a double-click; mutating the widget
|
||||
// tree here cancels GtkGridView's multi-press tracking, so the second press
|
||||
// restarts the count and `activate` never fires — the folder needs two
|
||||
// double-clicks. Deferring lets the click gesture finish first. Guard on the
|
||||
// entry still being present so a navigation that clears it wins the race.
|
||||
let ui = ui.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
if ui.details.details_entry.borrow().is_some() {
|
||||
ui.browser.split.set_show_sidebar(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Hide the details pane and forget the entry it was showing, so a stale entry
|
||||
/// can't be acted on after the listing moves on.
|
||||
pub(crate) fn hide_details(ui: &Rc<Ui>) {
|
||||
ui.browser.split.set_show_sidebar(false);
|
||||
*ui.details.details_entry.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// The entry highlighted in whichever browser view is on screen, if any. Backs the
|
||||
/// F2 / Delete shortcuts.
|
||||
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||
ui.details.details_entry.borrow().clone()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod share_dialog;
|
||||
@@ -0,0 +1,465 @@
|
||||
use crate::*;
|
||||
|
||||
/// The roles offered in the Share dialog's dropdowns, in index order.
|
||||
pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"];
|
||||
|
||||
/// Map a role dropdown index to the wire role string.
|
||||
pub(crate) fn role_index_to_wire(idx: u32) -> &'static str {
|
||||
match idx {
|
||||
1 => "editor",
|
||||
2 => "admin",
|
||||
_ => "viewer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a wire role string to its dropdown index.
|
||||
pub(crate) fn role_wire_to_index(role: &str) -> u32 {
|
||||
match role {
|
||||
"editor" => 1,
|
||||
"admin" => 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The mutable state behind an open Share dialog, so the invite/role/link
|
||||
/// handlers can rebuild the people and link sections after each change without
|
||||
/// tearing the whole dialog down.
|
||||
pub(crate) struct ShareDialog {
|
||||
pub(crate) ui: Rc<Ui>,
|
||||
/// The node's mountpoint-relative path — how every request addresses it.
|
||||
pub(crate) rel: String,
|
||||
pub(crate) people: adw::PreferencesGroup,
|
||||
pub(crate) link_group: adw::PreferencesGroup,
|
||||
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) link_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
}
|
||||
|
||||
/// Open the per-node Share dialog: invite Proton/external users, manage who has
|
||||
/// access and their roles, and create/copy/remove a public link.
|
||||
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
||||
return;
|
||||
}
|
||||
let rel = entry_rel(ui, entry);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name)));
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
// Invite section: emails + role + optional message.
|
||||
let invite_group = adw::PreferencesGroup::builder()
|
||||
.title("Invite people")
|
||||
.description("Proton and non-Proton email addresses, separated by spaces or commas.")
|
||||
.build();
|
||||
let email_row = adw::EntryRow::builder().title("Email addresses").build();
|
||||
let role_model = gtk4::StringList::new(&SHARE_ROLES);
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_wrap = adw::ActionRow::builder().title("Role").build();
|
||||
role_wrap.add_suffix(&role_drop);
|
||||
let message_row = adw::EntryRow::builder().title("Message (optional)").build();
|
||||
let invite_btn = gtk4::Button::builder()
|
||||
.label("Send Invitations")
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
invite_btn.add_css_class("suggested-action");
|
||||
let invite_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&invite_btn)
|
||||
.build();
|
||||
invite_group.add(&email_row);
|
||||
invite_group.add(&role_wrap);
|
||||
invite_group.add(&message_row);
|
||||
invite_group.add(&invite_wrap);
|
||||
|
||||
let people = adw::PreferencesGroup::builder()
|
||||
.title("People with access")
|
||||
.build();
|
||||
let link_group = adw::PreferencesGroup::builder()
|
||||
.title("Public link")
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&invite_group);
|
||||
content.append(&people);
|
||||
content.append(&link_group);
|
||||
let clamp = adw::Clamp::builder().child(&content).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
toolbar.set_content(Some(&scroll));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Share")
|
||||
.content_width(480)
|
||||
.content_height(560)
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
|
||||
let state = Rc::new(ShareDialog {
|
||||
ui: ui.clone(),
|
||||
rel,
|
||||
people,
|
||||
link_group,
|
||||
people_rows: RefCell::new(Vec::new()),
|
||||
link_rows: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// Enter in either free-text field sends the invitations, no mouse needed.
|
||||
let btn = invite_btn.clone();
|
||||
email_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
let btn = invite_btn.clone();
|
||||
message_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
// Invite button.
|
||||
let state_inv = state.clone();
|
||||
invite_btn.connect_clicked(move |_| {
|
||||
let raw = email_row.text();
|
||||
let emails: Vec<String> = raw
|
||||
.split([',', ' ', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
if emails.is_empty() {
|
||||
toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email.");
|
||||
return;
|
||||
}
|
||||
let role = role_index_to_wire(role_drop.selected()).to_string();
|
||||
let msg = message_row.text().trim().to_string();
|
||||
let message = if msg.is_empty() { None } else { Some(msg) };
|
||||
let email_clear = email_row.clone();
|
||||
let msg_clear = message_row.clone();
|
||||
share_dialog_op(
|
||||
&state_inv,
|
||||
Request::ShareNode {
|
||||
path: state_inv.rel.clone(),
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
},
|
||||
"Invitations sent",
|
||||
"Couldn't send invitations",
|
||||
Some(Box::new(move || {
|
||||
email_clear.set_text("");
|
||||
msg_clear.set_text("");
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
share_dialog_reload(&state);
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
let rx = spawn_request(
|
||||
state.ui.dirs.control_socket(),
|
||||
Request::ListShare {
|
||||
path: state.rel.clone(),
|
||||
},
|
||||
);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Share { entries, link })) => {
|
||||
repaint_share_people(&state, &entries);
|
||||
repaint_share_link(&state, link.as_ref());
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, "Couldn't load sharing", &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
"Couldn't load sharing",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the "People with access" rows from a fresh share listing.
|
||||
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
||||
for row in state.people_rows.borrow_mut().drain(..) {
|
||||
state.people.remove(&row);
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
if entries.is_empty() {
|
||||
let row = dim_row("No one else has access yet.");
|
||||
state.people.add(&row);
|
||||
rows.push(row.upcast());
|
||||
*state.people_rows.borrow_mut() = rows;
|
||||
return;
|
||||
}
|
||||
for entry in entries {
|
||||
let subtitle = match entry.kind {
|
||||
ShareEntryKind::Member => "Member".to_string(),
|
||||
ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(),
|
||||
ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.email)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
|
||||
// External invites can't have their role changed; show it read-only.
|
||||
// Members and Proton invites get a role dropdown.
|
||||
if matches!(
|
||||
entry.kind,
|
||||
ShareEntryKind::Member | ShareEntryKind::ProtonInvite
|
||||
) {
|
||||
let model = gtk4::StringList::new(&SHARE_ROLES);
|
||||
let drop = gtk4::DropDown::builder()
|
||||
.model(&model)
|
||||
.selected(role_wire_to_index(&entry.role))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let state_role = state.clone();
|
||||
let id = entry.id.clone();
|
||||
let kind = entry.kind;
|
||||
drop.connect_selected_notify(move |d| {
|
||||
share_dialog_op(
|
||||
&state_role,
|
||||
Request::UpdateShareRole {
|
||||
path: state_role.rel.clone(),
|
||||
id: id.clone(),
|
||||
kind,
|
||||
role: role_index_to_wire(d.selected()).to_string(),
|
||||
},
|
||||
"Role updated",
|
||||
"Couldn't update the role",
|
||||
None,
|
||||
);
|
||||
});
|
||||
row.add_suffix(&drop);
|
||||
} else {
|
||||
let label = gtk4::Label::builder()
|
||||
.label(capitalize(&entry.role))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
row.add_suffix(&label);
|
||||
}
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove access")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let state_rm = state.clone();
|
||||
let id = entry.id.clone();
|
||||
let kind = entry.kind;
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
Request::RemoveShareEntry {
|
||||
path: state_rm.rel.clone(),
|
||||
id: id.clone(),
|
||||
kind,
|
||||
},
|
||||
"Access removed",
|
||||
"Couldn't remove access",
|
||||
None,
|
||||
);
|
||||
});
|
||||
row.add_suffix(&remove);
|
||||
|
||||
state.people.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*state.people_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Rebuild the public-link section: an existing link (copy / remove) or a
|
||||
/// create control.
|
||||
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
|
||||
for row in state.link_rows.borrow_mut().drain(..) {
|
||||
state.link_group.remove(&row);
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
|
||||
match link {
|
||||
Some(link) => {
|
||||
let url = link.url.clone().unwrap_or_default();
|
||||
let subtitle = if link.has_password {
|
||||
format!("Anyone with the link ({}) · password-protected", link.role)
|
||||
} else {
|
||||
format!("Anyone with the link ({})", link.role)
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(if url.is_empty() { "Public link" } else { &url })
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
row.add_css_class("property");
|
||||
|
||||
if !url.is_empty() {
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text("Copy link")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
let state_copy = state.clone();
|
||||
let url_copy = url.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
toast(&state_copy.ui, "Link copied");
|
||||
});
|
||||
row.add_suffix(©);
|
||||
}
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove link")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let state_rm = state.clone();
|
||||
let id = link.id.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
Request::RemovePublicLink {
|
||||
path: state_rm.rel.clone(),
|
||||
id: id.clone(),
|
||||
},
|
||||
"Public link removed",
|
||||
"Couldn't remove the link",
|
||||
None,
|
||||
);
|
||||
});
|
||||
row.add_suffix(&remove);
|
||||
|
||||
state.link_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
None => {
|
||||
let role_model = gtk4::StringList::new(&["Viewer", "Editor"]);
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_row = adw::ActionRow::builder().title("Link role").build();
|
||||
role_row.add_suffix(&role_drop);
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (optional)")
|
||||
.build();
|
||||
let create = gtk4::Button::builder()
|
||||
.label("Create Public Link")
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
create.add_css_class("suggested-action");
|
||||
let create_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&create)
|
||||
.build();
|
||||
|
||||
// Enter in the password field creates the link.
|
||||
let btn = create.clone();
|
||||
pw_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
let state_c = state.clone();
|
||||
let pw_for = pw_row.clone();
|
||||
create.connect_clicked(move |_| {
|
||||
let role = if role_drop.selected() == 1 {
|
||||
"editor"
|
||||
} else {
|
||||
"viewer"
|
||||
}
|
||||
.to_string();
|
||||
let pw = pw_for.text().to_string();
|
||||
let password = if pw.is_empty() { None } else { Some(pw) };
|
||||
share_dialog_create_link(&state_c, role, password);
|
||||
});
|
||||
|
||||
state.link_group.add(&role_row);
|
||||
state.link_group.add(&pw_row);
|
||||
state.link_group.add(&create_wrap);
|
||||
rows.push(role_row.upcast());
|
||||
rows.push(pw_row.upcast());
|
||||
rows.push(create_wrap.upcast());
|
||||
}
|
||||
}
|
||||
*state.link_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Create a public link, then reload the dialog so the copy/remove controls
|
||||
/// replace the create form (and the freshly minted URL is shown).
|
||||
pub(crate) fn share_dialog_create_link(
|
||||
state: &Rc<ShareDialog>,
|
||||
role: String,
|
||||
password: Option<String>,
|
||||
) {
|
||||
let rx = spawn_request(
|
||||
state.ui.dirs.control_socket(),
|
||||
Request::CreatePublicLink {
|
||||
path: state.rel.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
},
|
||||
);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::PublicLink { .. })) => {
|
||||
toast(&state.ui, "Public link created");
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, "Couldn't create the link", &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
"Couldn't create the link",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run a Share-dialog mutation, then reload the dialog on success. `on_success`
|
||||
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload.
|
||||
pub(crate) fn share_dialog_op(
|
||||
state: &Rc<ShareDialog>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
on_success: Option<Box<dyn Fn()>>,
|
||||
) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), req);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
if let Some(cb) = on_success {
|
||||
cb();
|
||||
}
|
||||
toast(&state.ui, done);
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, failed, &message),
|
||||
_ => {
|
||||
// A role dropdown that failed is now out of sync with the server;
|
||||
// reload to snap it back.
|
||||
toast_error(&state.ui, failed, "The mount service didn't respond.");
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,9 @@ use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Tray, TrayService};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
|
||||
use pdfs_core::control::{
|
||||
JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send,
|
||||
};
|
||||
use pdfs_core::service;
|
||||
|
||||
/// How often the tray re-polls the daemon to refresh its menu.
|
||||
@@ -86,9 +88,19 @@ struct DriveTray {
|
||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
match send(socket, &Request::Status) {
|
||||
Ok(Response::Status {
|
||||
mountpoint, pinned, ..
|
||||
mountpoint,
|
||||
pinned,
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
}) => DriveState {
|
||||
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||
line: match (online, pending_summary(pending_uploads, pending_changes)) {
|
||||
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
|
||||
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
|
||||
(false, Some(q)) => format!("Offline — {q}"),
|
||||
},
|
||||
mounted: true,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
||||
@@ -258,6 +270,12 @@ fn main() -> Result<()> {
|
||||
let _ = std::fs::remove_file(&tray_sock);
|
||||
let _lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
|
||||
.context("failed to bind tray single-instance socket")?;
|
||||
// Owner-only like the control socket (B6). This one only guards single
|
||||
// instancing, so a failure is logged rather than fatal — but there is no
|
||||
// reason to leave it reachable by other local users either.
|
||||
if let Err(e) = pdfs_core::config::restrict_socket(&tray_sock) {
|
||||
tracing::warn!(error = %e, "could not restrict tray socket permissions");
|
||||
}
|
||||
|
||||
let socket = dirs.control_socket();
|
||||
let default_mountpoint = dirs.default_mountpoint();
|
||||
|
||||
+110
-12
@@ -173,6 +173,15 @@ impl Hit {
|
||||
fn pinned(&self) -> bool {
|
||||
matches!(self, Hit::Drive(h) if h.pinned)
|
||||
}
|
||||
|
||||
/// Stable identity used to preserve the keyboard cursor across re-renders.
|
||||
/// The bool keeps a Drive and a local hit at the same path distinct.
|
||||
fn key(&self) -> (bool, String) {
|
||||
match self {
|
||||
Hit::Drive(h) => (false, h.path.clone()),
|
||||
Hit::Local(h) => (true, h.path.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the window needs to render and act, in one place so the many
|
||||
@@ -199,6 +208,10 @@ struct Ui {
|
||||
/// then local ones, matching the visual order.
|
||||
visible: RefCell<Vec<Hit>>,
|
||||
cursor: Cell<Option<usize>>,
|
||||
/// The trimmed query the currently-rendered rows belong to. Enter compares
|
||||
/// against it so a keystroke that lands just before a fresher render can't
|
||||
/// open a file from a result set the user has already typed past.
|
||||
rendered_query: RefCell<String>,
|
||||
|
||||
filter: Cell<Filter>,
|
||||
/// Monotonic query id. A reply whose id is stale (the user typed again while
|
||||
@@ -211,6 +224,13 @@ struct Ui {
|
||||
/// further input is ignored.
|
||||
opening: Cell<bool>,
|
||||
indexing: Cell<bool>,
|
||||
/// Set when Enter arrives before the in-flight query has rendered (typing
|
||||
/// then hitting Enter faster than the search debounce). The open is honoured
|
||||
/// against the selected row once the fresh results settle, rather than being
|
||||
/// dropped — so a plain "type, Enter" always opens something.
|
||||
open_pending: Cell<bool>,
|
||||
/// The window, so a deferred open (above) can reach it from `render`.
|
||||
window: RefCell<Option<adw::ApplicationWindow>>,
|
||||
}
|
||||
|
||||
/// A titled group of rows: header (hidden when empty) plus its list.
|
||||
@@ -419,12 +439,16 @@ fn build_window(app: &adw::Application) {
|
||||
local_hits: RefCell::new(Vec::new()),
|
||||
visible: RefCell::new(Vec::new()),
|
||||
cursor: Cell::new(None),
|
||||
rendered_query: RefCell::new(String::new()),
|
||||
filter: Cell::new(Filter::All),
|
||||
query_id: Cell::new(0),
|
||||
pending: Cell::new(0),
|
||||
opening: Cell::new(false),
|
||||
indexing: Cell::new(false),
|
||||
open_pending: Cell::new(false),
|
||||
window: RefCell::new(None),
|
||||
});
|
||||
*ui.window.borrow_mut() = Some(window.clone());
|
||||
|
||||
for (filter, label) in FILTERS {
|
||||
let chip = gtk4::ToggleButton::builder()
|
||||
@@ -469,7 +493,13 @@ fn build_window(app: &adw::Application) {
|
||||
}
|
||||
match key {
|
||||
gtk4::gdk::Key::Escape => {
|
||||
window_key.close();
|
||||
// Spotlight-style: first Escape clears a non-empty query, a
|
||||
// second (or an already-empty box) dismisses the launcher.
|
||||
if ui_key.entry.text().is_empty() {
|
||||
window_key.close();
|
||||
} else {
|
||||
ui_key.entry.set_text("");
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Down => {
|
||||
@@ -486,9 +516,9 @@ fn build_window(app: &adw::Application) {
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter => {
|
||||
if let Some(index) = ui_key.cursor.get() {
|
||||
ui_key.open(index, &window_key);
|
||||
}
|
||||
// Fall back to the top row when nothing is explicitly selected
|
||||
// (e.g. results haven't rendered yet); `open` guards the rest.
|
||||
ui_key.open(ui_key.cursor.get().unwrap_or(0), &window_key);
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ => glib::Propagation::Proceed,
|
||||
@@ -501,6 +531,9 @@ fn build_window(app: &adw::Application) {
|
||||
let debounce: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
|
||||
let ui_changed = ui.clone();
|
||||
entry.connect_changed(move |entry| {
|
||||
// A fresh keystroke supersedes any Enter that was waiting on results:
|
||||
// the user is still refining the query, not asking to open yet.
|
||||
ui_changed.open_pending.set(false);
|
||||
if let Some(source) = debounce.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
@@ -556,6 +589,17 @@ impl Ui {
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon stopped answering after the window was already open. Flip to
|
||||
/// the offline page and lock the entry; the retry button re-runs `connect`.
|
||||
fn go_offline(self: &Rc<Self>) {
|
||||
self.pending.set(0);
|
||||
self.spinner.stop();
|
||||
self.spinner.set_visible(false);
|
||||
self.entry.set_sensitive(false);
|
||||
self.hint.set_label("Daemon offline");
|
||||
self.stack.set_visible_child_name("offline");
|
||||
}
|
||||
|
||||
/// The empty-query view: pinned files, which are the ones the user chose to
|
||||
/// keep on this machine — the closest thing the daemon has to "recent".
|
||||
fn load_suggestions(self: &Rc<Self>) {
|
||||
@@ -567,6 +611,10 @@ impl Ui {
|
||||
if !ui.finish_query(id) {
|
||||
return;
|
||||
}
|
||||
if matches!(reply, Ok(Err(_)) | Err(_)) {
|
||||
ui.go_offline();
|
||||
return;
|
||||
}
|
||||
let hits = match reply {
|
||||
Ok(Ok(Response::Pins { pins })) => pins
|
||||
.into_iter()
|
||||
@@ -612,10 +660,18 @@ impl Ui {
|
||||
return;
|
||||
}
|
||||
match reply {
|
||||
Ok(Ok(Response::SearchResults { hits })) => *ui.drive_hits.borrow_mut() = hits,
|
||||
_ => ui.drive_hits.borrow_mut().clear(),
|
||||
Ok(Ok(Response::SearchResults { hits })) => {
|
||||
*ui.drive_hits.borrow_mut() = hits;
|
||||
ui.render();
|
||||
}
|
||||
// Transport failure: the daemon is gone. Don't paint "No
|
||||
// results" over a crash — say so.
|
||||
Ok(Err(_)) | Err(_) => ui.go_offline(),
|
||||
_ => {
|
||||
ui.drive_hits.borrow_mut().clear();
|
||||
ui.render();
|
||||
}
|
||||
}
|
||||
ui.render();
|
||||
});
|
||||
|
||||
let local = spawn_request(
|
||||
@@ -635,10 +691,14 @@ impl Ui {
|
||||
Ok(Ok(Response::LocalResults { hits, indexing })) => {
|
||||
ui.indexing.set(indexing);
|
||||
*ui.local_hits.borrow_mut() = hits;
|
||||
ui.render();
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => ui.go_offline(),
|
||||
_ => {
|
||||
ui.local_hits.borrow_mut().clear();
|
||||
ui.render();
|
||||
}
|
||||
_ => ui.local_hits.borrow_mut().clear(),
|
||||
}
|
||||
ui.render();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -708,6 +768,13 @@ impl Ui {
|
||||
fn render(self: &Rc<Self>) {
|
||||
let filter = self.filter.get();
|
||||
|
||||
// Identity of the row under the cursor before we rebuild the list, so a
|
||||
// late-arriving second search can't yank the selection back to the top.
|
||||
let selected_key = self
|
||||
.cursor
|
||||
.get()
|
||||
.and_then(|i| self.visible.borrow().get(i).map(Hit::key));
|
||||
|
||||
let drive: Vec<Hit> = self
|
||||
.drive_hits
|
||||
.borrow()
|
||||
@@ -725,7 +792,8 @@ impl Ui {
|
||||
.map(Hit::Local)
|
||||
.collect();
|
||||
|
||||
let searching = !self.entry.text().trim().is_empty();
|
||||
let query = self.entry.text().trim().to_string();
|
||||
let searching = !query.is_empty();
|
||||
let drive_title = if searching {
|
||||
"Proton Drive"
|
||||
} else {
|
||||
@@ -737,7 +805,12 @@ impl Ui {
|
||||
let total = drive.len() + local.len();
|
||||
let mut visible = drive;
|
||||
visible.extend(local);
|
||||
|
||||
// Find where the previously-selected row landed in the rebuilt list.
|
||||
let restored = selected_key.and_then(|key| visible.iter().position(|hit| hit.key() == key));
|
||||
|
||||
*self.visible.borrow_mut() = visible;
|
||||
*self.rendered_query.borrow_mut() = query;
|
||||
|
||||
if total == 0 {
|
||||
self.placeholder.set_title(if searching {
|
||||
@@ -757,10 +830,20 @@ impl Ui {
|
||||
self.cursor.set(None);
|
||||
} else {
|
||||
self.stack.set_visible_child_name("results");
|
||||
self.select(0);
|
||||
self.select(restored.unwrap_or(0));
|
||||
}
|
||||
|
||||
self.hint.set_label(&self.status_text(total, searching));
|
||||
|
||||
// Honour an Enter that arrived before this query rendered, now that the
|
||||
// results (and the cursor) are settled. Only once both searches are in,
|
||||
// so it lands on the final list, not a half-populated one.
|
||||
if self.open_pending.get() && self.pending.get() == 0 {
|
||||
self.open_pending.set(false);
|
||||
if let (Some(index), Some(window)) = (self.cursor.get(), self.window.borrow().clone()) {
|
||||
self.open(index, &window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_text(&self, total: usize, searching: bool) -> String {
|
||||
@@ -845,6 +928,14 @@ impl Ui {
|
||||
if self.opening.get() {
|
||||
return;
|
||||
}
|
||||
// The rows on screen may belong to an older query whose reply is still
|
||||
// settling. Don't act on them — Enter must never launch a file the user
|
||||
// has typed past. But don't drop the intent either: remember it and open
|
||||
// the right row once the fresh results land (see `render`).
|
||||
if self.entry.text().trim() != *self.rendered_query.borrow() {
|
||||
self.open_pending.set(true);
|
||||
return;
|
||||
}
|
||||
let Some(hit) = self.visible.borrow().get(index).cloned() else {
|
||||
return;
|
||||
};
|
||||
@@ -884,7 +975,7 @@ impl Ui {
|
||||
xdg_open(Path::new(&path));
|
||||
window.close();
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => {
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
ui.hint.set_label(&format!("Could not open: {message}"));
|
||||
}
|
||||
_ => ui.hint.set_label("Could not reach the daemon"),
|
||||
@@ -1034,6 +1125,13 @@ fn format_age(epoch_secs: i64) -> String {
|
||||
/// launcher follows the system light/dark theme instead of pinning its own.
|
||||
fn load_css() {
|
||||
let css = "
|
||||
/* Pin the accent to Proton Purple so the HUD matches the main app,
|
||||
which its own process/theme would otherwise leave at the system
|
||||
default (often blue or orange). */
|
||||
@define-color accent_bg_color #6d4aff;
|
||||
@define-color accent_color #6d4aff;
|
||||
@define-color accent_fg_color #ffffff;
|
||||
|
||||
window.launcher-window {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
# Proton Drive Linux Client: Architecture Specification
|
||||
|
||||
This document provides a deep, comprehensive architectural description of the Proton Drive Linux Client (`pdfs`). It specifies the design patterns, data flows, thread models, and subsystem dependencies that govern the virtual filesystem (FUSE), database persistence, cache management, and two-way sync engine.
|
||||
|
||||
---
|
||||
|
||||
## 1. Subsystem Overview & Crate Topology
|
||||
|
||||
The application is modularized into four workspace crates, dividing core library logic, filesystem mounting, control-socket IPC, and front-ends.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
%% Crates
|
||||
CLI["crates/pdfs-cli (CLI & Daemon Entrypoint)"]
|
||||
GUI["crates/pdfs-gui (GTK Front-end)"]
|
||||
FUSE["crates/pdfs-fuse (FUSE VFS & Sync Loop)"]
|
||||
CORE["crates/pdfs-core (DB, Cache & IPC Protocol)"]
|
||||
SDK["proton-sdk-rs (Proton Drive API & Cryptography)"]
|
||||
|
||||
%% Dependencies
|
||||
CLI --> FUSE
|
||||
GUI --> CORE
|
||||
FUSE --> CORE
|
||||
CORE --> SDK
|
||||
CLI -.->|Unix Socket IPC| FUSE
|
||||
GUI -.->|Unix Socket IPC| FUSE
|
||||
```
|
||||
|
||||
### Crate Division & Responsibility Matrix
|
||||
|
||||
| Crate | Primary Role | Key Components | State Management |
|
||||
|---|---|---|---|
|
||||
| [`pdfs-core`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-core) | Core Infrastructure & Services | Cache Bookkeeping, Database migrations/schemas, IPC protocol payloads. | Holds the unified SQLite DB (`Db`) connection and the on-disk cache metadata (`ContentCache`). |
|
||||
| [`pdfs-fuse`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-fuse) | VFS Layer & Reconciliation | FUSE callbacks, background upload queue (`drain`), two-way sync runner. | Manages in-memory inode maps (`State`), active descriptors (`WriteHandle`), and background task threads. |
|
||||
| [`pdfs-cli`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-cli) | Command Line Interface | Command routing, daemon launcher, IPC client wrapper. | Stateless; communicates with daemon over IPC control socket. |
|
||||
| [`pdfs-gui`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-gui) | Graphical Interface | GTK Page timelines (Files, Photos, Shares, Status). | Stateless; polls daemon for status and lists timelines via IPC socket. |
|
||||
|
||||
---
|
||||
|
||||
## 2. In-Memory VFS State & File Operations
|
||||
|
||||
The VFS layer implements FUSE via the `fuser` crate. Because the remote storage contains base64-encoded file keys and requires cryptographic envelope parsing, raw listings and inodes are virtualized and stored in a local state directory.
|
||||
|
||||
### Inode and Path Resolution
|
||||
* **In-Memory Cache (`State`):** Maps FUSE `u64` inodes to Proton Drive `NodeUid`s.
|
||||
* **Database Row Mapping (`StoredNode`):** Stores directories, sizes, and timestamps.
|
||||
* **On-Demand Loading (`ensure_children`):** If a directory is accessed, the daemon checks its database `listed` flag. If `listed = 0`, it triggers an API call to fetch remote nodes, populates the DB and in-memory caches, and returns.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as Kernel (VFS Call)
|
||||
participant FUSE as pdfs-fuse VFS
|
||||
participant ST as State (In-Memory)
|
||||
participant DB as Db (SQLite)
|
||||
participant API as Proton API Client
|
||||
|
||||
User->>FUSE: lookup(parent_ino, "report.pdf")
|
||||
FUSE->>ST: children.get(&parent_ino)
|
||||
alt Parent listing is resident in-memory
|
||||
ST-->>FUSE: returns child_ino
|
||||
else Listing missing in-memory
|
||||
FUSE->>DB: children_if_listed(parent_uid)
|
||||
alt Parent marked listed in DB
|
||||
DB-->>FUSE: returns child node metadata list
|
||||
FUSE->>ST: intern_from_db() and populate children cache
|
||||
else Parent not listed in DB
|
||||
FUSE->>API: enumerate_folder_children_node_uids()
|
||||
API-->>FUSE: list of UIDs
|
||||
FUSE->>API: enumerate_nodes(uids)
|
||||
API-->>FUSE: list of decrypted Nodes
|
||||
FUSE->>DB: upsert_nodes() & set_listed(true)
|
||||
FUSE->>ST: intern_batch() and populate children cache
|
||||
end
|
||||
end
|
||||
FUSE-->>User: returns child inode metadata (attributes, TTL)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Read Path & Block Caching Pipeline
|
||||
|
||||
Read requests are parallelized and served in blocks of size `BLOCK_SIZE` (4 MiB).
|
||||
|
||||
* **Unpinned files (Streaming):** Avoids writing full files to disk to preserve storage. Instead, blocks are kept in a fixed-size `stream_ring` (in-memory ring cache) and evicted immediately.
|
||||
* **Pinned files (Persistent):** Block downloads are saved directly to `ContentCache` on disk.
|
||||
* **Read-Ahead:** The reader thread spawns asynchronous tasks to pre-fetch upcoming blocks.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Kernel as Kernel Read (offset, size)
|
||||
participant FUSE as pdfs-fuse VFS
|
||||
participant Cache as ContentCache (Local Disk)
|
||||
participant Ring as StreamRing (In-Memory)
|
||||
participant API as Proton API Client
|
||||
|
||||
Kernel->>FUSE: read(ino, fh, offset, size)
|
||||
FUSE->>FUSE: calculate block indices [first_block..last_block]
|
||||
loop For each block index
|
||||
alt Block in disk Cache (Pinned/Cache hit)
|
||||
FUSE->>Cache: read_block(block_idx)
|
||||
Cache-->>FUSE: block bytes
|
||||
else Block in stream ring cache
|
||||
FUSE->>Ring: get(block_idx)
|
||||
Ring-->>FUSE: block bytes
|
||||
else Block Cache Miss
|
||||
FUSE->>API: download_range(offset, len)
|
||||
API-->>FUSE: decrypted block bytes
|
||||
alt caching_enabled
|
||||
FUSE->>Cache: store_block(block_idx)
|
||||
else streaming_only
|
||||
FUSE->>Ring: insert(block_idx)
|
||||
end
|
||||
end
|
||||
end
|
||||
FUSE->>FUSE: stitch blocks and slice to offset/size
|
||||
FUSE-->>Kernel: return data buffer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Write Path & Staging/Draining Pipeline
|
||||
|
||||
Because Proton Drive does not support partial byte writes, modified files must be uploaded as whole new revisions.
|
||||
|
||||
1. **Staging writes (`WriteHandle`):** Writes are stored locally in a `scratch` file. The daemon tracks modified regions using `Intervals` (which holds ranges of edited bytes).
|
||||
2. **Close/Release (`queue_revision`):** When the application closes the file descriptor, the daemon:
|
||||
- Fetches any untouched gaps from the remote base file to compile the full file.
|
||||
- Moves the scratch file to `staging` under a `{uid}-{millis}-{counter}` name, so a staged blob can be tied back to its node without consulting the database.
|
||||
- Queues a pending database operation (`PendingOp`).
|
||||
3. **Async Drain Thread (`run_pending_drain`):** The background drain worker picks up the database operations queue, handles revisions uploads, resolves conflicts, and cleans up staging files.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Kernel as Kernel Write (fh, offset, data)
|
||||
participant FUSE as pdfs-fuse VFS
|
||||
participant WH as WriteHandle (Scratch File)
|
||||
participant DB as Db (SQLite Queue)
|
||||
participant DR as Drain Thread
|
||||
participant API as Proton API Client
|
||||
|
||||
Kernel->>FUSE: write(ino, fh, offset, data)
|
||||
FUSE->>WH: write_at(offset, data)
|
||||
FUSE->>WH: update written intervals
|
||||
FUSE-->>Kernel: return bytes_written
|
||||
|
||||
Note over Kernel, FUSE: Application closes file (close(2))
|
||||
Kernel->>FUSE: release(fh)
|
||||
FUSE->>FUSE: fill_gaps() (fetch untouched remote ranges)
|
||||
FUSE->>FUSE: move scratch file to staging directory
|
||||
FUSE->>DB: enqueue_op(OP_REVISION, staged_path, meta)
|
||||
FUSE->>FUSE: record_pending_write() (update size/mtime in memory & DB)
|
||||
FUSE-->>Kernel: return success (async release)
|
||||
|
||||
Note over DB, DR: Background Queue Processing
|
||||
DR->>DB: next_due_op()
|
||||
DB-->>DR: return OP_REVISION
|
||||
DR->>API: upload_new_revision_from(staged_path)
|
||||
API-->>DR: return new node revision metadata
|
||||
DR->>DB: delete_op()
|
||||
DR->>FUSE: refresh_after_upload() (sync local metadata with server time)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Sync Engine (Two-Way Reconciliation)
|
||||
|
||||
The sync engine handles offline-capable, bidirectional synchronization between the local disk and Proton Drive for directories marked in `mirror` mode.
|
||||
|
||||
### Lifecycle of a Sync Pass
|
||||
1. **Walk Local:** Walks the local directory tree recursively, scanning sizes and modification times.
|
||||
2. **Walk Remote:** Walks the remote database representation. If remote file modification times are updated, it calls the API to decrypt their sizes.
|
||||
3. **Load Baseline:** Loads the `sync_entry` database table, which contains the snapshot of both sides during the *last successful sync*.
|
||||
4. **Permutation Diffing:** The loop compares the three states (`local`, `remote`, `baseline`) to classify items:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
%% States
|
||||
Classify{"Classify (Local, Remote, Baseline)"}
|
||||
|
||||
%% Logic Rules
|
||||
Classify -->|Both Sides Match| Match["No-Op (In Sync)"]
|
||||
Classify -->|Local Changed, Remote Untouched| Upload["Upload Revision"]
|
||||
Classify -->|Remote Changed, Local Untouched| Download["Download Revision"]
|
||||
Classify -->|Both Sides Changed| Conflict["Conflict Copy (Local renamed to 'sync-conflict', remote downloaded)"]
|
||||
Classify -->|Local Deleted, Remote Untouched| RemoteDelete["Trash Remote Node"]
|
||||
Classify -->|Remote Deleted, Local Untouched| LocalDelete["Delete Local File"]
|
||||
Classify -->|New Local File, No Remote/Baseline| UploadNew["Upload New Node"]
|
||||
Classify -->|New Remote File, No Local/Baseline| DownloadNew["Download New Node"]
|
||||
```
|
||||
|
||||
5. **Depth-Ascending Batching:** Folders are processed first to ensure hierarchies exist before files are placed. Work is executed concurrently up to a set limit.
|
||||
6. **Post-Sync Settle:** On success, baseline entries are upserted, timestamps updated, and any pending mode switches (e.g. going on-demand) are evaluated.
|
||||
|
||||
---
|
||||
|
||||
## 6. IPC Socket Protocol
|
||||
|
||||
The CLI and GUI front-ends do not access database files or make network calls directly. They communicate with the background daemon process over a Unix domain socket.
|
||||
|
||||
* **Transport:** IPC over Unix Stream Socket.
|
||||
* **Framing:** Line-delimited JSON payloads.
|
||||
* **Control Protocol:**
|
||||
* Client sends a single JSON line (`Request`).
|
||||
* Daemon parses, handles the request, and replies with a single JSON line (`Response`).
|
||||
* Timeout durations are separated: **2 seconds** for writes (avoids hangs on defunct sockets) and **120 seconds** for reads (accommodates heavy transfers).
|
||||
|
||||
---
|
||||
|
||||
## 7. Subsystem Interaction & Thread Map
|
||||
|
||||
The background daemon relies on the following thread topology:
|
||||
|
||||
1. **Main Thread / Dispatch Loop:** Blocks on `fuser::Session` loop. Reads kernel FUSE events and hands off network-bound VFS work to the FUSE workers pool.
|
||||
2. **FUSE Workers Pool (11 threads, two lanes):** Bounded thread pool handling network operations. Split into 3 threads reserved for metadata (`lookup`, `readdir`) and 8 general threads that serve transfers (block reads) and fall back to metadata when no transfer is waiting. Reserved threads never accept a transfer — that is what keeps a directory listing from queuing behind saturated downloads (audit A6).
|
||||
3. **IPC listener Thread:** Listens on Unix socket connections, spawning a lightweight task per connection to serve front-end status/configuration requests.
|
||||
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
|
||||
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
|
||||
|
||||
---
|
||||
|
||||
## 8. Threat Model: What This Client Writes to Disk in Plaintext
|
||||
|
||||
Proton Drive is zero-knowledge: the server never holds the keys to your content. That property ends at this daemon. Serving a remote file through a POSIX filesystem means producing plaintext, and serving it *quickly* means keeping some of that plaintext around. This section states exactly what lands on disk, because the guarantee users infer from "zero-knowledge" is stronger than the one a files-on-demand client can offer locally.
|
||||
|
||||
**The short version: the cache and state directories hold decrypted content and decrypted metadata, and this client assumes the disk underneath them is encrypted (LUKS, or an encrypted home).** On an unencrypted disk, an attacker with the powered-off machine can read cached file content and the full name/structure of your Drive without ever touching your password.
|
||||
|
||||
### 8.1 Decrypted content
|
||||
|
||||
Everything under `$XDG_CACHE_HOME/<app>/content/` is plaintext:
|
||||
|
||||
| Path | Holds | Lifetime |
|
||||
|---|---|---|
|
||||
| `<uid>` blobs | Whole decrypted files (pinned files, opened files) | Until LRU eviction or budget purge |
|
||||
| `blocks/` | Decrypted 4 MiB block ranges of partially-read files | Until LRU eviction |
|
||||
| `thumbs/` | Decrypted thumbnails and previews | Until LRU eviction |
|
||||
| `scratch/` | In-progress writes from open file handles | Until `release`, or rescued at next open |
|
||||
| `staging/` | Released writes awaiting upload | **Until the upload lands** |
|
||||
| `recovery/` | `fsync`ed writes rescued from an unclean shutdown | **Until replayed into `staging/`** |
|
||||
|
||||
`staging/` and `recovery/` deserve separate attention: unlike the cache directories, they are not a copy of something the server already has. They hold user-authored content that may exist **nowhere else yet**, which is why they are deliberately never cleared on startup (§4, and audit A2). They are simultaneously the most sensitive thing on disk and the thing that must not be deleted to reclaim space.
|
||||
|
||||
### 8.2 Decrypted metadata
|
||||
|
||||
`$XDG_STATE_HOME/<app>/cache.db` is a plain SQLite database containing **decrypted node names**, the folder hierarchy, sizes, timestamps, a trigram full-text index over those names, the activity log, and the photos timeline. It is not evictable and not budgeted — it is the persistence layer the in-memory tree rehydrates from (§2).
|
||||
|
||||
Filename and directory-structure confidentiality is an explicit part of Proton Drive's model (each folder's manifest is encrypted server-side). This database is where that property is spent locally: it is a queryable, plaintext index of your entire Drive, and it survives cache purges. A `PurgeCache` clears content, not this.
|
||||
|
||||
### 8.3 What is *not* written in plaintext
|
||||
|
||||
Credentials. The session blob — access and refresh tokens, and the key material needed to resume unattended — lives only in the OS keyring via libsecret (`auth.rs`), never on disk in cleartext. `config.json` and `pins.json` hold settings and node uids, no secrets.
|
||||
|
||||
Note that the *control socket* is a credential of a different kind: anything that can connect to `control.sock` can drive the daemon — list and read paths, upload, trash, create share links — without touching the keyring at all. See §8.5.
|
||||
|
||||
### 8.4 Memory, swap, and the page cache
|
||||
|
||||
Two exposures this client does **not** currently mitigate, stated plainly rather than left implied:
|
||||
|
||||
- **Swap.** Content keys, session keys, and decrypted buffers live in ordinary heap memory. Nothing calls `mlock(2)`, so under memory pressure they may be paged out. Raising `LimitMEMLOCK` in the systemd unit would *not* change this — there is no locking to permit. The effective mitigation is encrypted swap (dm-crypt / `systemd-cryptsetup`), which is standard on a LUKS install.
|
||||
- **Kernel page cache.** Plaintext returned through FUSE is cached by the kernel like any other file data, and is likewise swappable. Defeating this would mean `direct_io` on every read, forfeiting the readahead and caching that make the mount usable. The trade is taken deliberately in favour of performance.
|
||||
|
||||
### 8.5 File modes: currently inherited, not enforced
|
||||
|
||||
**The daemon does not set restrictive permissions on anything it creates.** Cache and state directories come from `create_dir_all`, and `control.sock` from a plain `UnixListener::bind` — all subject to the process umask, which on a typical desktop yields `0755` directories and a `0755` socket.
|
||||
|
||||
What protects them today is the mode of the XDG parents (`~/.cache`, `~/.local/state`), which are conventionally `0700` — but that is a property of the user's system, not something this client establishes or verifies. On a machine where the home directory is group- or world-traversable, the consequences differ by artifact:
|
||||
|
||||
| Artifact | Exposure if the parent is traversable |
|
||||
|---|---|
|
||||
| `content/` | Another local user can read cached plaintext file content |
|
||||
| `cache.db` | Another local user can read the full decrypted name/structure index |
|
||||
| `control.sock` | **Another local user can drive the daemon**: enumerate, read, upload, trash, create public share links |
|
||||
|
||||
The socket is the sharpest of the three, because it is an authority boundary rather than a data one — connecting to it confers the daemon's authenticated session without any credential.
|
||||
|
||||
**Fixed (bugs.md B6):** `AppDirs::ensure` now sets `0700` on the state, cache, and config directories on every start — not only at creation, so a directory that already exists with a permissive mode is tightened — and `config::restrict_socket` sets `0600` on both sockets immediately after `bind`. The directories are restricted *before* the socket is bound, which closes the window between `bind` and `chmod`. A control socket whose mode cannot be set takes the daemon down rather than serving unguarded.
|
||||
|
||||
See [RECOVERY.md](RECOVERY.md) for what a lost machine means for the plaintext described in this section, and what to revoke.
|
||||
|
||||
### 8.6 Implications for deployment
|
||||
|
||||
- Treat the cache and state directories as being as sensitive as the Drive contents themselves.
|
||||
- Multi-user machines warrant checking `~/.cache` and `~/.local/state` are `0700` until §8.5 is addressed.
|
||||
- Purging the cache (`pdfs` settings, or `PurgeCache` over IPC) removes content but **not** `cache.db`, and deliberately never removes undrained `staging/` or `recovery/` blobs.
|
||||
@@ -0,0 +1,262 @@
|
||||
# Recovery: Getting a Replacement Machine Back onto Your Device
|
||||
|
||||
You lose the laptop. You install `pdfs` on a new one. What does it take to get the
|
||||
synced folders back?
|
||||
|
||||
This document is two things: **§2 is the runbook that works with the client as it
|
||||
stands today**, gotchas included, and **§3 is the plan to reduce that runbook to
|
||||
one command.** §1 is the part neither can fix — what actually died with the
|
||||
machine.
|
||||
|
||||
---
|
||||
|
||||
## 1. What survives, and what does not
|
||||
|
||||
### Survives (it is on Drive, or derivable)
|
||||
|
||||
| Thing | Where it lives |
|
||||
|---|---|
|
||||
| Every file that finished uploading | Proton Drive |
|
||||
| The Device registration itself | Proton Drive (`enumerate_devices`) |
|
||||
| The device's folder tree — one folder per synced folder | Proton Drive, under the device root |
|
||||
| Your account, keys, sharing | Proton |
|
||||
|
||||
### Does not survive
|
||||
|
||||
| Thing | Why | Recoverable? |
|
||||
|---|---|---|
|
||||
| Undrained `staging/` + `recovery/` blobs | Writes the kernel accepted but that never reached Drive. They existed only on that disk. | **No.** |
|
||||
| Local-only files in a mirror folder that had not uploaded yet | Same. | **No.** |
|
||||
| `sync_folder` rows — which local path maps to which remote folder, and in which mode | Local SQLite (`cache.db`) | Rebuildable by hand (§2), not automatically (§3) |
|
||||
| `sync_entry` baseline | Local SQLite | Not needed. An empty baseline reconciles correctly — see §2.4. |
|
||||
| Pins | Local SQLite | Rebuildable by hand |
|
||||
| `config.json` — mountpoint, cache budget | Local config dir | Rebuildable by hand |
|
||||
| `cache.db` node/FTS/photo caches | Local SQLite | Pure cache; rebuilds itself |
|
||||
|
||||
The first two rows are the only genuinely unrecoverable ones, and today nothing
|
||||
warns you they are non-empty before you walk away from a machine. That is tracked
|
||||
separately from this document as a pending-writes visibility gap.
|
||||
|
||||
### Before anything else: revoke
|
||||
|
||||
The lost machine holds a decrypted content cache, a plaintext index of every node
|
||||
name in your Drive (`cache.db`), and a live session in its keyring. Whether that
|
||||
matters depends on whether its disk was encrypted — but the session does not.
|
||||
|
||||
**Revoke the session from your Proton account settings before restoring.** Nothing
|
||||
in this client can do that for you; the daemon on the lost machine holds
|
||||
credentials it will happily keep refreshing.
|
||||
|
||||
If you have also lost your password, Proton's recovery phrase is what determines
|
||||
whether the data is reachable at all. That is upstream of this client entirely: if
|
||||
zero-knowledge encryption worked as advertised, nobody at Proton can help.
|
||||
|
||||
---
|
||||
|
||||
## 2. The runbook that works today
|
||||
|
||||
### 2.1 Install, log in
|
||||
|
||||
```bash
|
||||
pdfs login # stores the session in the keyring
|
||||
systemctl --user start proton-drive # or let autostart do it
|
||||
```
|
||||
|
||||
### 2.2 Re-adopt the device — check this before anything else
|
||||
|
||||
`Core::ensure_device` recovers an existing device by **matching a Linux device
|
||||
whose name equals this machine's hostname**, read from
|
||||
`/proc/sys/kernel/hostname`. So:
|
||||
|
||||
```bash
|
||||
hostnamectl hostname # what this machine will register as
|
||||
pdfs devices list # what the account already has
|
||||
```
|
||||
|
||||
- **Hostnames match** → the new machine silently re-adopts the old device and its
|
||||
root folder. This is the happy path, and it is why the rest of the runbook works.
|
||||
- **Hostnames differ** → `pdfs` registers a **second device**. Nothing errors.
|
||||
Your old device and all its folders are still there, but this machine is not
|
||||
attached to them, and every step below will quietly create empty folders under
|
||||
the new device instead.
|
||||
|
||||
> **The single most important step in this document:** if the hostnames differ,
|
||||
> set the new machine's hostname to match the old device's name *before* adding
|
||||
> any sync folders.
|
||||
> ```bash
|
||||
> sudo hostnamectl set-hostname OLD-DEVICE-NAME
|
||||
> ```
|
||||
> There is no supported way to adopt a device by uid yet — see §3.1.
|
||||
|
||||
### 2.3 Find out which folders the device had
|
||||
|
||||
```bash
|
||||
pdfs devices list # confirm the device and note its name
|
||||
```
|
||||
|
||||
The device's synced folders are the folders directly under its root in Drive.
|
||||
Browse them in the GUI's Computers page, or in the Proton Drive web app under
|
||||
that device. **Write down their exact names** — you need them in the next step.
|
||||
|
||||
### 2.4 Re-attach each folder
|
||||
|
||||
For each folder name you noted, create the local directory and add it:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Documents
|
||||
pdfs sync add ~/Documents
|
||||
```
|
||||
|
||||
`add_sync_folder` looks for a folder under the device root named after the local
|
||||
directory's **basename**, and reuses it if found rather than creating a second
|
||||
one. So `~/Documents` re-attaches to the existing `Documents`. With an empty
|
||||
baseline and an empty local directory, the first reconcile classifies every remote
|
||||
path as `(None, Some(remote))` with no baseline → **download**, and the folder
|
||||
refills from Drive.
|
||||
|
||||
Three things to be careful about:
|
||||
|
||||
- **The basename must match exactly.** Restoring to `~/docs` when the device
|
||||
folder is `Documents` does not re-attach — it creates a *new*, empty `docs`
|
||||
folder in Drive and syncs nothing. The failure is silent; you just see an empty
|
||||
directory. Check with `pdfs sync list` and the Computers page.
|
||||
- **Add the folder empty, or accept conflict copies.** If the local directory
|
||||
already has files (restored from a backup, say), there is no baseline to say
|
||||
which side is newer, so both sides read as changed and the reconcile takes the
|
||||
conflict arm: your local file is renamed to a `(sync-conflict <ts>)` copy and
|
||||
the remote version is downloaded. Nothing is lost, but it is noisy. Restoring
|
||||
into an empty directory avoids it.
|
||||
- **A wholly empty local tree is now safe.** Before the audit fixes, adding a
|
||||
folder whose local side was empty against a populated remote could, on a later
|
||||
pass, be read as "the user deleted everything" and trash the lot. `sync.rs`'s
|
||||
`guard_local_wipe` now refuses any pass where every baseline path has vanished
|
||||
locally, which is exactly the restore shape.
|
||||
|
||||
### 2.5 Restore modes, pins, and settings by hand
|
||||
|
||||
```bash
|
||||
pdfs sync list # note the ids
|
||||
pdfs sync mode <id> ondemand # for folders that were on-demand
|
||||
```
|
||||
|
||||
Nothing records which folders were `ondemand` before, so this is from memory. Pins
|
||||
and the cache budget/mountpoint likewise: set them again from the Settings page.
|
||||
|
||||
### 2.6 Clean up
|
||||
|
||||
If a duplicate device got created before you noticed the hostname mismatch:
|
||||
|
||||
```bash
|
||||
pdfs devices list
|
||||
pdfs devices rm <uid-of-the-stray-device>
|
||||
```
|
||||
|
||||
Check what is under it in the web app first. Deleting a device you actually
|
||||
attached folders to will take its folders with it.
|
||||
|
||||
---
|
||||
|
||||
## 3. The plan: make this one command
|
||||
|
||||
The pieces all exist — device enumeration, folder-name reuse, an empty-baseline
|
||||
reconcile that downloads correctly. What is missing is that the restore is
|
||||
**implicit, name-based, and undiscoverable**. Each phase below removes one of
|
||||
those properties. They are independent and land in this order by value.
|
||||
|
||||
### P1 — Adopt a device explicitly, by uid
|
||||
|
||||
**Problem:** device identity is inferred from the hostname. A new laptop with a
|
||||
new name silently forks a second device, and the failure looks like "my files
|
||||
didn't come back" rather than an error.
|
||||
|
||||
- Add `Request::AdoptDevice { uid }`: validate the uid against
|
||||
`enumerate_devices`, write the `StoredDevice` row, and refuse if any
|
||||
`sync_folder` rows already reference a different device.
|
||||
- `pdfs devices adopt <uid>`, and an "Adopt this device" action on rows in the
|
||||
GUI's Computers page that are not the current machine.
|
||||
- Make the fork *visible* rather than silent: when `ensure_device` is about to
|
||||
create a device and the account already has other Linux devices, log a warning
|
||||
naming them, and surface it in `pdfs status`.
|
||||
|
||||
Keep hostname matching as the default. It is right for the common case; it just
|
||||
must not be the *only* way.
|
||||
|
||||
### P2 — Discover and re-attach the device's folders
|
||||
|
||||
**Problem:** §2.3 and §2.4 are "remember what you had, then retype it exactly."
|
||||
|
||||
- Add `Request::ListDeviceFolders { device_uid }` — enumerate the device root's
|
||||
child folders and report name, node uid, child count, and total size. This is
|
||||
the "what did this machine sync?" answer that today requires the web app.
|
||||
- Add `Request::AttachSyncFolder { remote_uid, local_path, mode }` — the
|
||||
re-attach counterpart to `AddSyncFolder`. It binds a local path to a **specific
|
||||
remote uid** rather than resolving by basename, which removes the silent
|
||||
`~/docs` vs `Documents` failure entirely.
|
||||
- CLI: `pdfs sync restore [--device <uid>] [--into <dir>]` — list the device's
|
||||
folders, propose `<dir>/<name>` for each (default `$HOME`), and attach the ones
|
||||
the user confirms.
|
||||
- GUI: a "Restore folders from this device" flow on the Computers page, with a
|
||||
per-folder local-path picker defaulting to `~/<name>`.
|
||||
|
||||
Refuse to attach into a non-empty directory without an explicit
|
||||
`--allow-nonempty`, so the conflict-copy noise of §2.4 is opt-in rather than a
|
||||
surprise.
|
||||
|
||||
### P3 — Back the profile up to Drive
|
||||
|
||||
**Problem:** modes, pins, mountpoint, and cache budget exist only on the dead
|
||||
machine, so even a perfect P2 restores folders in the wrong mode with no pins.
|
||||
|
||||
Write a small JSON document into a hidden folder in the user's own Drive — say
|
||||
`.proton-drive-linux/profile.json` — containing, per device:
|
||||
|
||||
- sync folders: remote uid, the local path last used, and mode
|
||||
- pinned node uids
|
||||
- mountpoint and cache budget
|
||||
|
||||
It rides on Drive's own encryption, so this introduces no new crypto and no new
|
||||
place for plaintext to sit. Rewrite it whenever any of those change, debounced.
|
||||
|
||||
On a fresh install, after login: *"Found settings from `<device>`, last updated
|
||||
`<date>`. Restore?"* Node uids are stable across machines, so pins restore
|
||||
exactly. Local paths need remapping when the username or home differs — show the
|
||||
old path, default the new one to `$HOME/<basename>`.
|
||||
|
||||
Store the last-used local path only as a *hint*. Restoring a profile must never
|
||||
write to a path the user has not confirmed in this session.
|
||||
|
||||
### P4 — Make the unrecoverable visible
|
||||
|
||||
Nothing above helps with §1's undrained writes, because by then it is too late.
|
||||
The mitigation is to make the pending queue impossible to miss *before* the
|
||||
machine is lost:
|
||||
|
||||
- pending count and bytes in `pdfs status`, using the existing `pending` map and
|
||||
`TransferRegistry`
|
||||
- a tray icon state and a GUI banner while the queue is non-empty
|
||||
- a warning on logout or shutdown while it is non-empty
|
||||
|
||||
### P5 — Verification
|
||||
|
||||
None of the above is trustworthy without exercising the actual shape of the
|
||||
disaster. The end-to-end test is: register a device, sync a folder, upload
|
||||
content, **delete the local state directory and cache entirely** (simulating the
|
||||
lost machine), restart, restore, and assert the tree comes back byte-identical.
|
||||
|
||||
That is a two-account or at least two-state-dir integration test against a live
|
||||
account, not a unit test. Until the mock-backend problem described in
|
||||
`audit-plan.md` is solved, it stays manual — but it should be run, and its result
|
||||
recorded here, before P1–P3 are called done.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current status
|
||||
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| Manual runbook (§2) | Works, with the hostname and basename caveats |
|
||||
| P1 explicit adoption | Not started |
|
||||
| P2 discover + attach | Not started |
|
||||
| P3 profile on Drive | Not started |
|
||||
| P4 pending visibility | Not started |
|
||||
| P5 restore drill | Never run |
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=0.4.0
|
||||
pkgver=0.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
Reference in new issue
Block a user