diff --git a/Cargo.lock b/Cargo.lock index b355851..5835873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -30,17 +40,42 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", + "aead 0.5.2", + "aes 0.8.4", "cipher 0.4.4", - "ctr", - "ghash", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead 0.6.1", + "aes 0.9.1", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", "subtle", ] @@ -50,7 +85,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" dependencies = [ - "aes", + "aes 0.8.4", ] [[package]] @@ -740,6 +775,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -769,6 +815,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", ] @@ -922,6 +969,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1015,7 +1068,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1027,6 +1082,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1152,7 +1216,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ - "aes", + "aes 0.8.4", "block-padding", "cbc", "dbus", @@ -1331,10 +1395,10 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", "cmac", - "ctr", + "ctr 0.9.2", "subtle", ] @@ -1838,6 +1902,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1847,7 +1912,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.2", ] [[package]] @@ -2976,7 +3050,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-complex", "num-integer", "num-iter", @@ -2994,6 +3068,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -3058,7 +3142,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", ] @@ -3101,9 +3185,9 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" dependencies = [ - "aead", + "aead 0.5.2", "cipher 0.4.4", - "ctr", + "ctr 0.9.2", "subtle", ] @@ -3356,9 +3440,9 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" dependencies = [ - "aead", - "aes", - "aes-gcm", + "aead 0.5.2", + "aes 0.8.4", + "aes-gcm 0.10.3", "aes-kw", "argon2", "base64", @@ -3500,7 +3584,18 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b20f20e954175de5f463f67781b35583397d916b1d148738923711b2ad16bee8" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -3569,14 +3664,13 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6862368afe7da79766f4d86845ba0ad7cdfc619ef6d962aeb45ac444612dc42" +version = "0.1.6" dependencies = [ "async-trait", "base64", "bytes", "futures", + "getrandom 0.4.3", "hex", "hmac 0.13.0", "pgp", @@ -3594,24 +3688,23 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fa00fcf9c1ad4866ab8bc145295efe12e518232ab7cebb6cf2bf0caa4f7e3da" +version = "0.1.6" dependencies = [ - "aes-gcm", + "aes-gcm 0.11.0", "async-trait", "base64", "bcrypt", "bytes", "chrono", "futures", - "getrandom 0.2.17", + "getrandom 0.4.3", "hex", "hkdf 0.13.0", "hmac 0.13.0", - "num-bigint", + "num-bigint 0.5.1", "num-traits", "pgp", + "rand 0.10.2", "rand 0.8.6", "reqwest", "serde", @@ -3762,6 +3855,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3800,6 +3904,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rav1e" version = "0.8.1" @@ -4238,7 +4348,7 @@ version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" dependencies = [ - "aes", + "aes 0.8.4", "cbc", "futures-util", "generic-array", @@ -5012,6 +5122,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index e215d4a..394de30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ authors = ["Nils Pukropp "] rust-version = "1.96" [workspace.dependencies] -proton-sdk = "0.1.5" -proton-drive-rs = "0.1.5" +proton-sdk = "0.1.6" +proton-drive-rs = "0.1.6" # Internal crates. pdfs-core = { path = "crates/pdfs-core" } @@ -37,3 +37,9 @@ 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" } diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 4db566f..cb242e9 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -10,7 +10,7 @@ use clap::{Parser, Subcommand}; use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; -use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse}; +use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse, ShareEntryKind}; use pdfs_core::db::Db; #[derive(Parser)] @@ -113,11 +113,15 @@ enum Command { /// New folder name (a single path component). name: String, }, - /// Upload a local file into a Drive folder via the running daemon. + /// Upload local files and/or folders into a Drive folder via the running + /// daemon. Folders are uploaded recursively. The daemon uploads in the + /// background; watch progress with `pdfs transfers`. Upload { - /// Local file to upload. - file: PathBuf, - /// Destination folder path, inside the mountpoint or relative to it. + /// Local files and/or folders to upload. + #[arg(required = true)] + sources: Vec, + /// Destination folder, inside the mountpoint or relative to it. + #[arg(short = 't', long = "to", default_value = ".")] parent: PathBuf, }, /// Show the daemon's in-flight transfers (active uploads/downloads). @@ -139,6 +143,163 @@ enum Command { }, /// Permanently delete everything in the trash. This cannot be undone. EmptyTrash, + + /// Manage the account's registered devices. + Devices { + #[command(subcommand)] + action: DeviceCmd, + }, + /// Share a file or folder with Proton and/or external email addresses. + Share { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + /// One or more invitee emails (Proton or external, auto-detected). + #[arg(required = true)] + emails: Vec, + /// Role to grant: viewer, editor or admin. + #[arg(long, default_value = "viewer")] + role: String, + /// Optional message included in the invitation email. + #[arg(long)] + message: Option, + }, + /// List a node's members, pending invitations and public link. + Members { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + }, + /// Change the role of a member or pending invitation on a node's share. + ShareRole { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + /// Entry id (membership or invitation id), from `pdfs members`. + id: String, + /// Entry kind: member, proton or external. + kind: ShareKindArg, + /// New role: viewer, editor or admin. + role: String, + }, + /// Remove a member or pending invitation from a node's share. + Unshare { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + /// Entry id (membership or invitation id), from `pdfs members`. + id: String, + /// Entry kind: member, proton or external. + kind: ShareKindArg, + }, + /// Manage a node's public link. + PublicLink { + #[command(subcommand)] + action: PublicLinkCmd, + }, + /// List the items I have shared with others (members, invites or a link). + Shared, + /// List nodes shared with me that I have accepted. + SharedWithMe, + /// Leave a shared node by its uid (from `pdfs shared-with-me`). + Leave { + /// Node uid in `volume~link` form. + uid: String, + }, + /// Manage invitations addressed to me. + Invitations { + #[command(subcommand)] + action: InvitationCmd, + }, + /// Manage saved public-link bookmarks. + Bookmarks { + #[command(subcommand)] + action: BookmarkCmd, + }, + /// Show the daemon's recent activity, newest first. + Activity { + /// Maximum entries to show. + #[arg(short, long, default_value_t = 50)] + limit: usize, + }, +} + +/// Which collection a share entry lives in, for `share-role` / `unshare`. +#[derive(Clone, Copy, clap::ValueEnum)] +enum ShareKindArg { + /// An accepted member. + Member, + /// A pending invitation to a Proton user. + Proton, + /// A pending invitation to a non-Proton email. + External, +} + +impl ShareKindArg { + fn to_kind(self) -> ShareEntryKind { + match self { + ShareKindArg::Member => ShareEntryKind::Member, + ShareKindArg::Proton => ShareEntryKind::ProtonInvite, + ShareKindArg::External => ShareEntryKind::ExternalInvite, + } + } +} + +#[derive(Subcommand)] +enum DeviceCmd { + /// List registered devices. + List, + /// Rename a device by uid. + Rename { uid: String, name: String }, + /// Delete (deregister) a device by uid. + Rm { uid: String }, +} + +#[derive(Subcommand)] +enum PublicLinkCmd { + /// Create a public link on a node. + Create { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + /// Role: viewer or editor. + #[arg(long, default_value = "viewer")] + role: String, + /// Optional custom password protecting the link. + #[arg(long)] + password: Option, + /// Optional expiry, Unix epoch seconds. + #[arg(long)] + expires: Option, + }, + /// Remove a node's public link. + Remove { + /// File/folder path, inside the mountpoint or relative to it. + path: PathBuf, + /// Public-link id, from `pdfs members`. + id: String, + }, +} + +#[derive(Subcommand)] +enum InvitationCmd { + /// List invitations addressed to me. + List, + /// Accept an invitation by id. + Accept { id: String }, + /// Reject an invitation by id. + Reject { id: String }, +} + +#[derive(Subcommand)] +enum BookmarkCmd { + /// List saved public-link bookmarks. + List, + /// Save a public link (optionally password-protected) as a bookmark. + Add { + /// Public link URL, including the `#password` fragment. + url: String, + /// Optional custom password for the link. + #[arg(long)] + password: Option, + }, + /// Remove a saved bookmark by its token. + Rm { token: String }, } fn main() -> Result<()> { @@ -170,15 +331,335 @@ fn main() -> Result<()> { Command::Move { path, new_parent } => cmd_move(path, new_parent), Command::Rm { path } => cmd_rm(path), Command::Mkdir { parent, name } => cmd_mkdir(parent, name), - Command::Upload { file, parent } => cmd_upload(file, parent), + Command::Upload { sources, parent } => cmd_upload(sources, parent), Command::Transfers => cmd_transfers(), Command::Trash => cmd_trash(), Command::Restore { uids } => cmd_restore(uids), Command::DeleteForever { uids } => cmd_delete_forever(uids), Command::EmptyTrash => cmd_empty_trash(), + Command::Devices { action } => cmd_devices(action), + Command::Share { + path, + emails, + role, + message, + } => cmd_share(path, emails, role, message), + Command::Members { path } => cmd_members(path), + Command::ShareRole { + path, + id, + kind, + role, + } => cmd_share_role(path, id, kind, role), + Command::Unshare { path, id, kind } => cmd_unshare(path, id, kind), + Command::PublicLink { action } => cmd_public_link(action), + Command::Shared => cmd_shared(), + Command::SharedWithMe => cmd_shared_with_me(), + Command::Leave { uid } => cmd_leave(uid), + Command::Invitations { action } => cmd_invitations(action), + Command::Bookmarks { action } => cmd_bookmarks(action), + Command::Activity { limit } => cmd_activity(limit), } } +fn cmd_devices(action: DeviceCmd) -> Result<()> { + match action { + DeviceCmd::List => match control_request(CtlRequest::ListDevices)? { + CtlResponse::Devices { items } if items.is_empty() => println!("No devices."), + CtlResponse::Devices { items } => { + for d in items { + let sync = d + .last_sync + .map(|t| t.to_string()) + .unwrap_or_else(|| "never".to_string()); + println!( + "{} {} (synced: {sync}) [{}]", + d.device_type, d.name, d.uid + ); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + }, + DeviceCmd::Rename { uid, name } => { + ok_or_bail(control_request(CtlRequest::RenameDevice { uid, name })?)? + } + DeviceCmd::Rm { uid } => ok_or_bail(control_request(CtlRequest::DeleteDevice { uid })?)?, + } + Ok(()) +} + +fn cmd_share( + path: PathBuf, + emails: Vec, + role: String, + message: Option, +) -> Result<()> { + ok_or_bail(control_request(CtlRequest::ShareNode { + path: path_arg(&path)?, + emails, + role, + message, + })?) +} + +fn cmd_members(path: PathBuf) -> Result<()> { + match control_request(CtlRequest::ListShare { + path: path_arg(&path)?, + })? { + CtlResponse::Share { entries, link } => { + if entries.is_empty() { + println!("No members or pending invitations."); + } + for e in entries { + let kind = match e.kind { + ShareEntryKind::Member => "member", + ShareEntryKind::ProtonInvite => "invited (proton)", + ShareEntryKind::ExternalInvite => "invited (external)", + }; + println!("{:<18} {:<8} {} [{}]", kind, e.role, e.email, e.id); + } + match link { + Some(l) => { + let url = l.url.as_deref().unwrap_or("(url hidden)"); + let pw = if l.has_password { " +password" } else { "" }; + println!("public link ({}{pw}): {url} [{}]", l.role, l.id); + } + None => println!("public link: none"), + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) -> Result<()> { + ok_or_bail(control_request(CtlRequest::UpdateShareRole { + path: path_arg(&path)?, + id, + kind: kind.to_kind(), + role, + })?) +} + +fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> { + ok_or_bail(control_request(CtlRequest::RemoveShareEntry { + path: path_arg(&path)?, + id, + kind: kind.to_kind(), + })?) +} + +fn cmd_public_link(action: PublicLinkCmd) -> Result<()> { + match action { + PublicLinkCmd::Create { + path, + role, + password, + expires, + } => match control_request(CtlRequest::CreatePublicLink { + path: path_arg(&path)?, + role, + password, + expires, + })? { + CtlResponse::PublicLink { link } => { + println!("{}", link.url.as_deref().unwrap_or("(no url returned)")); + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + }, + PublicLinkCmd::Remove { path, id } => { + ok_or_bail(control_request(CtlRequest::RemovePublicLink { + path: path_arg(&path)?, + id, + })?)? + } + } + Ok(()) +} + +fn cmd_shared() -> Result<()> { + match control_request(CtlRequest::ListSharedByMe)? { + CtlResponse::SharedByMe { items } if items.is_empty() => { + println!("You haven't shared anything.") + } + CtlResponse::SharedByMe { items } => { + for it in items { + let kind = if it.is_dir { "d" } else { "-" }; + let mut tags = Vec::new(); + if it.member_count > 0 { + tags.push(format!("{} member(s)", it.member_count)); + } + if it.invite_count > 0 { + tags.push(format!("{} pending", it.invite_count)); + } + if let Some(link) = &it.link { + tags.push(match &link.url { + Some(url) => format!("link: {url}"), + None => "link".to_string(), + }); + } + let tags = if tags.is_empty() { + String::new() + } else { + format!(" ({})", tags.join(", ")) + }; + println!("{kind} {}{tags} [{}]", it.name, it.uid); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_shared_with_me() -> Result<()> { + match control_request(CtlRequest::ListSharedWithMe)? { + CtlResponse::Entries { entries } if entries.is_empty() => { + println!("Nothing shared with you.") + } + CtlResponse::Entries { entries } => { + for e in entries { + let kind = if e.is_dir { "d" } else { "-" }; + println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +fn cmd_activity(limit: usize) -> Result<()> { + match control_request(CtlRequest::ListActivity { limit })? { + CtlResponse::Activity { items } if items.is_empty() => println!("No recent activity."), + CtlResponse::Activity { items } => { + for a in items { + let when = format_epoch(a.time); + let verb = activity_verb(a.kind); + let mark = if a.ok { " " } else { "!" }; + let detail = if a.detail.is_empty() { + String::new() + } else { + format!(" {}", a.detail) + }; + println!("{mark} {when} {verb:<8} {}{detail}", a.target); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + +/// A short verb for an activity kind, for the CLI's one-line-per-event feed. +fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str { + use pdfs_core::control::ActivityKind::*; + match kind { + Upload => "upload", + Rename => "rename", + Move => "move", + CreateFolder => "mkdir", + Trash => "trash", + Restore => "restore", + DeleteForever => "delete", + EmptyTrash => "empty", + Share => "share", + PublicLink => "link", + Unshare => "unshare", + } +} + +/// Format an epoch-seconds timestamp as a compact local-ish `MM-DD HH:MM`. +/// Deliberately dependency-free: derived directly from the Unix time via UTC. +fn format_epoch(secs: i64) -> String { + // Minimal civil-time conversion (UTC) — good enough for an activity feed. + let days = secs.div_euclid(86_400); + let tod = secs.rem_euclid(86_400); + let (h, mi) = (tod / 3600, (tod % 3600) / 60); + // Days since 1970-01-01 → y/m/d (Howard Hinnant's civil_from_days). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let _ = y; + format!("{m:02}-{d:02} {h:02}:{mi:02}") +} + +fn cmd_leave(uid: String) -> Result<()> { + ok_or_bail(control_request(CtlRequest::LeaveShared { uid })?) +} + +fn cmd_invitations(action: InvitationCmd) -> Result<()> { + match action { + InvitationCmd::List => match control_request(CtlRequest::ListInvitations)? { + CtlResponse::Invitations { items } if items.is_empty() => { + println!("No pending invitations.") + } + CtlResponse::Invitations { items } => { + for i in items { + let kind = if i.is_dir { "folder" } else { "file" }; + let name = i.name.as_deref().unwrap_or("(name hidden)"); + println!("{} shared {kind} \"{name}\" [{}]", i.inviter_email, i.id); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + }, + InvitationCmd::Accept { id } => { + ok_or_bail(control_request(CtlRequest::AcceptInvitation { id })?)? + } + InvitationCmd::Reject { id } => { + ok_or_bail(control_request(CtlRequest::RejectInvitation { id })?)? + } + } + Ok(()) +} + +fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> { + match action { + BookmarkCmd::List => match control_request(CtlRequest::ListBookmarks)? { + CtlResponse::Bookmarks { items } if items.is_empty() => println!("No bookmarks."), + CtlResponse::Bookmarks { items } => { + for b in items { + let name = b.name.as_deref().unwrap_or("(name hidden)"); + println!("{name} {} [{}]", b.url, b.token); + } + } + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + }, + BookmarkCmd::Add { url, password } => { + ok_or_bail(control_request(CtlRequest::CreateBookmark { + url, + password, + })?)? + } + BookmarkCmd::Rm { token } => { + ok_or_bail(control_request(CtlRequest::DeleteBookmark { token })?)? + } + } + Ok(()) +} + +/// Print an `Ok` message or bail on an `Error`, for the many commands whose only +/// reply is one of those two. +fn ok_or_bail(resp: CtlResponse) -> Result<()> { + match resp { + CtlResponse::Ok { message } => println!("{message}"), + CtlResponse::Error { message } => bail!("{message}"), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + /// Read a line from stdin with a prompt (echoed). fn prompt_line(label: &str) -> Result { print!("{label}: "); @@ -595,17 +1076,21 @@ fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> { Ok(()) } -fn cmd_upload(file: PathBuf, parent: PathBuf) -> Result<()> { - let bytes = std::fs::read(&file).with_context(|| format!("read {}", file.display()))?; - let name = file - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| anyhow!("source file has no valid name"))? - .to_owned(); - match control_request(CtlRequest::UploadFile { +fn cmd_upload(sources: Vec, parent: PathBuf) -> Result<()> { + // The daemon reads the sources off its own (local) filesystem, so send it + // absolute, canonicalized paths. + let mut abs = Vec::with_capacity(sources.len()); + for src in &sources { + let p = std::fs::canonicalize(src).with_context(|| format!("resolve {}", src.display()))?; + abs.push( + p.to_str() + .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", p.display()))? + .to_owned(), + ); + } + match control_request(CtlRequest::UploadPaths { parent: path_arg(&parent)?, - name, - bytes, + sources: abs, })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message } => bail!("{message}"), diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index e30b6fb..64ac4a6 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -92,6 +92,18 @@ pub enum Request { name: String, bytes: Vec, }, + /// 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 + /// recreated remotely and walked recursively. The daemon acks immediately with + /// [`Response::Ok`] and does the work in the background — a big tree far + /// outlasts the socket read timeout — so progress is observed through + /// [`Request::GetQueueStatus`] and completion through the transfer count + /// falling to zero. + UploadPaths { + parent: String, + sources: Vec, + }, /// Delete all unpinned cached blobs and on-demand blocks, keeping pinned /// files intact. Replies with [`Response::Ok`] reporting the bytes freed. PurgeCache, @@ -117,6 +129,243 @@ pub enum Request { /// Permanently delete everything in the trash. Irreversible. /// Replies with [`Response::Ok`]. EmptyTrash, + + // ---- devices ---------------------------------------------------------- + /// List the account's registered devices. Replies with [`Response::Devices`]. + ListDevices, + /// Rename a device by its uid. Replies with [`Response::Ok`]. + RenameDevice { uid: String, name: String }, + /// Delete (deregister) a device by its uid. Replies with [`Response::Ok`]. + DeleteDevice { uid: String }, + + // ---- sharing a node --------------------------------------------------- + /// Invite `emails` (Proton and/or external addresses, auto-detected) to the + /// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"), + /// with an optional email `message`. Replies with [`Response::Ok`]. + ShareNode { + path: String, + emails: Vec, + role: String, + message: Option, + }, + /// List the members, pending invitations and public link of the node at + /// mountpoint-relative `path`. Replies with [`Response::Share`]. + ListShare { path: String }, + /// Change the role of a share entry (member or pending invitation) on the node + /// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]). + /// Replies with [`Response::Ok`]. + UpdateShareRole { + path: String, + id: String, + kind: ShareEntryKind, + role: String, + }, + /// Remove a share entry (member, pending Proton invite, or external invite) + /// from the node at `path`. Replies with [`Response::Ok`]. + RemoveShareEntry { + path: String, + id: String, + kind: ShareEntryKind, + }, + /// Create a public link on the node at `path`. `role` is "viewer" or "editor"; + /// `password` optionally adds a custom password; `expires` is an optional Unix + /// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL). + CreatePublicLink { + path: String, + role: String, + password: Option, + expires: Option, + }, + /// Remove the public link `id` from the node at `path`. Replies with + /// [`Response::Ok`]. + RemovePublicLink { path: String, id: String }, + + // ---- shared by me ----------------------------------------------------- + /// List the nodes I have shared with others — collaborative shares that still + /// have members, pending invitations or a public link. Replies with + /// [`Response::SharedByMe`]. Each item carries the share's state so the + /// front-end can render it without a follow-up per node. + ListSharedByMe, + + // ---- shared with me --------------------------------------------------- + /// List nodes shared with me that I have accepted. Replies with + /// [`Response::Entries`] (each entry carries its `uid`; `path` is empty since + /// the item lives outside the mount tree). + ListSharedWithMe, + /// Leave a shared node by its `uid`, giving up my access. Replies with + /// [`Response::Ok`]. + LeaveShared { uid: String }, + + // ---- incoming invitations --------------------------------------------- + /// List invitations addressed to me, pending accept or reject. Replies with + /// [`Response::Invitations`]. + ListInvitations, + /// Accept the invitation `id`, gaining access to the shared node. Replies with + /// [`Response::Ok`]. + AcceptInvitation { id: String }, + /// Reject the invitation `id`, declining access. Replies with [`Response::Ok`]. + RejectInvitation { id: String }, + + // ---- bookmarks -------------------------------------------------------- + /// List public links saved to my account. Replies with [`Response::Bookmarks`]. + ListBookmarks, + /// Save a public link `url` (optionally password-protected) as a bookmark. + /// Replies with [`Response::Ok`]. + CreateBookmark { + url: String, + password: Option, + }, + /// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`]. + DeleteBookmark { token: String }, + + // ---- activity --------------------------------------------------------- + /// Fetch the daemon's recent activity log, newest first, capped at `limit` + /// entries. Replies with [`Response::Activity`]. The log lives in memory for + /// the life of the daemon: it records the mutations and transfers the daemon + /// performs (uploads, deletes, renames, shares, …), so a front-end can show a + /// running "what happened" feed without re-deriving it from anywhere. + ListActivity { limit: usize }, +} + +/// Which kind of share entry a [`ShareEntry`] is, and which collection an +/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShareEntryKind { + /// An accepted member (identified by its membership id). + Member, + /// A pending invitation to a Proton user (identified by its invitation id). + ProtonInvite, + /// A pending invitation to a non-Proton email (identified by its invitation id). + ExternalInvite, +} + +/// A registered device in a [`Response::Devices`] listing. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct DeviceInfo { + /// Device uid — the handle for [`Request::RenameDevice`]/[`Request::DeleteDevice`]. + pub uid: String, + /// Decrypted device name, or a placeholder when it could not be decrypted. + pub name: String, + /// Platform: "Windows", "MacOs" or "Linux". + pub device_type: String, + /// Last sync time, epoch seconds; `None` if it never synced. + pub last_sync: Option, +} + +/// One member or pending invitation on a node's share (in [`Response::Share`]). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ShareEntry { + /// Membership id (members) or invitation id (invitations) — the handle for + /// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`]. + pub id: String, + /// The member's / invitee's email address. + pub email: String, + /// Role: "viewer", "editor", "admin" or "inherited". + pub role: String, + /// Whether this is a member, a pending Proton invite, or an external invite. + pub kind: ShareEntryKind, +} + +/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct PublicLinkInfo { + /// Public-link id — the handle for [`Request::RemovePublicLink`]. + pub id: String, + /// The shareable URL including the `#password` fragment, when known (always on + /// creation; absent when only listed). + pub url: Option, + /// Role granted to anyone with the link ("viewer" or "editor"). + pub role: String, + /// Expiry, epoch seconds, if set. + pub expires: Option, + /// Whether a custom password additionally protects the link. + pub has_password: bool, +} + +/// One invitation addressed to me (in [`Response::Invitations`]). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct InvitationInfo { + /// Invitation id — the handle for [`Request::AcceptInvitation`]/[`Request::RejectInvitation`]. + pub id: String, + /// The email of the user who shared the item. + pub inviter_email: String, + /// The shared item's decrypted name, when available. + pub name: Option, + /// Whether the shared item is a folder. + pub is_dir: bool, +} + +/// One saved public link (in [`Response::Bookmarks`]). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BookmarkInfo { + /// Bookmark token — the handle for [`Request::DeleteBookmark`]. + pub token: String, + /// The full public URL including the `#password` fragment. + pub url: String, + /// The bookmarked item's decrypted name, when available. + pub name: Option, + /// Whether the bookmarked item is a folder. + pub is_dir: bool, +} + +/// One node I have shared with others (in [`Response::SharedByMe`]). Summarizes +/// the share's state so the "Shared" view renders in one pass: how many people +/// have access, how many invitations are still pending, and the public link if +/// the node has one. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SharedItem { + /// Node uid in `volume~link` form — the handle for opening the node's share + /// (via its mount path) or its details. + pub uid: String, + /// The shared node's decrypted name. + pub name: String, + pub is_dir: bool, + /// Mountpoint-relative path, when the daemon can resolve it (the node lives in + /// my own tree). Empty when the path is unknown. + #[serde(default)] + pub path: String, + /// Accepted members (people who already have access), excluding me. + pub member_count: usize, + /// Pending invitations (Proton + external) not yet accepted. + pub invite_count: usize, + /// The node's public link, if it has one. + pub link: Option, +} + +/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to +/// an icon and a verb, and the human detail lives in the entry's fields. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivityKind { + Upload, + Rename, + Move, + CreateFolder, + Trash, + Restore, + DeleteForever, + EmptyTrash, + Share, + PublicLink, + Unshare, +} + +/// One line in the daemon's activity log (in [`Response::Activity`]). Newest +/// first. Records a mutation or transfer the daemon performed, with enough +/// context to read as a sentence: "Uploaded report.pdf to /docs". +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ActivityEntry { + /// When it happened, epoch seconds. + pub time: i64, + pub kind: ActivityKind, + /// The primary item's name (a file/folder name, or a count like "3 items"). + pub target: String, + /// Extra context: a destination path, a new name, an invitee, an error + /// message. Empty when there is nothing to add. + #[serde(default)] + pub detail: String, + /// Whether the operation succeeded. A failed entry still appears, so the log + /// shows what was attempted. + pub ok: bool, } /// Which way an active transfer is moving, in a [`TransferItem`]. @@ -285,6 +534,26 @@ pub enum Response { LocalResults { hits: Vec, indexing: bool }, /// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]). Transfers { items: Vec }, + /// The account's devices (reply to [`Request::ListDevices`]). + Devices { items: Vec }, + /// A node's share: members + pending invitations, and its public link if any + /// (reply to [`Request::ListShare`]). + Share { + entries: Vec, + link: Option, + }, + /// A freshly created public link, carrying the shareable URL (reply to + /// [`Request::CreatePublicLink`]). + PublicLink { link: PublicLinkInfo }, + /// Invitations addressed to me (reply to [`Request::ListInvitations`]). + Invitations { items: Vec }, + /// Saved public links (reply to [`Request::ListBookmarks`]). + Bookmarks { items: Vec }, + /// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]). + SharedByMe { items: Vec }, + /// The daemon's recent activity, newest first (reply to + /// [`Request::ListActivity`]). + Activity { items: Vec }, /// The request failed. Error { message: String }, } @@ -336,6 +605,10 @@ mod tests { name: "f.bin".into(), bytes: vec![0, 1, 2, 255], }, + Request::UploadPaths { + parent: "a".into(), + sources: vec!["/home/u/x.txt".into(), "/home/u/pics".into()], + }, ]; for req in reqs { let line = serde_json::to_string(&req).unwrap(); @@ -368,4 +641,71 @@ mod tests { assert_eq!(line, serde_json::to_string(&back).unwrap()); } } + + /// The sharing and devices requests must survive the same line-delimited JSON + /// round-trip: a mangled id or role would touch the wrong share or member. + #[test] + fn sharing_requests_roundtrip() { + let reqs = [ + Request::ListDevices, + Request::RenameDevice { + uid: "dev-1".into(), + name: "laptop".into(), + }, + Request::DeleteDevice { + uid: "dev-1".into(), + }, + Request::ShareNode { + path: "a/b".into(), + emails: vec!["x@proton.me".into(), "y@example.com".into()], + role: "editor".into(), + message: Some("hi".into()), + }, + Request::ListShare { path: "a/b".into() }, + Request::UpdateShareRole { + path: "a/b".into(), + id: "mid-1".into(), + kind: ShareEntryKind::Member, + role: "admin".into(), + }, + Request::RemoveShareEntry { + path: "a/b".into(), + id: "iid-1".into(), + kind: ShareEntryKind::ExternalInvite, + }, + Request::CreatePublicLink { + path: "a/b".into(), + role: "viewer".into(), + password: Some("pw".into()), + expires: Some(1_700_000_000), + }, + Request::RemovePublicLink { + path: "a/b".into(), + id: "url-1".into(), + }, + Request::ListSharedByMe, + Request::ListActivity { limit: 100 }, + Request::ListSharedWithMe, + Request::LeaveShared { + uid: "vol~link".into(), + }, + Request::ListInvitations, + Request::AcceptInvitation { id: "inv-1".into() }, + Request::RejectInvitation { id: "inv-1".into() }, + Request::ListBookmarks, + Request::CreateBookmark { + url: "https://drive.proton.me/urls/tok#pw".into(), + password: None, + }, + Request::DeleteBookmark { + token: "tok".into(), + }, + ]; + for req in reqs { + let line = serde_json::to_string(&req).unwrap(); + assert!(!line.contains('\n'), "wire form must be a single line"); + let back: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&back).unwrap()); + } + } } diff --git a/crates/pdfs-core/src/db.rs b/crates/pdfs-core/src/db.rs index d2661b8..78b07a8 100644 --- a/crates/pdfs-core/src/db.rs +++ b/crates/pdfs-core/src/db.rs @@ -381,9 +381,8 @@ impl Db { let tx = conn.transaction()?; let learned: HashMap, i64)> = { let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?; - let rows = stmt.query_map([], |r| { - Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?))) - })?; + let rows = + stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?))))?; rows.collect::>()? }; @@ -394,8 +393,16 @@ impl Db { VALUES (?1, ?2, ?3, ?4, ?5, ?6)", )?; for (seq, (uid, capture_time, name)) in items.iter().enumerate() { - let (ratio, thumb_state) = learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN)); - stmt.execute(params![uid, capture_time, name, ratio, thumb_state, seq as i64])?; + let (ratio, thumb_state) = + learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN)); + stmt.execute(params![ + uid, + capture_time, + name, + ratio, + thumb_state, + seq as i64 + ])?; } } tx.commit()?; diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 01726b7..84f3ceb 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -31,7 +31,7 @@ //! set, so a multi-GiB write never buffers in RAM and only the untouched //! remainder of the file is pulled from the remote — lazily, at commit. -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::OsStr; use std::fs::File; use std::io::{BufRead, BufReader, Write as _}; @@ -52,21 +52,22 @@ use fuser::{ use pdfs_core::cache::{BLOCK_SIZE, ContentCache}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - DirEntry, LocalHit, PhotoItem, PhotoThumb, Request as CtlRequest, Response as CtlResponse, - SearchHit, TransferDirection, + ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, LocalHit, + PhotoItem, PhotoThumb, PublicLinkInfo, Request as CtlRequest, Response as CtlResponse, + SearchHit, ShareEntry, ShareEntryKind, SharedItem, TransferDirection, }; use pdfs_core::db::{self, Db, StoredNode, StoredPhoto, StoredTrash}; use pdfs_core::localindex; use proton_drive_rs::proton_sdk::error::ProtonError; -use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; +use proton_drive_rs::proton_sdk::ids::{DeviceUid, DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::{ - DriveEvent, DriveEventScopeId, Node, NodeKind, ProtonDriveClient, ProtonPhotosClient, - ThumbnailType, + DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, ProtonDriveClient, + ProtonPhotosClient, ThumbnailType, }; mod transfers; use tracing::{debug, error, info, warn}; -use transfers::{CountingReader, CountingWriter, TransferRegistry}; +use transfers::{CountingReader, CountingWriter, OwnedCountingReader, TransferRegistry}; /// Attribute/entry cache lifetime handed back to the kernel. Long because the /// Phase 2 event poller actively invalidates changed inodes; without a remote @@ -317,6 +318,92 @@ impl State { } } +/// How many files the bulk uploader ships at once. Overlaps the per-file network +/// round-trips without letting an unbounded number of block buffers pile up. +const UPLOAD_CONCURRENCY: usize = 4; + +/// One file queued for bulk upload, resolved during the directory walk so the +/// concurrent phase carries everything it needs (no shared state, no `block_on`). +struct UploadTask { + /// Inode of the (already-created) remote parent folder, for interning the + /// uploaded node afterwards. + parent_ino: u64, + parent_uid: NodeUid, + name: String, + /// Local filesystem path to stream from. + path: PathBuf, + size: u64, +} + +/// Tally of a completed [`Core::upload_paths`] batch, for the daemon log. +#[derive(Default)] +struct UploadStats { + uploaded: usize, + failed: usize, + /// Total plaintext bytes of the files that uploaded successfully. + bytes: u64, + /// Folders created (or reused) to mirror the local tree. + folders: usize, +} + +/// Upload every [`UploadTask`] with at most `limit` in flight at once, each +/// streamed straight from disk and ticking its own transfer-registry guard. +/// Returns, per file, either `(parent_ino, new_uid)` for the caller to intern or +/// `(name, error)` to log — one failure never sinks the batch. +async fn run_uploads( + core: Core, + tasks: Vec, + limit: usize, +) -> Vec> { + let sem = Arc::new(tokio::sync::Semaphore::new(limit)); + let mut set = tokio::task::JoinSet::new(); + for t in tasks { + let core = core.clone(); + let sem = sem.clone(); + set.spawn(async move { + let _permit = sem.acquire_owned().await.expect("semaphore not closed"); + let file = match std::fs::File::open(&t.path) { + Ok(f) => f, + Err(e) => return Err((t.name, format!("open {}: {e}", t.path.display()))), + }; + let mtime = std::fs::metadata(&t.path) + .and_then(|m| m.modified()) + .ok() + .and_then(|mt| mt.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + let guard = core + .transfers + .begin(&t.name, "", TransferDirection::Upload, t.size); + let reader = OwnedCountingReader::new(file, guard); + match core + .client + .upload_file_from( + &t.parent_uid, + &t.name, + media_type_for(&t.name), + reader, + t.size as i64, + Vec::new(), + mtime, + false, + ) + .await + { + Ok(uid) => Ok((t.parent_ino, uid, t.size)), + Err(e) => Err((t.name, format!("upload: {e}"))), + } + }); + } + let mut out = Vec::new(); + while let Some(joined) = set.join_next().await { + match joined { + Ok(result) => out.push(result), + Err(e) => warn!(error = %e, "upload task panicked"), + } + } + out +} + /// Shared engine behind both the FUSE callbacks and the control socket: the /// Drive client, a Tokio handle to bridge the synchronous FUSE/socket threads /// to the async SDK, the inode bookkeeping, and the on-disk content cache. @@ -349,8 +436,17 @@ struct Core { /// True while the background scanner is rebuilding the local-file index, so /// `SearchLocal` can tell a front-end "still indexing" apart from "no match". indexing: Arc, + /// Recent activity, newest at the back, capped at [`ACTIVITY_CAP`]. In memory + /// only — a running feed of the mutations and transfers the daemon performed + /// this session, served to `ListActivity`. Not persisted: it is a convenience + /// view of "what just happened", not an audit trail. + activity: Arc>>, } +/// How many activity entries the daemon keeps in memory. Older entries fall off +/// the front as new ones arrive. +const ACTIVITY_CAP: usize = 500; + impl Core { /// Rehydrate the in-memory `State` maps from the DB on mount, so a cold /// start serves previously-seen metadata (stable inodes, instant listings) @@ -1107,9 +1203,7 @@ impl Core { .db .photos_page(offset, limit) .map_err(|e| e.to_string())?; - Ok(Some( - page.into_iter().map(|p| self.photo_item(p)).collect(), - )) + 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 @@ -1752,6 +1846,679 @@ impl Core { } Ok(name.to_string()) } + + /// Bulk-upload local files and directory trees under `sources` into the + /// mountpoint-relative `parent_rel` folder. Directories are recreated (or + /// merged into an existing same-named folder) and walked; the resulting flat + /// set of files is uploaded with bounded concurrency, each ticking the + /// transfer registry so a front-end sees live progress. Runs on a background + /// thread — a large tree far outlasts the control socket's read timeout — so + /// it reports only a summary for the log. Individual failures are counted and + /// logged rather than aborting the whole batch. + fn upload_paths(&self, parent_rel: &Path, sources: &[PathBuf]) -> Result { + let (pino, parent_uid) = self + .resolve_path(parent_rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + self.ensure_children(pino) + .map_err(|e| format!("enumerate: {e:?}"))?; + + // Phase 1 (sequential): build the remote folder skeleton and collect the + // flat list of files to upload. Folders must exist before their children, + // so this can't be parallelised. + let mut tasks = Vec::new(); + let mut folders = 0usize; + for src in sources { + if let Err(e) = self.collect_uploads(pino, &parent_uid, src, &mut tasks, &mut folders) { + warn!(source = %src.display(), error = %e, "skipping source"); + } + } + + // Phase 2 (concurrent): upload the files, up to UPLOAD_CONCURRENCY at once. + let outcomes = self + .rt + .block_on(run_uploads(self.clone(), tasks, UPLOAD_CONCURRENCY)); + + // Phase 3 (sequential): intern each uploaded node so it shows up in the + // listing without a re-enumeration. fetch_node uses `block_on`, so it must + // run here rather than inside the async batch. + let mut stats = UploadStats { + folders, + ..UploadStats::default() + }; + for outcome in outcomes { + match outcome { + Ok((parent_ino, uid, size)) => { + stats.uploaded += 1; + stats.bytes += size; + match self.fetch_node(&uid) { + Ok(node) => { + let mut st = self.state.lock().unwrap(); + let ino = st.intern(parent_ino, node); + if let Some(kids) = st.children.get_mut(&parent_ino) + && !kids.contains(&ino) + { + kids.push(ino); + } + } + // Uploaded fine but the metadata refresh failed; it will + // appear on the next directory enumeration regardless. + Err(e) => warn!(%uid, error = ?e, "uploaded node metadata refresh failed"), + } + } + Err((name, msg)) => { + stats.failed += 1; + warn!(name, error = %msg, "file upload failed"); + } + } + } + info!( + uploaded = stats.uploaded, + failed = stats.failed, + "bulk upload finished" + ); + Ok(stats) + } + + /// Resolve a remote child folder named `name` under `pino`, creating it if it + /// doesn't exist, and return its `(inode, uid)`. Reusing an existing same-named + /// folder makes re-uploading a directory merge into it rather than fail on a + /// duplicate name. + fn ensure_remote_folder( + &self, + pino: u64, + parent_uid: &NodeUid, + name: &str, + ) -> Result<(u64, NodeUid), String> { + if name.is_empty() || name.contains('/') { + return Err(format!("invalid folder name: {name:?}")); + } + self.ensure_children(pino) + .map_err(|e| format!("enumerate: {e:?}"))?; + { + let st = self.state.lock().unwrap(); + if let Some(kids) = st.children.get(&pino) { + for &ino in kids { + if let Some(e) = st.entries.get(&ino) + && e.node.is_folder() + && e.node.name == name + { + return Ok((ino, e.uid.clone())); + } + } + } + } + let new_uid = self + .rt + .block_on( + self.client + .create_folder(parent_uid, name, Some(now_secs())), + ) + .map_err(|e| format!("create folder {name}: {e}"))?; + let node = self + .fetch_node(&new_uid) + .map_err(|e| format!("fetch node: {e:?}"))?; + let mut st = self.state.lock().unwrap(); + let ino = st.intern(pino, node); + if let Some(kids) = st.children.get_mut(&pino) + && !kids.contains(&ino) + { + kids.push(ino); + } + Ok((ino, new_uid)) + } + + /// Walk one local source path, appending its files to `tasks`. A file becomes + /// one task; a directory is recreated remotely and recursed into (children + /// sorted for a stable order). Symlinks and other special files are skipped. + fn collect_uploads( + &self, + pino: u64, + parent_uid: &NodeUid, + src: &Path, + tasks: &mut Vec, + folders: &mut usize, + ) -> Result<(), String> { + let meta = + std::fs::symlink_metadata(src).map_err(|e| format!("stat {}: {e}", src.display()))?; + let name = src + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| format!("unusable name: {}", src.display()))? + .to_string(); + + if meta.is_dir() { + let (child_ino, child_uid) = self.ensure_remote_folder(pino, parent_uid, &name)?; + *folders += 1; + let mut entries: Vec = std::fs::read_dir(src) + .map_err(|e| format!("read dir {}: {e}", src.display()))? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .collect(); + entries.sort(); + for entry in entries { + if let Err(e) = self.collect_uploads(child_ino, &child_uid, &entry, tasks, folders) + { + warn!(source = %entry.display(), error = %e, "skipping entry"); + } + } + } else if meta.is_file() { + if name.contains('/') { + return Err(format!("invalid file name: {name:?}")); + } + tasks.push(UploadTask { + parent_ino: pino, + parent_uid: parent_uid.clone(), + name, + path: src.to_path_buf(), + size: meta.len(), + }); + } + Ok(()) + } + + // ---- devices ---------------------------------------------------------- + + /// List the account's registered devices. + fn list_devices(&self) -> Result, String> { + let devices = self + .rt + .block_on(self.client.enumerate_devices()) + .map_err(|e| format!("list devices: {e}"))?; + Ok(devices + .into_iter() + .map(|d| DeviceInfo { + uid: d.uid.to_string(), + 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. + fn rename_device(&self, uid: &str, name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("device name must not be empty".to_string()); + } + let device_uid = DeviceUid::from(uid); + self.rt + .block_on(self.client.rename_device(&device_uid, name)) + .map_err(|e| format!("rename device: {e}"))?; + Ok(()) + } + + /// Delete (deregister) a device by its uid. + fn delete_device(&self, uid: &str) -> Result<(), String> { + let device_uid = DeviceUid::from(uid); + self.rt + .block_on(self.client.delete_device(&device_uid)) + .map_err(|e| format!("delete device: {e}"))?; + Ok(()) + } + + // ---- sharing a node --------------------------------------------------- + + /// Invite Proton and/or external emails to the node at `rel` at `role`. + /// Returns `(proton_invited, external_invited)`. + fn share_node( + &self, + rel: &Path, + emails: &[String], + role: &str, + message: Option<&str>, + ) -> Result<(usize, usize), String> { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + 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| format!("share: {e}")) + } + + /// List the members, pending invitations and public link of the node at `rel`. + fn list_share(&self, rel: &Path) -> Result<(Vec, Option), String> { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + + let mut entries = Vec::new(); + for m in self + .rt + .block_on(self.client.list_share_members(&uid)) + .map_err(|e| format!("list members: {e}"))? + { + 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| format!("list invitations: {e}"))? + { + 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| format!("list external invitations: {e}"))? + { + 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| format!("get public link: {e}"))? + .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. + fn update_share_role( + &self, + rel: &Path, + id: &str, + kind: ShareEntryKind, + role: &str, + ) -> Result<(), String> { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + 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| format!("list members: {e}"))? + .into_iter() + .find(|m| m.membership_id.to_string() == id) + .ok_or_else(|| "member not found".to_string())?; + self.rt + .block_on(self.client.update_member_role(&member, role)) + .map_err(|e| format!("update role: {e}")) + } + ShareEntryKind::ProtonInvite => { + let inv = self + .rt + .block_on(self.client.list_share_invitations(&uid)) + .map_err(|e| format!("list invitations: {e}"))? + .into_iter() + .find(|i| i.invitation_id == id) + .ok_or_else(|| "invitation not found".to_string())?; + self.rt + .block_on(self.client.update_invitation_role(&inv, role)) + .map_err(|e| format!("update role: {e}")) + } + ShareEntryKind::ExternalInvite => { + Err("an external invitation's role cannot be changed".to_string()) + } + } + } + + /// Remove a member, pending Proton invite, or external invite from the node + /// at `rel`. + fn remove_share_entry(&self, rel: &Path, id: &str, kind: ShareEntryKind) -> Result<(), String> { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + match kind { + ShareEntryKind::Member => { + let member = self + .rt + .block_on(self.client.list_share_members(&uid)) + .map_err(|e| format!("list members: {e}"))? + .into_iter() + .find(|m| m.membership_id.to_string() == id) + .ok_or_else(|| "member not found".to_string())?; + self.rt + .block_on(self.client.remove_member(&member)) + .map_err(|e| format!("remove member: {e}")) + } + ShareEntryKind::ProtonInvite => { + let inv = self + .rt + .block_on(self.client.list_share_invitations(&uid)) + .map_err(|e| format!("list invitations: {e}"))? + .into_iter() + .find(|i| i.invitation_id == id) + .ok_or_else(|| "invitation not found".to_string())?; + self.rt + .block_on(self.client.delete_invitation(&inv)) + .map_err(|e| format!("revoke invitation: {e}")) + } + ShareEntryKind::ExternalInvite => { + let ext = self + .rt + .block_on(self.client.list_external_invitations(&uid)) + .map_err(|e| format!("list external invitations: {e}"))? + .into_iter() + .find(|i| i.invitation_id == id) + .ok_or_else(|| "external invitation not found".to_string())?; + self.rt + .block_on(self.client.delete_external_invitation(&ext)) + .map_err(|e| format!("revoke external invitation: {e}")) + } + } + } + + /// Create a public link on the node at `rel`, returning its info (with URL). + fn create_public_link( + &self, + rel: &Path, + role: &str, + password: Option<&str>, + expires: Option, + ) -> Result { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + let role = role_from_str(role)?; + let link = self + .rt + .block_on( + self.client + .create_public_link(&uid, role, password, expires), + ) + .map_err(|e| format!("create public link: {e}"))?; + Ok(public_link_info(link)) + } + + /// Remove the public link `id` from the node at `rel`. + fn remove_public_link(&self, rel: &Path, id: &str) -> Result<(), String> { + let (_ino, uid) = self + .resolve_path(rel) + .map_err(|e| format!("resolve path: {e:?}"))?; + let link = self + .rt + .block_on(self.client.get_public_link(&uid)) + .map_err(|e| format!("get public link: {e}"))? + .filter(|l| l.public_link_id == id) + .ok_or_else(|| "public link not found".to_string())?; + self.rt + .block_on(self.client.remove_public_link(&link)) + .map_err(|e| format!("remove public link: {e}")) + } + + // ---- shared with me --------------------------------------------------- + + /// List nodes shared with me that I have accepted. + fn list_shared_with_me(&self) -> Result, String> { + let uids = self + .rt + .block_on(self.client.enumerate_shared_with_me_node_uids()) + .map_err(|e| format!("enumerate shared: {e}"))?; + if uids.is_empty() { + return Ok(Vec::new()); + } + let nodes = self + .rt + .block_on(self.client.enumerate_nodes(&uids)) + .map_err(|e| format!("enumerate nodes: {e}"))?; + 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. + fn leave_shared(&self, uid: &str) -> Result<(), String> { + let uid = parse_uid(uid).ok_or_else(|| format!("invalid uid: {uid}"))?; + self.rt + .block_on(self.client.leave_shared_node(&uid)) + .map_err(|e| format!("leave shared: {e}")) + } + + // ---- incoming invitations --------------------------------------------- + + /// List invitations addressed to me, pending accept or reject. + fn list_invitations(&self) -> Result, String> { + let invitations = self + .rt + .block_on(self.client.list_incoming_invitations()) + .map_err(|e| format!("list invitations: {e}"))?; + 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. + fn accept_invitation(&self, id: &str) -> Result<(), String> { + self.rt + .block_on(self.client.accept_invitation(id)) + .map_err(|e| format!("accept invitation: {e}")) + } + + /// Reject an invitation addressed to me by its id. + fn reject_invitation(&self, id: &str) -> Result<(), String> { + self.rt + .block_on(self.client.reject_invitation(id)) + .map_err(|e| format!("reject invitation: {e}")) + } + + // ---- bookmarks -------------------------------------------------------- + + /// List public links saved to my account. + fn list_bookmarks(&self) -> Result, String> { + let bookmarks = self + .rt + .block_on(self.client.list_bookmarks()) + .map_err(|e| format!("list bookmarks: {e}"))?; + 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. + fn create_bookmark(&self, url: &str, password: Option<&str>) -> Result<(), String> { + self.rt + .block_on(self.client.create_bookmark(url, password)) + .map_err(|e| format!("create bookmark: {e}")) + } + + /// Remove a saved bookmark by its token. + fn delete_bookmark(&self, token: &str) -> Result<(), String> { + self.rt + .block_on(self.client.delete_bookmark(token)) + .map_err(|e| format!("delete bookmark: {e}")) + } + + // ---- 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. + fn list_shared_by_me(&self) -> Result, String> { + let uids = self + .rt + .block_on(self.client.enumerate_shared_by_me_node_uids()) + .map_err(|e| format!("enumerate shared-by-me: {e}"))?; + if uids.is_empty() { + return Ok(Vec::new()); + } + let nodes = self + .rt + .block_on(self.client.enumerate_nodes(&uids)) + .map_err(|e| format!("enumerate nodes: {e}"))?; + 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. + fn rel_path_for_uid(&self, uid: &NodeUid) -> Option { + let st = self.state.lock().unwrap(); + 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("/")) + } + + // ---- activity log ----------------------------------------------------- + + /// Record one entry in the in-memory activity log, evicting the oldest when the + /// buffer is full. Lock-guarded and cheap; callable from any thread (the bulk + /// upload logs its summary from a background thread). + fn log_activity( + &self, + kind: ActivityKind, + target: impl Into, + detail: impl Into, + ok: bool, + ) { + let entry = ActivityEntry { + time: now_secs(), + kind, + target: target.into(), + detail: detail.into(), + ok, + }; + let mut log = self.activity.lock().unwrap(); + if log.len() >= ACTIVITY_CAP { + log.pop_front(); + } + log.push_back(entry); + } + + /// The recent activity, newest first, capped at `limit` entries. + fn list_activity(&self, limit: usize) -> Vec { + let log = self.activity.lock().unwrap(); + log.iter().rev().take(limit).cloned().collect() + } +} + +/// Map a [`MemberRole`] to its wire string. +fn role_to_str(role: MemberRole) -> &'static str { + match role { + MemberRole::Viewer => "viewer", + MemberRole::Editor => "editor", + MemberRole::Admin => "admin", + MemberRole::Inherited => "inherited", + } +} + +/// Parse a wire role string into a [`MemberRole`]. "inherited" is read-only and +/// rejected here, since it cannot be sent when inviting or updating. +fn role_from_str(role: &str) -> Result { + match role.to_lowercase().as_str() { + "viewer" => Ok(MemberRole::Viewer), + "editor" => Ok(MemberRole::Editor), + "admin" => Ok(MemberRole::Admin), + other => Err(format!("invalid role: {other}")), + } +} + +/// Map a device type to a display string. +fn device_type_str(t: proton_drive_rs::DeviceType) -> &'static str { + match t { + proton_drive_rs::DeviceType::Windows => "Windows", + proton_drive_rs::DeviceType::MacOs => "MacOs", + proton_drive_rs::DeviceType::Linux => "Linux", + } +} + +/// Convert an SDK [`PublicLink`](proton_drive_rs::PublicLink) into the wire form. +fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo { + PublicLinkInfo { + id: link.public_link_id, + url: link.url, + role: role_to_str(link.role).to_string(), + expires: link.expiration_time, + has_password: link.has_custom_password, + } } /// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends @@ -1883,6 +2650,42 @@ fn node_size(node: &Node) -> u64 { } } +/// `"1 file"` / `"3 files"` — a count with a correctly pluralised noun, for +/// human-readable activity-log lines. +fn count_noun(n: usize, one: &str, many: &str) -> String { + format!("{n} {}", if n == 1 { one } else { many }) +} + +/// Bytes rendered with a binary unit and one decimal place (e.g. `"1.2 GB"`), +/// for the activity log. Uses 1024-based steps but the shorter SI labels. +fn human_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut val = bytes as f64; + let mut unit = 0; + while val >= 1024.0 && unit < UNITS.len() - 1 { + val /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{val:.1} {}", UNITS[unit]) + } +} + +/// A compact elapsed-time label for the activity log: `"820ms"`, `"43s"`, or +/// `"2m 5s"`. +fn human_duration(d: Duration) -> String { + let secs = d.as_secs(); + if secs == 0 { + format!("{}ms", d.as_millis()) + } else if secs < 60 { + format!("{secs}s") + } else { + format!("{}m {}s", secs / 60, secs % 60) + } +} + /// Wall clock in milliseconds since the epoch — the unit the persisted listings /// stamp their freshness in. fn now_ms() -> i64 { @@ -3044,10 +3847,16 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.rename(&rel, &new_name) { - Ok(name) => CtlResponse::Ok { - message: format!("renamed to {name}"), - }, - Err(e) => CtlResponse::Error { message: e }, + 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 { message: e } + } }, Err(e) => CtlResponse::Error { message: e }, }, @@ -3057,29 +3866,52 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U rel_to_mount(mountpoint, &new_parent), ) { (Ok(rel), Ok(parent_rel)) => match core.move_to(&rel, &parent_rel) { - Ok(name) => CtlResponse::Ok { - message: format!("moved {name}"), - }, - Err(e) => CtlResponse::Error { message: e }, + 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 { message: e } + } }, (Err(e), _) | (_, Err(e)) => CtlResponse::Error { message: e }, } } Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.delete(&rel) { - Ok(name) => CtlResponse::Ok { - message: format!("trashed {name}"), - }, - Err(e) => CtlResponse::Error { message: e }, + 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 { message: e } + } }, Err(e) => CtlResponse::Error { message: e }, }, Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) { Ok(parent_rel) => match core.create_folder(&parent_rel, &name) { - Ok(name) => CtlResponse::Ok { - message: format!("created folder {name}"), - }, - Err(e) => CtlResponse::Error { message: e }, + 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 { message: e } + } }, Err(e) => CtlResponse::Error { message: e }, }, @@ -3089,33 +3921,107 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U bytes, }) => match rel_to_mount(mountpoint, &parent) { Ok(parent_rel) => match core.upload(&parent_rel, &name, &bytes) { - Ok(name) => CtlResponse::Ok { - message: format!("uploaded {name}"), - }, - Err(e) => CtlResponse::Error { message: e }, + Ok(name) => { + core.log_activity(ActivityKind::Upload, &name, "", true); + CtlResponse::Ok { + message: format!("uploaded {name}"), + } + } + Err(e) => { + core.log_activity(ActivityKind::Upload, &name, &e, false); + CtlResponse::Error { message: e } + } }, Err(e) => CtlResponse::Error { message: 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 = 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 { message: e }, + } + } Ok(CtlRequest::ListTrash) => match core.list_trash() { Ok(entries) => CtlResponse::Entries { entries }, Err(e) => CtlResponse::Error { message: e }, }, Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) { - Ok(n) => CtlResponse::Ok { - message: format!("restored {n} item(s)"), - }, + Ok(n) => { + core.log_activity(ActivityKind::Restore, format!("{n} item(s)"), "", true); + CtlResponse::Ok { + message: format!("restored {n} item(s)"), + } + } Err(e) => CtlResponse::Error { message: e }, }, Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) { - Ok(n) => CtlResponse::Ok { - message: format!("permanently deleted {n} item(s)"), - }, + 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 { message: e }, }, Ok(CtlRequest::EmptyTrash) => match core.empty_trash() { - Ok(n) => CtlResponse::Ok { - message: format!("emptied trash ({n} item(s))"), - }, + 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 { message: e }, }, Ok(CtlRequest::PurgeCache) => { @@ -3150,6 +4056,166 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, } } + Ok(CtlRequest::ListDevices) => match core.list_devices() { + Ok(items) => CtlResponse::Devices { items }, + Err(e) => CtlResponse::Error { message: 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 { message: e }, + }, + Ok(CtlRequest::DeleteDevice { uid }) => match core.delete_device(&uid) { + Ok(()) => CtlResponse::Ok { + message: "device deleted".to_string(), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + 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 { message: e } + } + }, + Err(e) => CtlResponse::Error { message: 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 { message: e }, + }, + Err(e) => CtlResponse::Error { message: 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 { message: e }, + }, + Err(e) => CtlResponse::Error { message: 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 { message: e }, + }, + Err(e) => CtlResponse::Error { message: 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 { message: e } + } + }, + Err(e) => CtlResponse::Error { message: 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 { message: e }, + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() { + Ok(entries) => CtlResponse::Entries { entries }, + Err(e) => CtlResponse::Error { message: 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 { message: e }, + }, + Ok(CtlRequest::ListInvitations) => match core.list_invitations() { + Ok(items) => CtlResponse::Invitations { items }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::AcceptInvitation { id }) => match core.accept_invitation(&id) { + Ok(()) => CtlResponse::Ok { + message: "invitation accepted".to_string(), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::RejectInvitation { id }) => match core.reject_invitation(&id) { + Ok(()) => CtlResponse::Ok { + message: "invitation rejected".to_string(), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::ListBookmarks) => match core.list_bookmarks() { + Ok(items) => CtlResponse::Bookmarks { items }, + Err(e) => CtlResponse::Error { message: 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 { message: e }, + } + } + Ok(CtlRequest::DeleteBookmark { token }) => match core.delete_bookmark(&token) { + Ok(()) => CtlResponse::Ok { + message: "bookmark removed".to_string(), + }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::ListSharedByMe) => match core.list_shared_by_me() { + Ok(items) => CtlResponse::SharedByMe { items }, + Err(e) => CtlResponse::Error { message: e }, + }, + Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity { + items: core.list_activity(limit), + }, Err(e) => CtlResponse::Error { message: format!("bad request: {e}"), }, @@ -3276,6 +4342,7 @@ pub fn mount( thumb_gen: Arc::new(Mutex::new(HashSet::new())), transfers: TransferRegistry::new(), indexing: Arc::new(AtomicBool::new(false)), + activity: Arc::new(Mutex::new(VecDeque::with_capacity(ACTIVITY_CAP))), }; // Keep the launcher prompt's "This computer" index fresh. Its own thread: diff --git a/crates/pdfs-fuse/src/transfers.rs b/crates/pdfs-fuse/src/transfers.rs index ac58626..18dafe8 100644 --- a/crates/pdfs-fuse/src/transfers.rs +++ b/crates/pdfs-fuse/src/transfers.rs @@ -148,6 +148,31 @@ impl Write for CountingWriter<'_, W> { } } +/// A [`Read`] that tallies bytes read and *owns* its [`TransferGuard`], so the +/// transfer stays registered exactly as long as the reader lives. Used by the +/// concurrent bulk uploader, where each task hands its reader to the SDK and has +/// nowhere separate to park the guard; the transfer deregisters when the SDK +/// drops the reader after sealing the revision. Owning (rather than borrowing) +/// also keeps each upload future `Send + 'static` for [`tokio::task::spawn`]. +pub struct OwnedCountingReader { + inner: R, + guard: TransferGuard, +} + +impl OwnedCountingReader { + pub fn new(inner: R, guard: TransferGuard) -> Self { + Self { inner, guard } + } +} + +impl Read for OwnedCountingReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buf)?; + self.guard.add(n as u64); + Ok(n) + } +} + /// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path). pub struct CountingReader<'a, R> { inner: R, diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index b7a423d..318d68d 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -23,7 +23,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::process::Command; use std::rc::Rc; -use std::time::Duration; +use std::time::{Duration, Instant}; use adw::prelude::*; use gtk4::gio; @@ -33,7 +33,9 @@ use gtk4::glib::BoxedAnyObject; use pdfs_core::auth; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - DirEntry, PhotoItem, Request, Response, SearchHit, TransferDirection, TransferItem, send, + ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, PhotoItem, + PublicLinkInfo, Request, Response, SearchHit, ShareEntry, ShareEntryKind, SharedItem, + TransferDirection, TransferItem, send, }; use pdfs_core::service; @@ -49,6 +51,13 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(2); /// Backoff between auto-retries of a Files/Photos load while the mount service /// is still coming up (see [`load_browser`] / [`load_gallery`]). const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); + +/// How long a network-backed page (Shared, Shared-by-me, Devices, Activity) is +/// considered fresh. Re-navigating to it within this window reuses the rows +/// already on screen instead of re-fetching and flashing the "Loading…" +/// placeholder. The Retry button and every mutation still force an immediate +/// reload by clearing the page's timestamp. +const PAGE_TTL: Duration = Duration::from_secs(30); /// Idle pause after the last keystroke before a search query is sent, so typing /// doesn't fire a request per character. const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); @@ -224,6 +233,7 @@ struct Ui { /// daemon they can only fail, and a greyed button says so before the click. browser_new_folder: gtk4::Button, browser_upload: gtk4::Button, + browser_upload_folder: gtk4::Button, /// Pending debounce timer for the search box; replaced on every keystroke so /// only the last pause actually fires a [`Request::Search`]. search_source: RefCell>, @@ -302,6 +312,45 @@ struct Ui { /// in place — rebuilding the ListStore instead would reset the scroll /// position out from under the user. gallery_bound: RefCell>, + // 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). + shared_content: gtk4::Stack, + shared_status: adw::StatusPage, + shared_retry: gtk4::Button, + shared_with_me_group: adw::PreferencesGroup, + invitations_group: adw::PreferencesGroup, + bookmarks_group: adw::PreferencesGroup, + shared_rows: RefCell>, + /// Guards the Shared page's load so overlapping navigations don't stack. + shared_inflight: Cell, + /// When the Shared page last painted good data. `None` = never / invalidated, + /// forcing a fetch on next visit. See [`PAGE_TTL`]. + shared_loaded_at: Cell>, + // Devices page: one section, rebuilt wholesale on each load. + devices_content: gtk4::Stack, + devices_status: adw::StatusPage, + devices_retry: gtk4::Button, + devices_group: adw::PreferencesGroup, + devices_rows: RefCell>, + devices_inflight: Cell, + devices_loaded_at: Cell>, + // Shared (by me) page: one section listing the items I've shared, each with a + // copy-link / manage affordance. Rebuilt wholesale on each load. + shared_by_me_content: gtk4::Stack, + shared_by_me_status: adw::StatusPage, + shared_by_me_retry: gtk4::Button, + shared_by_me_group: adw::PreferencesGroup, + shared_by_me_rows: RefCell>, + shared_by_me_inflight: Cell, + shared_by_me_loaded_at: Cell>, + // Activity page: one section, newest-first, rebuilt wholesale on each load. + activity_content: gtk4::Stack, + activity_status: adw::StatusPage, + activity_retry: gtk4::Button, + activity_group: adw::PreferencesGroup, + activity_rows: RefCell>, + activity_inflight: Cell, } impl Ui { @@ -502,11 +551,19 @@ fn build_window(app: &adw::Application) { let (main_page, main_widgets) = build_main_page(); let (browser_page, browser_widgets) = build_browser_page(); let (gallery_page, gallery_widgets) = build_gallery_page(); + let (shared_page, shared_widgets) = build_shared_page(); + let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page(); + let (devices_page, devices_widgets) = build_devices_page(); + let (activity_page, activity_widgets) = build_activity_page(); let (trash_page, trash_widgets) = build_trash_page(); stack.add_named(&login_page, Some("login")); stack.add_named(&main_page, Some("main")); stack.add_named(&browser_page, Some("browser")); stack.add_named(&gallery_page, Some("gallery")); + stack.add_named(&shared_by_me_page, Some("sharedbyme")); + stack.add_named(&shared_page, Some("shared")); + stack.add_named(&devices_page, Some("devices")); + stack.add_named(&activity_page, Some("activity")); stack.add_named(&trash_page, Some("trash")); // Sidebar: the signed-in destinations. Selecting a row swaps the page stack; @@ -588,6 +645,7 @@ fn build_window(app: &adw::Application) { browser_search: browser_widgets.search.clone(), browser_new_folder: browser_widgets.new_folder.clone(), browser_upload: browser_widgets.upload.clone(), + browser_upload_folder: browser_widgets.upload_folder.clone(), search_source: RefCell::new(None), trash_model: trash_widgets.model.clone(), trash_content: trash_widgets.content.clone(), @@ -619,6 +677,35 @@ fn build_window(app: &adw::Application) { thumb_source: RefCell::new(None), relayout_source: RefCell::new(None), gallery_bound: RefCell::new(HashMap::new()), + shared_content: shared_widgets.content.clone(), + shared_status: shared_widgets.status.clone(), + shared_retry: shared_widgets.retry.clone(), + shared_with_me_group: shared_widgets.shared_with_me.clone(), + invitations_group: shared_widgets.invitations.clone(), + bookmarks_group: shared_widgets.bookmarks.clone(), + shared_rows: RefCell::new(Vec::new()), + shared_inflight: Cell::new(false), + shared_loaded_at: Cell::new(None), + devices_content: devices_widgets.content.clone(), + devices_status: devices_widgets.status.clone(), + devices_retry: devices_widgets.retry.clone(), + devices_group: devices_widgets.group.clone(), + devices_rows: RefCell::new(Vec::new()), + devices_inflight: Cell::new(false), + devices_loaded_at: Cell::new(None), + shared_by_me_content: shared_by_me_widgets.content.clone(), + shared_by_me_status: shared_by_me_widgets.status.clone(), + shared_by_me_retry: shared_by_me_widgets.retry.clone(), + shared_by_me_group: shared_by_me_widgets.group.clone(), + shared_by_me_rows: RefCell::new(Vec::new()), + shared_by_me_inflight: Cell::new(false), + shared_by_me_loaded_at: Cell::new(None), + activity_content: activity_widgets.content.clone(), + activity_status: activity_widgets.status.clone(), + activity_retry: activity_widgets.retry.clone(), + activity_group: activity_widgets.group.clone(), + activity_rows: RefCell::new(Vec::new()), + activity_inflight: Cell::new(false), }); ui.load_ratios(); @@ -631,11 +718,20 @@ fn build_window(app: &adw::Application) { ); wire_sidebar(&ui); wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view); - wire_browser_actions(&ui, &browser_widgets.new_folder, &browser_widgets.upload); + wire_browser_actions( + &ui, + &browser_widgets.new_folder, + &browser_widgets.upload, + &browser_widgets.upload_folder, + ); wire_details(&ui); wire_search(&ui); wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll); wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); + wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); + wire_shared_by_me(&ui, &shared_by_me_widgets.retry); + wire_devices(&ui, &devices_widgets.retry); + wire_activity(&ui, &activity_widgets.retry); wire_retry(&ui); // Lazily load the Files / Photos pages the first time they're shown, so the @@ -646,6 +742,19 @@ fn build_window(app: &adw::Application) { match st.visible_child_name().as_deref() { Some("browser") => load_browser(&ui_nav), Some("gallery") => load_gallery(&ui_nav, false), + // Network-backed pages skip the fetch (and the "Loading…" flash) when + // the rows on screen are still fresh; the Retry button and mutations + // invalidate the timestamp to force a reload. + Some("sharedbyme") if page_fresh(&ui_nav.shared_by_me_loaded_at) => {} + Some("sharedbyme") => load_shared_by_me(&ui_nav), + Some("shared") if page_fresh(&ui_nav.shared_loaded_at) => {} + Some("shared") => load_shared(&ui_nav), + Some("devices") if page_fresh(&ui_nav.devices_loaded_at) => {} + Some("devices") => load_devices(&ui_nav), + // Activity is intentionally not TTL-cached: it changes out from under + // the page as background uploads and edits complete, so it reloads on + // every visit to stay live. + Some("activity") => load_activity(&ui_nav), Some("trash") => load_trash(&ui_nav), _ => {} } @@ -684,9 +793,13 @@ fn build_window(app: &adw::Application) { /// The sidebar destinations, in order: the row index is the index into this table, /// and each entry is `(stack page name, label, icon)`. -const DESTINATIONS: [(&str, &str, &str); 4] = [ - ("browser", "Files", "folder-symbolic"), +const DESTINATIONS: [(&str, &str, &str); 8] = [ + ("browser", "My files", "folder-symbolic"), + ("sharedbyme", "Shared", "emblem-shared-symbolic"), + ("shared", "Shared with me", "system-users-symbolic"), + ("devices", "Computers", "computer-symbolic"), ("gallery", "Photos", "image-x-generic-symbolic"), + ("activity", "Activity", "document-open-recent-symbolic"), ("trash", "Trash", "user-trash-symbolic"), ("main", "Settings", "emblem-system-symbolic"), ]; @@ -745,6 +858,13 @@ fn wire_sidebar(ui: &Rc) { }); } +/// Whether a network-backed page painted good data within [`PAGE_TTL`] and so +/// can be reused without re-fetching. A `None` timestamp (never loaded, or +/// invalidated by a mutation) is always stale. +fn page_fresh(loaded_at: &Cell>) -> bool { + loaded_at.get().is_some_and(|t| t.elapsed() < PAGE_TTL) +} + /// Highlight the sidebar row for whichever page the stack is showing, so /// navigation that doesn't start in the sidebar (login landing on Files, the tray /// raising the window) still moves the selection. @@ -1504,6 +1624,7 @@ fn set_mounted(ui: &Rc, 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.pin_row.set_sensitive(mounted); ui.details.rename_button.set_sensitive(mounted); @@ -1589,6 +1710,9 @@ fn repaint_transfers(ui: &Rc, items: &[TransferItem]) { "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 items.is_empty() { @@ -1828,6 +1952,7 @@ struct BrowserWidgets { search: gtk4::SearchEntry, new_folder: gtk4::Button, upload: gtk4::Button, + upload_folder: gtk4::Button, /// Wraps the views + the details pane; the pane slides in on selection. split: adw::OverlaySplitView, details: DetailsWidgets, @@ -1869,10 +1994,16 @@ fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { new_folder.add_css_class("flat"); let upload = gtk4::Button::builder() .icon_name("document-send-symbolic") - .tooltip_text("Upload file") + .tooltip_text("Upload files") .valign(gtk4::Align::Center) .build(); upload.add_css_class("flat"); + let upload_folder = gtk4::Button::builder() + .icon_name("send-to-symbolic") + .tooltip_text("Upload folder") + .valign(gtk4::Align::Center) + .build(); + upload_folder.add_css_class("flat"); // Linked grid/list toggle, top-right, Nautilus-style. let grid_toggle = gtk4::ToggleButton::builder() @@ -1901,6 +2032,7 @@ fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { header.append(&crumb_scroll); header.append(&new_folder); header.append(&upload); + header.append(&upload_folder); header.append(&search); header.append(&toggles); @@ -2007,6 +2139,7 @@ fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { search, new_folder, upload, + upload_folder, split, details, grid_selection, @@ -2170,19 +2303,25 @@ fn wire_details(ui: &Rc) { let ui_open = ui.clone(); ui.details.open_button.connect_clicked(move |_| { - if let Some(entry) = ui_open.details_entry.borrow().clone() { + // 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_entry.borrow().clone(); + if let Some(entry) = entry { activate_entry(&ui_open, &entry); } }); let ui_rename = ui.clone(); ui.details.rename_button.connect_clicked(move |_| { - if let Some(entry) = ui_rename.details_entry.borrow().clone() { + let entry = ui_rename.details_entry.borrow().clone(); + if let Some(entry) = entry { prompt_rename(&ui_rename, &entry); } }); let ui_trash = ui.clone(); ui.details.trash_button.connect_clicked(move |_| { - if let Some(entry) = ui_trash.details_entry.borrow().clone() { + let entry = ui_trash.details_entry.borrow().clone(); + if let Some(entry) = entry { prompt_delete(&ui_trash, &entry); } }); @@ -2235,7 +2374,18 @@ fn show_details(ui: &Rc, entry: &DirEntry) { ui.details_suppress.set(false); *ui.details_entry.borrow_mut() = Some(entry.clone()); - ui.browser_split.set_show_sidebar(true); + // 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_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 @@ -2504,6 +2654,18 @@ fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Box, x: f64, }); menu.append(&move_it); + menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + let share = menu_item("Share…", "emblem-shared-symbolic"); + let ui_sh = ui.clone(); + let entry_sh = entry.clone(); + let pop = popover.clone(); + share.connect_clicked(move |_| { + pop.popdown(); + open_share_dialog(&ui_sh, &entry_sh); + }); + menu.append(&share); + let trash = menu_item("Move to Trash", "user-trash-symbolic"); let ui_tr = ui.clone(); let entry_tr = entry.clone(); @@ -2694,12 +2856,19 @@ fn crumb_node(ui: &Rc, label: &str, target: &str, current: bool) -> gtk4::Wi button.upcast() } -/// Wire the browser header's New-folder and Upload-file buttons. -fn wire_browser_actions(ui: &Rc, new_folder: >k4::Button, upload: >k4::Button) { +/// Wire the browser header's New-folder, Upload-files and Upload-folder buttons. +fn wire_browser_actions( + ui: &Rc, + new_folder: >k4::Button, + upload: >k4::Button, + upload_folder: >k4::Button, +) { let ui_nf = ui.clone(); new_folder.connect_clicked(move |_| prompt_new_folder(&ui_nf)); let ui_up = ui.clone(); upload.connect_clicked(move |_| prompt_upload(&ui_up)); + let ui_uf = ui.clone(); + upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); } /// Send a mutating request (rename / move / delete / mkdir / upload, or a trash @@ -2901,38 +3070,73 @@ fn prompt_new_folder(ui: &Rc) { dialog.present(win.as_ref()); } -/// Pick a local file and upload it into the current browser directory. +/// Pick one or more local files and upload them into the current browser +/// directory. The daemon streams them from disk itself, so nothing is read into +/// the GUI — even a large multi-file selection. fn prompt_upload(ui: &Rc) { let win = ui_window(ui); - let parent = ui.browser_path.borrow().clone(); - let dialog = gtk4::FileDialog::builder().title("Upload File").build(); + let dialog = gtk4::FileDialog::builder().title("Upload Files").build(); let ui = ui.clone(); - dialog.open(win.as_ref(), gio::Cancellable::NONE, move |res| { - let Ok(file) = res else { return }; - let Some(path) = file.path() else { return }; - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string(); - let bytes = match std::fs::read(&path) { - Ok(b) => b, - Err(e) => { - toast_error(&ui, "Couldn't upload", &e.to_string()); - return; - } + dialog.open_multiple(win.as_ref(), gio::Cancellable::NONE, move |res| { + let Ok(files) = res else { return }; + let sources: Vec = files + .into_iter() + .filter_map(|f| f.ok()) + .filter_map(|obj| obj.downcast::().ok()) + .filter_map(|f| f.path()) + .filter_map(|p| p.to_str().map(str::to_string)) + .collect(); + start_upload(&ui, sources); + }); +} + +/// Pick a local folder and upload it — with its whole subtree — into the current +/// browser directory. The daemon recreates the directory structure remotely. +fn prompt_upload_folder(ui: &Rc) { + let win = ui_window(ui); + let dialog = gtk4::FileDialog::builder().title("Upload 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().and_then(|p| p.to_str().map(str::to_string)) else { + return; }; - let done = format!("Uploaded “{name}”"); - run_mutation( - &ui, - Request::UploadFile { - parent: parent.clone(), - name, - bytes, - }, - done, - "Couldn't upload", - ); + start_upload(&ui, vec![path]); + }); +} + +/// Hand a set of local source paths to the daemon for background bulk upload. +/// The daemon acks at once and does the work off-socket, so we confirm the +/// hand-off with a toast; the Activity group then shows live progress and the +/// listing refreshes itself when the transfers finish (see [`repaint_transfers`]). +fn start_upload(ui: &Rc, sources: Vec) { + if sources.is_empty() { + return; + } + if !*ui.mounted.borrow() { + toast_error(ui, "Couldn't upload", "Proton Drive isn't connected."); + return; + } + let parent = ui.browser_path.borrow().clone(); + let n = sources.len(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::UploadPaths { parent, sources }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + let what = if n == 1 { + "Uploading…".to_string() + } else { + format!("Uploading {n} items…") + }; + toast(&ui, &what); + } + Ok(Ok(Response::Error { message })) => toast_error(&ui, "Couldn't upload", &message), + _ => toast_error(&ui, "Couldn't upload", "The mount service didn't respond."), + } }); } @@ -3283,6 +3487,167 @@ struct TrashWidgets { subtitle: gtk4::Label, } +/// Widgets the Shared page's load/repaint touch. +struct SharedWidgets { + content: gtk4::Stack, + status: adw::StatusPage, + shared_with_me: adw::PreferencesGroup, + invitations: adw::PreferencesGroup, + bookmarks: adw::PreferencesGroup, + retry: gtk4::Button, + add_bookmark: gtk4::Button, +} + +/// Widgets the Devices page's load/repaint touch. +struct DevicesWidgets { + content: gtk4::Stack, + status: adw::StatusPage, + group: adw::PreferencesGroup, + retry: 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. +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 header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.append(&titles); + 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, + }, + ) +} + +/// The Devices page: one section listing every device registered to the account, +/// each row offering Rename and Remove. +fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { + let title = gtk4::Label::builder() + .label("Devices") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.append(&title); + + 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("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, + group, + retry, + }, + ) +} + /// 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. /// @@ -3652,6 +4017,1603 @@ fn prompt_empty_trash(ui: &Rc) { dialog.present(win.as_ref()); } +// ---- Shared page ---------------------------------------------------------- + +/// Install the Shared page's retry and Add-Bookmark buttons. +fn wire_shared(ui: &Rc, 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. +fn shared_status(ui: &Rc, 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. +fn load_shared(ui: &Rc) { + 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. +fn shared_unreachable(ui: &Rc) { + 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. +fn repaint_shared( + ui: &Rc, + 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.invitations_group.add(&row); + rows.push((ui.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.invitations_group.add(&row); + rows.push((ui.invitations_group.clone(), row.upcast())); + } + } + + // Bookmarks: name/URL, Open / Remove. + if bookmarks.is_empty() { + let row = dim_row("No saved bookmarks."); + ui.bookmarks_group.add(&row); + rows.push((ui.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.bookmarks_group.add(&row); + rows.push((ui.bookmarks_group.clone(), row.upcast())); + } + } + + *ui.shared_rows.borrow_mut() = rows; +} + +/// Accept or reject an invitation, then reload the Shared page. +fn respond_invitation(ui: &Rc, 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. +fn prompt_leave_shared(ui: &Rc, 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. +fn prompt_remove_bookmark(ui: &Rc, 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. +fn prompt_add_bookmark(ui: &Rc) { + 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").build(); + let pw_row = adw::PasswordEntryRow::builder() + .title("Password (if the link has one)") + .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. +fn run_shared_mutation(ui: &Rc, 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 })) => toast_error(&ui, failed, &message), + _ => toast_error(&ui, failed, "The mount service didn't respond."), + } + }); +} + +// ---- Devices page --------------------------------------------------------- + +/// Install the Devices page's retry button. +fn wire_devices(ui: &Rc, retry: >k4::Button) { + let ui_retry = ui.clone(); + retry.connect_clicked(move |_| { + service::restart(); + load_devices(&ui_retry); + }); +} + +/// Show a status page in place of the Devices list. +fn devices_status(ui: &Rc, 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 the device list and repaint the page. +fn load_devices(ui: &Rc) { + if ui.devices_inflight.get() { + return; + } + ui.devices_inflight.set(true); + devices_status( + ui, + "computer-symbolic", + "Loading…", + "Reading your devices.", + false, + ); + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListDevices); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.devices_inflight.set(false); + match result { + Ok(Ok(Response::Devices { items })) => { + repaint_devices(&ui, &items); + ui.devices_loaded_at.set(Some(Instant::now())); + } + Ok(Ok(Response::Error { message })) => devices_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read devices", + &message, + false, + ), + Ok(Ok(_)) => devices_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read devices", + "Unexpected reply from the mount service.", + false, + ), + Ok(Err(_)) | Err(_) => { + ui.devices_loaded_at.set(None); + devices_unreachable(&ui); + } + } + }); +} + +/// The daemon didn't answer the Devices page. +fn devices_unreachable(ui: &Rc) { + 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 Devices section from a fresh listing. +fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { + for row in ui.devices_rows.borrow_mut().drain(..) { + ui.devices_group.remove(&row); + } + if devices.is_empty() { + devices_status( + ui, + "computer-symbolic", + "No devices", + "Desktop apps that sync a folder to this account show up here.", + false, + ); + return; + } + ui.devices_content.set_visible_child_name("list"); + let mut rows: Vec = Vec::new(); + for dev in devices { + let row = adw::ActionRow::builder() + .title(&dev.name) + .subtitle(&dev.device_type) + .build(); + row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); + let remove = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Remove device") + .valign(gtk4::Align::Center) + .build(); + remove.add_css_class("flat"); + let rename = gtk4::Button::builder() + .icon_name("document-edit-symbolic") + .tooltip_text("Rename device") + .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; +} + +/// Prompt for a new device name and rename it. +fn prompt_rename_device(ui: &Rc, 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").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. +fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading("Remove device") + .body(format!( + "Remove “{name}”? Its synced folder registration is deleted." + )) + .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. +fn run_devices_mutation(ui: &Rc, 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 })) => toast_error(&ui, failed, &message), + _ => toast_error(&ui, failed, "The mount service didn't respond."), + } + }); +} + +// ---- Shared (by me) page -------------------------------------------------- + +/// Widgets the Shared (by-me) page's load/repaint touch. +struct SharedByMeWidgets { + content: gtk4::Stack, + status: adw::StatusPage, + group: adw::PreferencesGroup, + retry: 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. +fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { + let title = gtk4::Label::builder() + .label("Shared") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.append(&title); + + 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, + }, + ) +} + +/// Install the Shared (by-me) page's retry button. +fn wire_shared_by_me(ui: &Rc, 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. +fn shared_by_me_status(ui: &Rc, 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. +fn load_shared_by_me(ui: &Rc) { + 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. +fn shared_by_me_unreachable(ui: &Rc) { + 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. +fn repaint_shared_by_me(ui: &Rc, 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 = 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. +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. +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(), + } +} + +// ---- Activity page -------------------------------------------------------- + +/// Widgets the Activity page's load/repaint touch. +struct ActivityWidgets { + content: gtk4::Stack, + status: adw::StatusPage, + group: adw::PreferencesGroup, + retry: gtk4::Button, +} + +/// The Activity page: a newest-first feed of the mutations and transfers the +/// daemon performed this session (uploads, deletes, shares, …). +fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { + let title = gtk4::Label::builder() + .label("Activity") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.append(&title); + + 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, + }, + ) +} + +/// Install the Activity page's retry button. +fn wire_activity(ui: &Rc, 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. +fn activity_status(ui: &Rc, 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"); +} + +/// Fetch the recent activity and repaint the page. +fn load_activity(ui: &Rc) { + 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. +fn activity_unreachable(ui: &Rc) { + 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); + } + }); +} + +/// Rebuild the Activity section from a fresh log. +fn repaint_activity(ui: &Rc, items: &[ActivityEntry]) { + 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 = 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. +fn activity_verb(kind: ActivityKind) -> &'static str { + match kind { + ActivityKind::Upload => "Uploaded", + 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. +fn activity_icon(kind: ActivityKind) -> &'static str { + match kind { + ActivityKind::Upload => "document-send-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. +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() +} + +/// A dim, non-interactive placeholder row for an empty section. +fn dim_row(text: &str) -> adw::ActionRow { + let row = adw::ActionRow::builder().title(text).build(); + row.add_css_class("dim-label"); + row.set_activatable(false); + row +} + +/// Open a URL in the user's default browser. +fn open_uri(url: &str) { + if let Err(e) = gio::AppInfo::launch_default_for_uri(url, None::<&gio::AppLaunchContext>) { + tracing::warn!("open uri {url}: {e}"); + } +} + +// ---- per-node Share dialog ------------------------------------------------ + +/// The roles offered in the Share dialog's dropdowns, in index order. +const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"]; + +/// Map a role dropdown index to the wire role string. +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. +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. +struct ShareDialog { + ui: Rc, + /// The node's mountpoint-relative path — how every request addresses it. + rel: String, + people: adw::PreferencesGroup, + link_group: adw::PreferencesGroup, + people_rows: RefCell>, + link_rows: RefCell>, +} + +/// Open the per-node Share dialog: invite Proton/external users, manage who has +/// access and their roles, and create/copy/remove a public link. +fn open_share_dialog(ui: &Rc, 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()), + }); + + // Invite button. + let state_inv = state.clone(); + invite_btn.connect_clicked(move |_| { + let raw = email_row.text(); + let emails: Vec = 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. +fn share_dialog_reload(state: &Rc) { + 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. +fn repaint_share_people(state: &Rc, entries: &[ShareEntry]) { + for row in state.people_rows.borrow_mut().drain(..) { + state.people.remove(&row); + } + let mut rows: Vec = 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. +fn repaint_share_link(state: &Rc, link: Option<&PublicLinkInfo>) { + for row in state.link_rows.borrow_mut().drain(..) { + state.link_group.remove(&row); + } + let mut rows: Vec = 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(); + + 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). +fn share_dialog_create_link(state: &Rc, role: String, password: Option) { + 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. +fn share_dialog_op( + state: &Rc, + req: Request, + done: &'static str, + failed: &'static str, + on_success: Option>, +) { + 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); + } + } + }); +} + +/// Uppercase the first character of a role word for read-only display. +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + /// One day-section of the photos timeline: a heading plus the photos captured /// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by /// [`group_photos`] and rendered as one [`gtk4::ListView`] row.