mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
implemented shared files, folder uploads, caching
This commit is contained in:
1 parent
cc744d56c7
commit
a38d0371ec
8 files changed
+4141
-129
No files matched your search
Generated
+147
-27
@@ -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"
|
||||
|
||||
+8
-2
@@ -10,8 +10,8 @@ authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
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" }
|
||||
+501
-16
@@ -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<PathBuf>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
/// Optional expiry, Unix epoch seconds.
|
||||
#[arg(long)]
|
||||
expires: Option<i64>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
role: String,
|
||||
message: Option<String>,
|
||||
) -> 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<String> {
|
||||
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<PathBuf>, 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}"),
|
||||
|
||||
@@ -92,6 +92,18 @@ pub enum Request {
|
||||
name: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
role: String,
|
||||
message: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
expires: Option<i64>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<i64>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Role granted to anyone with the link ("viewer" or "editor").
|
||||
pub role: String,
|
||||
/// Expiry, epoch seconds, if set.
|
||||
pub expires: Option<i64>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<PublicLinkInfo>,
|
||||
}
|
||||
|
||||
/// 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<LocalHit>, indexing: bool },
|
||||
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
|
||||
Transfers { items: Vec<TransferItem> },
|
||||
/// The account's devices (reply to [`Request::ListDevices`]).
|
||||
Devices { items: Vec<DeviceInfo> },
|
||||
/// A node's share: members + pending invitations, and its public link if any
|
||||
/// (reply to [`Request::ListShare`]).
|
||||
Share {
|
||||
entries: Vec<ShareEntry>,
|
||||
link: Option<PublicLinkInfo>,
|
||||
},
|
||||
/// 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<InvitationInfo> },
|
||||
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
||||
Bookmarks { items: Vec<BookmarkInfo> },
|
||||
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
|
||||
SharedByMe { items: Vec<SharedItem> },
|
||||
/// The daemon's recent activity, newest first (reply to
|
||||
/// [`Request::ListActivity`]).
|
||||
Activity { items: Vec<ActivityEntry> },
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,9 +381,8 @@ impl Db {
|
||||
let tx = conn.transaction()?;
|
||||
let learned: HashMap<String, (Option<f64>, 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::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
+1106
-39
File diff suppressed because it is too large.
Load diff
@@ -148,6 +148,31 @@ impl<W: Write> 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<R> {
|
||||
inner: R,
|
||||
guard: TransferGuard,
|
||||
}
|
||||
|
||||
impl<R: Read> OwnedCountingReader<R> {
|
||||
pub fn new(inner: R, guard: TransferGuard) -> Self {
|
||||
Self { inner, guard }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read> Read for OwnedCountingReader<R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
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,
|
||||
|
||||
+2002
-40
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user