Compare commits

...
Author SHA1 Message Date
nvrl bb9804498c added new folder icon, fixed db size (max log size), progress bars 2026-07-16 18:13:48 +02:00
nvrl a38d0371ec implemented shared files, folder uploads, caching 2026-07-16 01:18:03 +02:00
nvrl cc744d56c7 added photos thumbnail caching + bigger photos 2026-07-15 01:00:43 +02:00
nvrl ae0d374ec0 updated readme screenshots 2026-07-14 19:36:11 +02:00
nvrl c52d829a43 added trash 2026-07-14 14:55:54 +02:00
nvrl 30039c43d7 updated photos gallery 2026-07-14 12:40:33 +02:00
25 changed files with 12315 additions and 729 deletions

No files matched your search

Generated
+835 -30
View File
File diff suppressed because it is too large. Load diff
+11 -8
View File
@@ -3,15 +3,15 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96"
[workspace.dependencies]
proton-sdk = "0.1.4"
proton-drive-rs = "0.1.4"
proton-sdk = "0.1.6"
proton-drive-rs = "0.1.6"
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -27,6 +27,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
ignore = "0.4"
sha2 = "0.10"
# Thumbnail generation for photos the server has none for. Decoders only for the
# formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
rusqlite = { version = "0.32", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
clap = { version = "4", features = ["derive"] }
@@ -34,9 +38,8 @@ 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]
# Track the SDK's `main` on GitHub rather than a sibling checkout, so a clean
# clone (and CI, and the PKGBUILD) builds without the working tree next door.
# `cargo update -p proton-sdk -p proton-drive-rs` pulls newer pushes.
proton-sdk = { git = "https://github.com/narrrl/proton-sdk-rs", branch = "main" }
proton-drive-rs = { git = "https://github.com/narrrl/proton-sdk-rs", branch = "main" }
proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" }
proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" }
+13 -7
View File
@@ -26,13 +26,19 @@ The client includes several optimizations designed for high efficiency, a low me
### GUI Application & Launcher
| Login Screen | Files Browser |
| :---: | :---: |
| ![Login Screen](images/login.png) | ![Files Browser](images/files.png) |
| Photos Timeline | Search Launcher Prompt |
| :---: | :---: |
| ![Photos Timeline](images/photos.png) | ![Search Launcher Prompt](images/prompt.png) |
<table>
<tr>
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="images/settings.png" alt="Settings" width="50%"><br><sub><b>Settings</b></sub></td>
</tr>
</table>
---
+717 -19
View File
@@ -1,5 +1,6 @@
//! `pdfs` — command-line front-end for the Proton Drive Linux client.
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -9,7 +10,9 @@ 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, SyncPhase,
};
use pdfs_core::db::Db;
#[derive(Parser)]
@@ -112,15 +115,230 @@ 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).
Transfers,
/// List what is in the account's trash, with the uids the restore and
/// delete-forever commands take.
Trash,
/// Restore trashed items, by uid, to the folders they were trashed from.
Restore {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
uids: Vec<String>,
},
/// Permanently delete trashed items by uid. This cannot be undone.
DeleteForever {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
uids: Vec<String>,
},
/// Permanently delete everything in the trash. This cannot be undone.
EmptyTrash,
/// Manage the account's registered devices.
Devices {
#[command(subcommand)]
action: DeviceCmd,
},
/// Sync local folders to this machine's Proton Drive device.
Sync {
#[command(subcommand)]
action: SyncCmd,
},
/// 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 SyncCmd {
/// Add a local folder to this machine's device and upload its contents.
Add {
/// Local directory to sync.
path: PathBuf,
},
/// List this machine's synced folders.
List,
/// Remove a synced folder by id.
Rm {
/// Synced-folder id (from `sync list`).
id: i64,
/// Also delete the folder's copy in Proton Drive.
#[arg(long)]
delete_remote: bool,
},
/// Force a sync pass now (all folders, or one by id).
Now {
/// Optional synced-folder id; omit to reconcile every folder.
id: Option<i64>,
},
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
Mode {
/// Synced-folder id (from `sync list`).
id: i64,
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
#[arg(value_parser = ["mirror", "ondemand"])]
mode: 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<()> {
@@ -152,11 +370,395 @@ 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::Sync { action } => cmd_sync(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_sync(action: SyncCmd) -> Result<()> {
match action {
SyncCmd::Add { path } => {
let abs = std::fs::canonicalize(&path)
.with_context(|| format!("resolve {}", path.display()))?;
let local_path = abs
.to_str()
.ok_or_else(|| anyhow!("path is not valid UTF-8"))?
.to_string();
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
}
SyncCmd::List => match control_request(CtlRequest::ListSyncFolders)? {
CtlResponse::SyncFolders { items } if items.is_empty() => {
println!("No synced folders.")
}
CtlResponse::SyncFolders { items } => {
for f in items {
let sync = if f.last_sync == 0 {
"never".to_string()
} else {
f.last_sync.to_string()
};
println!(
"[{}] {} ({}, {}, synced: {sync})",
f.id, f.local_path, f.mode, f.state
);
// A pass in flight says what it is doing, indented under it.
if let Some(p) = &f.progress {
match p.phase {
SyncPhase::Scanning => println!(" scanning…"),
SyncPhase::Applying => println!(
" {} of {} {}",
p.done + 1,
p.total.max(p.done + 1),
p.current
),
}
}
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
},
SyncCmd::Rm { id, delete_remote } => {
ok_or_bail(control_request(CtlRequest::RemoveSyncFolder {
id,
delete_remote,
})?)?
}
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
SyncCmd::Mode { id, mode } => {
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
}
}
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",
Download => "download",
Sync => "sync",
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}: ");
@@ -344,8 +946,25 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
fn cmd_transfers() -> Result<()> {
use pdfs_core::control::TransferDirection;
match control_request(CtlRequest::GetQueueStatus)? {
CtlResponse::Transfers { items } if items.is_empty() => println!("No active transfers."),
CtlResponse::Transfers { items } => {
CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => {
println!("Nothing in progress.")
}
CtlResponse::Transfers { items, jobs } => {
// Jobs first: they are the context for the transfers under them ("of
// 40 files", "still scanning") rather than a separate list.
for j in jobs {
let count = if j.total > 0 {
format!("{} of {}", j.done, j.total)
} else {
"—".to_string()
};
let detail = if j.detail.is_empty() {
String::new()
} else {
format!(" — {}", j.detail)
};
println!("… {:>9} {}{detail}", count, j.title);
}
for t in items {
let arrow = match t.direction {
TransferDirection::Download => "↓",
@@ -415,8 +1034,22 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
}
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
CtlResponse::Photos { items, .. } => {
// The timeline reply only carries thumbnails already in the cache, so
// pull the rest of the page's in one batch to print a path per photo.
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
let thumbs: HashMap<String, Option<String>> =
match control_request(CtlRequest::PhotoThumbs { uids })? {
CtlResponse::Thumbs { items } => {
items.into_iter().map(|t| (t.uid, t.path)).collect()
}
_ => HashMap::new(),
};
for p in items {
let thumb = p.thumb_path.as_deref().unwrap_or("(no thumbnail)");
let thumb = thumbs
.get(&p.uid)
.and_then(|p| p.as_deref())
.or(p.thumb_path.as_deref())
.unwrap_or("(no thumbnail)");
println!("{} {} {thumb}", p.capture_time, p.uid);
}
}
@@ -486,6 +1119,67 @@ fn cmd_rm(path: PathBuf) -> Result<()> {
Ok(())
}
fn cmd_trash() -> Result<()> {
match control_request(CtlRequest::ListTrash)? {
CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"),
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_restore(uids: Vec<String>) -> Result<()> {
match control_request(CtlRequest::Restore { uids })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_delete_forever(uids: Vec<String>) -> Result<()> {
confirm(&format!(
"Permanently delete {} item(s)? This cannot be undone.",
uids.len()
))?;
match control_request(CtlRequest::DeleteForever { uids })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_empty_trash() -> Result<()> {
confirm("Permanently delete everything in the trash? This cannot be undone.")?;
match control_request(CtlRequest::EmptyTrash)? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// Ask for a typed `yes` before an irreversible destroy. Errors (aborting the
/// command) on anything else, including a non-interactive stdin: a piped or
/// scripted invocation must not silently wipe the trash.
fn confirm(prompt: &str) -> Result<()> {
print!("{prompt} [type \"yes\" to confirm] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if answer.trim() != "yes" {
bail!("aborted");
}
Ok(())
}
fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
match control_request(CtlRequest::CreateFolder {
parent: path_arg(&parent)?,
@@ -498,17 +1192,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}"),
+62 -11
View File
@@ -7,7 +7,7 @@
//! path, so no fresh 2FA is required until the refresh token itself expires.
use keyring::Entry;
use proton_drive_rs::ProtonDriveClient;
use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
@@ -32,6 +32,20 @@ pub struct StoredSession {
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
pub mailbox_password: String,
/// The account's key salts, captured at login.
///
/// `core/v4/keys/salts` requires the `locked` scope, which only the access
/// token minted by a password login carries: once that token has been
/// rotated through `auth/v4/refresh` (as it is on every daemon start after
/// the first), the endpoint answers 403 and the key chain can no longer be
/// unlocked. Salts only change when the password changes, so store them
/// once and seed the Drive client with them on resume.
///
/// Empty for blobs written before this field existed; [`resume_client`]
/// then falls back to fetching (and, if the token is still scoped for it,
/// backfilling) them.
#[serde(default)]
pub key_salts: Vec<KeySalt>,
}
impl StoredSession {
@@ -84,14 +98,24 @@ pub async fn login(
session.apply_second_factor_code(code.trim()).await?;
}
register_refresh_handler(&session, password.to_owned());
// Grab the key salts while this access token still has the `locked` scope:
// after its first refresh it never will again, and without them no later
// resume can unlock the key chain. See `StoredSession::key_salts`.
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
let key_salts = client.account().key_salts().await?;
save(&session, password).await?;
register_refresh_handler(&session, password.to_owned(), key_salts.clone());
save(&session, password, key_salts).await?;
Ok(())
}
/// Persist the session's current tokens + mailbox password to the keyring.
pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<()> {
/// Persist the session's current tokens, mailbox password and key salts.
pub async fn save(
session: &ProtonApiSession,
mailbox_password: &str,
key_salts: Vec<KeySalt>,
) -> Result<()> {
let tokens = session.current_tokens().await;
let stored = StoredSession {
session_id: session.session_id().as_str().to_owned(),
@@ -105,6 +129,7 @@ pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<
PasswordMode::Dual => 2,
},
mailbox_password: mailbox_password.to_owned(),
key_salts,
};
let json = serde_json::to_string(&stored)?;
keyring_entry()?.set_password(&json)?;
@@ -139,10 +164,30 @@ pub fn logout() -> Result<()> {
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
let stored = load()?;
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
let password = stored.mailbox_password.clone();
register_refresh_handler(&session, stored.mailbox_password.clone());
// Blobs written before key salts were persisted have none: fetch them once
// more and write them back, which only works while the stored access token
// still carries the `locked` scope. If it doesn't, the account must be
// logged into again — no refresh can restore that scope.
let key_salts = if stored.key_salts.is_empty() {
register_refresh_handler(&session, password.clone(), Vec::new());
let probe = ProtonDriveClient::new(&session, password.clone().into_bytes());
let salts = probe.account().key_salts().await.map_err(|e| match &e {
proton_sdk::error::ProtonError::Api(api) if api.is_insufficient_scope() => {
Error::ReloginRequired
}
_ => e.into(),
})?;
save(&session, &password, salts.clone()).await?;
salts
} else {
stored.key_salts
};
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
register_refresh_handler(&session, password.clone(), key_salts.clone());
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
Ok((client, session))
}
@@ -153,11 +198,15 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
/// preserved. Call whenever the session may have rotated its tokens through the
/// 401-refresh path so a later [`resume_client`] presents a live refresh token.
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
let mailbox_password = load()?.mailbox_password;
save(session, &mailbox_password).await
let stored = load()?;
save(session, &stored.mailbox_password, stored.key_salts).await
}
fn register_refresh_handler(session: &ProtonApiSession, mailbox_password: String) {
fn register_refresh_handler(
session: &ProtonApiSession,
mailbox_password: String,
key_salts: Vec<KeySalt>,
) {
let session_id = session.session_id().as_str().to_owned();
let username = session.username().to_owned();
let user_id = session.user_id().as_str().to_owned();
@@ -177,9 +226,11 @@ fn register_refresh_handler(session: &ProtonApiSession, mailbox_password: String
scopes: scopes.clone(),
password_mode,
mailbox_password: mailbox_password.clone(),
key_salts: key_salts.clone(),
};
if let Ok(json) = serde_json::to_string(&stored)
&& let Ok(entry) = keyring_entry() {
&& let Ok(entry) = keyring_entry()
{
if let Err(e) = entry.set_password(&json) {
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
} else {
+538 -11
View File
@@ -44,9 +44,15 @@ pub enum Request {
/// List a directory for the in-app file browser. `path` is
/// mountpoint-relative (empty or "." = the mount root).
ListDir { path: String },
/// Fetch a page of the photos timeline, newest first. Thumbnails for the
/// page are fetched into the cache and their on-disk paths returned.
/// Fetch a page of the photos timeline, newest first. Returns metadata only:
/// a thumbnail path comes back only for photos already in the cache, so the
/// reply never waits on the network. Front-ends ask for the thumbnails they
/// actually display with [`Request::PhotoThumbs`].
PhotosTimeline { offset: usize, limit: usize },
/// Fetch thumbnails for the given photo uids, downloading the ones not
/// already cached (one batched round-trip) and replying with their on-disk
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
PhotoThumbs { uids: Vec<String> },
/// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String },
/// Upload a photo with the given name, media type, and content bytes.
@@ -86,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,
@@ -93,10 +111,331 @@ pub enum Request {
/// persist it to config so the next mount keeps it. Replies with
/// [`Response::Ok`].
SetCacheBudget { bytes: u64 },
/// Snapshot the daemon's in-flight transfers (active uploads/downloads).
/// Replies with [`Response::Transfers`]. Cheap to poll: the daemon keeps the
/// registry in memory, so a front-end can render a live progress widget.
/// Snapshot what the daemon is working on: in-flight transfers (active
/// uploads/downloads) and the longer jobs around them (scans, folder
/// skeletons, the local index, sync passes). Replies with
/// [`Response::Transfers`]. Cheap to poll: the daemon keeps the registry in
/// memory, so a front-end can render a live progress widget.
GetQueueStatus,
/// List what is in the account's trash. Replies with [`Response::Entries`];
/// a trashed node has no path inside the mount, so each entry carries only
/// its `uid` — the handle for [`Request::Restore`] and
/// [`Request::DeleteForever`] — and its `path` is empty.
ListTrash,
/// Restore trashed nodes, by uid, to the folders they were trashed from.
/// Replies with [`Response::Ok`].
Restore { uids: Vec<String> },
/// Permanently delete trashed nodes by uid. Irreversible: the content is
/// gone from Proton Drive, not moved. Replies with [`Response::Ok`].
DeleteForever { uids: Vec<String> },
/// 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 },
// ---- device folder sync (devices.md) ----------------------------------
/// Add a local folder to this machine's device, uploading its tree and
/// registering the device on first use. Replies with [`Response::Ok`].
AddSyncFolder { local_path: String },
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
ListSyncFolders,
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
/// Replies with [`Response::Ok`].
RemoveSyncFolder { id: i64, delete_remote: bool },
/// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies
/// with [`Response::Ok`].
SetSyncFolderMode { id: i64, mode: String },
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
/// Replies with [`Response::Ok`].
SyncNow { id: Option<i64> },
// ---- 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 is persisted, so it
/// survives a daemon restart: it records the mutations and transfers the
/// daemon performs (uploads, downloads, deletes, renames, shares, sync
/// passes, …), 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 synced local folder on this machine's device (in [`Response::SyncFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncFolderInfo {
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
pub id: i64,
/// Absolute local folder path.
pub local_path: String,
/// The uid of the folder's remote root under the device root.
pub remote_uid: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
/// `idle` | `syncing` | `error` | `conflict`.
pub state: String,
/// Last successful sync, epoch seconds; `0` if never.
pub last_sync: i64,
/// What the folder's sync pass is doing right now, or `None` when no pass is
/// running. Live daemon state, not a stored column.
#[serde(default)]
pub progress: Option<SyncProgress>,
}
/// Which stage a running sync pass is in, in a [`SyncProgress`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncPhase {
/// Walking the local tree, the remote tree and the stored baseline to work
/// out what changed. No per-item counts exist yet.
Scanning,
/// Applying the diff: creating folders, uploading, downloading, deleting.
Applying,
}
/// A snapshot of a sync pass in flight (in [`SyncFolderInfo::progress`]), so a
/// front-end can say what the daemon is doing rather than just "syncing".
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncProgress {
pub phase: SyncPhase,
/// Items applied so far this pass.
pub done: usize,
/// Items known to need applying. This *grows* during a pass: paths are
/// classified depth by depth (a folder must exist remotely before its
/// children can be queued), so the total is what has been discovered so far,
/// not a figure fixed up front.
pub total: usize,
/// The name of an item currently being applied, or empty between items.
/// Several run at once; this is just the most recently started.
pub current: String,
}
/// 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,
Download,
/// A whole sync pass over one folder, summarising what it moved.
Sync,
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`].
@@ -106,6 +445,23 @@ pub enum TransferDirection {
Upload,
}
/// One long-running daemon job in a [`Response::Transfers`] snapshot: work that
/// takes long enough to need reporting but doesn't move bytes over the wire —
/// walking a local tree, building a remote folder skeleton, indexing `$HOME`.
/// Byte-moving work is a [`TransferItem`] instead.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct JobItem {
/// What the job is, as a front-end would title it ("Uploading files").
pub title: String,
/// What it is doing right now ("Scanning Photos/2024"), or empty.
pub detail: String,
/// Steps finished so far.
pub done: u64,
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
/// May *grow* mid-job as more work is discovered.
pub total: u64,
}
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TransferItem {
@@ -144,8 +500,10 @@ pub struct DirEntry {
pub uid: String,
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
/// (the entry lives in the requested directory, so the caller derives the
/// path by joining its name); populated when an entry can live anywhere in
/// the tree, as for search hits rendered through the browser.
/// path by joining its name) and for a [`Request::ListTrash`] listing (a
/// trashed node has no path inside the mount at all); populated when an entry
/// can live anywhere in the tree, as for search hits rendered through the
/// browser.
#[serde(default)]
pub path: String,
}
@@ -187,8 +545,38 @@ pub struct PhotoItem {
pub uid: String,
/// Capture time, epoch seconds (the timeline is newest-first).
pub capture_time: i64,
/// On-disk path to the cached thumbnail, if one was available/fetched.
/// On-disk path to the cached thumbnail, when one is *already* cached. A
/// `None` here means "not fetched yet", not "has no thumbnail" — ask for it
/// with [`Request::PhotoThumbs`].
pub thumb_path: Option<String>,
/// File name, when the daemon knows it.
pub name: Option<String>,
/// Aspect ratio (w/h), remembered by the daemon from the last time this
/// photo's thumbnail was decoded. Lets the gallery justify its rows correctly
/// on the first frame instead of guessing and reflowing as images land.
pub ratio: Option<f64>,
/// True when this photo can never be given a thumbnail — the server has none
/// and its bytes could not be decoded locally. The tile shows a placeholder
/// rather than waiting for an image that will never come.
pub no_thumb: bool,
}
/// One thumbnail in a [`Response::Thumbs`] batch.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoThumb {
pub uid: String,
/// On-disk path, or `None` when there is no thumbnail to serve *yet*.
pub path: Option<String>,
/// True when the daemon is making this thumbnail itself, because the server
/// has none: the photo's full file is downloading and will be scaled when it
/// lands. A `None` path with `pending` set means "ask again shortly"; a `None`
/// path *without* it means the photo can never have a thumbnail, and a
/// front-end should stop asking.
///
/// Generation is not made to block the reply: one 20 MB camera photo takes
/// far longer to fetch than the whole rest of a batch, and holding the batch
/// for it would leave a screenful of ready thumbnails unpainted.
pub pending: bool,
}
/// The daemon's reply to a [`Request`].
@@ -212,7 +600,8 @@ pub enum Response {
Ok { message: String },
/// The pin registry.
Pins { pins: Vec<Pin> },
/// A directory listing (reply to [`Request::ListDir`]).
/// A directory listing (reply to [`Request::ListDir`]) or a trash listing
/// (reply to [`Request::ListTrash`]).
Entries { entries: Vec<DirEntry> },
/// A page of the photos timeline. `available` is false when the account
/// has no photos volume.
@@ -220,6 +609,8 @@ pub enum Response {
available: bool,
items: Vec<PhotoItem>,
},
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]).
@@ -228,8 +619,37 @@ pub enum Response {
/// is true while a scan of the machine is still running, so a front-end can
/// say "still indexing" instead of "no matches" on a cold first launch.
LocalResults { hits: Vec<LocalHit>, indexing: bool },
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
Transfers { items: Vec<TransferItem> },
/// A snapshot of what the daemon is working on (reply to
/// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs`
/// the longer non-transfer work around them (scans, folder skeletons, the
/// local index, sync passes). Both empty means the daemon is idle.
Transfers {
items: Vec<TransferItem>,
#[serde(default)]
jobs: Vec<JobItem>,
},
/// The account's devices (reply to [`Request::ListDevices`]).
Devices { items: Vec<DeviceInfo> },
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
SyncFolders { items: Vec<SyncFolderInfo> },
/// 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 },
}
@@ -281,6 +701,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();
@@ -290,4 +714,107 @@ mod tests {
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
/// The trash requests carry uids rather than paths; they must survive the same
/// line-delimited round-trip, since a mangled uid would restore or destroy the
/// wrong node.
#[test]
fn trash_requests_roundtrip() {
let reqs = [
Request::ListTrash,
Request::Restore {
uids: vec!["vol~link".into(), "vol~other".into()],
},
Request::DeleteForever {
uids: vec!["vol~link".into()],
},
Request::EmptyTrash,
];
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());
}
}
/// 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::AddSyncFolder {
local_path: "/home/me/Docs".into(),
},
Request::ListSyncFolders,
Request::RemoveSyncFolder {
id: 3,
delete_remote: true,
},
Request::SetSyncFolderMode {
id: 3,
mode: "ondemand".into(),
},
Request::SyncNow { id: Some(3) },
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());
}
}
}
+806 -1
View File
@@ -10,6 +10,7 @@
//! applies the forward-only schema migrations. Write-through of nodes, the
//! event cursor, FTS, and the cache index land in later phases on this schema.
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
@@ -18,10 +19,29 @@ use proton_drive_rs::{Node, NodeKind};
use rusqlite::{Connection, OptionalExtension, params};
use crate::Result;
use crate::control::{ActivityEntry, ActivityKind};
use crate::localindex::LocalEntry;
/// Current schema version. Bump on every forward migration added below.
const SCHEMA_VERSION: i64 = 6;
const SCHEMA_VERSION: i64 = 9;
/// How many activity rows to keep. Older rows are pruned on insert, so the feed
/// stays a bounded "recent history" rather than growing without limit.
const ACTIVITY_KEEP: i64 = 2000;
/// Size the WAL is truncated back to after a checkpoint. Comfortably above the
/// steady-state working set (a few MB), so the truncation only claws back the
/// outliers rather than fighting the normal write path for disk.
const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
/// A photo whose thumbnail state is not known yet: it has never been asked for.
pub const THUMB_UNKNOWN: i64 = 0;
/// A thumbnail exists for this photo — served by the server, or generated locally
/// from the full file when the server had none.
pub const THUMB_HAVE: i64 = 1;
/// This photo can never be given a thumbnail: the server has none and the bytes
/// could not be decoded locally either. Never retried.
pub const THUMB_NONE: i64 = 2;
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
@@ -42,6 +62,71 @@ pub struct SearchHit {
pub path: String,
}
/// One photo of the persisted timeline. The timeline itself is server-ordered
/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state`
/// are locally learned and survive a refresh of the timeline.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredPhoto {
pub uid: String,
pub capture_time: i64,
pub name: Option<String>,
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
pub ratio: Option<f64>,
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
pub thumb_state: i64,
}
/// One trashed node, as persisted for the Trash page. Trashed nodes live outside
/// the mounted tree, so a uid is their only handle.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredTrash {
pub uid: String,
pub name: String,
pub is_dir: bool,
pub size: i64,
pub mtime: i64,
}
/// The one Proton Drive Device this machine is registered as (devices.md).
/// Cached so restarts reuse the same device rather than creating a new one.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredDevice {
pub uid: String,
pub share_id: String,
pub root_uid: String,
pub name: String,
pub created: i64,
}
/// One local folder the user added to this device's sync set.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredSyncFolder {
pub id: i64,
pub local_path: String,
pub remote_uid: String,
pub remote_share_id: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
/// `idle` | `syncing` | `error` | `conflict`.
pub state: String,
pub last_sync: i64,
}
/// One per-file sync baseline row: what a path looked like on both sides at the
/// last successful sync, so the next reconcile can tell which side changed
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
/// its modification time and size as strings — since no cheap content hash is
/// exposed; change detection is `(mtime, size)` on each side.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredSyncEntry {
pub rel_path: String,
pub remote_uid: Option<String>,
pub local_mtime: i64,
pub local_size: i64,
pub remote_rev: Option<String>,
pub remote_hash: Option<String>,
}
/// Handle to the unified metadata database.
///
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
@@ -60,6 +145,13 @@ impl Db {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
// Hand the WAL's disk back after a checkpoint. Without a limit SQLite
// reuses the file in place but never shrinks it, so a single large
// transaction (a full-index FTS rebuild in `local_finish_scan` is the
// one that reaches this size) leaves the WAL at its high-water mark
// forever — a multi-GB file next to a database two orders of magnitude
// smaller. Checkpointing is unaffected; only the file is truncated back.
conn.pragma_update(None, "journal_size_limit", WAL_SIZE_LIMIT)?;
let db = Self {
conn: Mutex::new(conn),
@@ -123,6 +215,15 @@ impl Db {
if current < 6 {
tx.execute_batch(MIGRATION_V6)?;
}
if current < 7 {
tx.execute_batch(MIGRATION_V7)?;
}
if current < 8 {
tx.execute_batch(MIGRATION_V8)?;
}
if current < 9 {
tx.execute_batch(MIGRATION_V9)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -299,6 +400,456 @@ impl Db {
Ok(())
}
/// Read a `sync_state` value as an integer (the freshness stamps of the
/// photos timeline and the trash listing are kept there).
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
let conn = self.conn.lock().unwrap();
let v: Option<String> = conn
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![key],
|r| r.get(0),
)
.optional()?;
Ok(v.and_then(|v| v.parse().ok()))
}
/// Write a `sync_state` integer value.
pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value.to_string()],
)?;
Ok(())
}
/// Drop a `sync_state` key, so whatever it stamped counts as never fetched.
pub fn clear_state(&self, key: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
/// Replace the persisted photos timeline with `items` (newest first), keeping
/// what was locally learned about the photos that are still there: a ratio or
/// a thumbnail verdict costs a download to rediscover, while the server order
/// and capture times are cheap and authoritative.
///
/// Photos no longer in the timeline are dropped, so a deletion on another
/// client doesn't leave a ghost tile behind.
pub fn photos_replace(&self, items: &[(String, i64, Option<String>)]) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
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)?))))?;
rows.collect::<rusqlite::Result<_>>()?
};
tx.execute("DELETE FROM photos", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO photos (uid, capture_time, name, ratio, thumb_state, seq)
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
])?;
}
}
tx.commit()?;
Ok(())
}
/// One page of the persisted timeline, newest first.
pub fn photos_page(&self, offset: usize, limit: usize) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
ORDER BY seq LIMIT ?1 OFFSET ?2",
)?;
let rows = stmt.query_map(params![limit as i64, offset as i64], |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
})
})?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
}
Ok(photos)
}
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
/// path, which needs each photo's capture time (the cache validity tag) and
/// its thumbnail verdict.
pub fn photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock().unwrap();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state FROM photos
WHERE uid IN ({placeholders})"
))?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
})
})?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
}
Ok(photos)
}
/// Record what a thumbnail attempt learned: whether the photo now has one
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
/// seen. A `None` ratio leaves any previously learned one alone.
pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
params![uid, state, ratio],
)?;
Ok(())
}
/// Number of photos in the persisted timeline.
pub fn photos_count(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
/// Replace the persisted trash listing.
pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
for item in items {
stmt.execute(params![
item.uid,
item.name,
item.is_dir as i64,
item.size,
item.mtime
])?;
}
}
tx.commit()?;
Ok(())
}
/// The persisted trash listing, folders first then by name — the order the
/// Trash page shows it in.
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT uid, name, is_dir, size, mtime FROM trash
ORDER BY is_dir DESC, name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredTrash {
uid: r.get(0)?,
name: r.get(1)?,
is_dir: r.get::<_, i64>(2)? != 0,
size: r.get(3)?,
mtime: r.get(4)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
// ---- device sync (devices.md) -----------------------------------------
/// The registered device for this machine, if one has been created/cached.
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
[],
|r| {
Ok(StoredDevice {
uid: r.get(0)?,
share_id: r.get(1)?,
root_uid: r.get(2)?,
name: r.get(3)?,
created: r.get(4)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Persist (or replace) this machine's device. The table holds a single row.
pub fn device_set(&self, dev: &StoredDevice) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM device", [])?;
conn.execute(
"INSERT INTO device (uid, share_id, root_uid, name, created)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![dev.uid, dev.share_id, dev.root_uid, dev.name, dev.created],
)?;
Ok(())
}
/// Add a synced folder, returning its new row id.
pub fn sync_folder_add(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
VALUES (?1, ?2, ?3)",
params![local_path, remote_uid, remote_share_id],
)?;
Ok(conn.last_insert_rowid())
}
/// Every synced folder, oldest first.
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, local_path, remote_uid, remote_share_id, mode, state, last_sync
FROM sync_folder ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
state: r.get(5)?,
last_sync: r.get(6)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
/// Look up one synced folder by id.
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, local_path, remote_uid, remote_share_id, mode, state, last_sync
FROM sync_folder WHERE id = ?1",
params![id],
|r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
state: r.get(5)?,
last_sync: r.get(6)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Remove a synced folder and its per-file baseline.
pub fn sync_folder_remove(&self, id: i64) -> Result<bool> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute("DELETE FROM sync_entry WHERE folder_id = ?1", params![id])?;
let n = tx.execute("DELETE FROM sync_folder WHERE id = ?1", params![id])?;
tx.commit()?;
Ok(n > 0)
}
/// Update a synced folder's mode (`mirror`/`ondemand`).
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_folder SET mode = ?2 WHERE id = ?1",
params![id, mode],
)?;
Ok(())
}
/// Update a synced folder's state and stamp `last_sync` to now.
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_folder SET state = ?2, last_sync = ?3 WHERE id = ?1",
params![id, state, last_sync],
)?;
Ok(())
}
/// The whole per-file sync baseline for a folder, keyed by relative path.
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
FROM sync_entry WHERE folder_id = ?1",
)?;
let rows = stmt.query_map(params![folder_id], |r| {
Ok(StoredSyncEntry {
rel_path: r.get(0)?,
remote_uid: r.get(1)?,
local_mtime: r.get(2)?,
local_size: r.get(3)?,
remote_rev: r.get(4)?,
remote_hash: r.get(5)?,
})
})?;
let mut map = HashMap::new();
for row in rows {
let e = row?;
map.insert(e.rel_path.clone(), e);
}
Ok(map)
}
/// Insert or replace one baseline row.
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
remote_uid = excluded.remote_uid,
local_mtime = excluded.local_mtime,
local_size = excluded.local_size,
remote_rev = excluded.remote_rev,
remote_hash = excluded.remote_hash",
params![
folder_id,
e.rel_path,
e.remote_uid,
e.local_mtime,
e.local_size,
e.remote_rev,
e.remote_hash,
],
)?;
Ok(())
}
/// Drop one baseline row (a path that left the sync set on both sides).
pub fn sync_entry_remove(&self, folder_id: i64, rel_path: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2",
params![folder_id, rel_path],
)?;
Ok(())
}
// ---- activity ---------------------------------------------------------
/// Append one entry to the activity log, pruning back to [`ACTIVITY_KEEP`]
/// rows. `kind` round-trips through serde rather than a hand-written string
/// table, so adding an [`ActivityKind`] variant needs no change here.
pub fn activity_add(&self, entry: &ActivityEntry) -> Result<()> {
let kind = serde_json::to_string(&entry.kind)?;
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO activity (time, kind, target, detail, ok) VALUES (?1, ?2, ?3, ?4, ?5)",
params![entry.time, kind, entry.target, entry.detail, entry.ok],
)?;
tx.execute(
"DELETE FROM activity WHERE id <= (
SELECT id FROM activity ORDER BY id DESC LIMIT 1 OFFSET ?1
)",
params![ACTIVITY_KEEP],
)?;
tx.commit()?;
Ok(())
}
/// The most recent activity, newest first, capped at `limit` entries. Rows
/// whose stored `kind` no longer parses (written by an older build) are
/// skipped rather than failing the whole read.
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT time, kind, target, detail, ok FROM activity
ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, bool>(4)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (time, kind, target, detail, ok) = row?;
let Ok(kind) = serde_json::from_str::<ActivityKind>(&kind) else {
continue;
};
out.push(ActivityEntry {
time,
kind,
target,
detail,
ok,
});
}
Ok(out)
}
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
/// the local tree was evicted, so the old baseline is stale and would make the
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
/// leaves an empty baseline + full remote = pure download (devices.md P3).
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1",
params![folder_id],
)?;
Ok(())
}
/// Children of `parent`, for the offline `ensure_children` fast path when a
/// listed folder's `children` map entry was trimmed mid-run. Returns `None`
/// when the folder is not marked `listed` (listing unknown, must re-fetch).
@@ -849,9 +1400,115 @@ CREATE VIRTUAL TABLE local_fts USING fts5(
);
";
/// Schema v7: the photos timeline and the trash listing become persistent, so
/// opening the app paints them from disk instead of re-fetching the world (both
/// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all).
///
/// `photos.seq` preserves the server's newest-first order, which is the only order
/// the timeline has — `capture_time` ties are common (a burst of shots) and would
/// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally
/// learned*: they cost a download to rediscover, so `photos_replace` carries them
/// across a refresh while capture times and order come from the server.
const MIGRATION_V7: &str = "
CREATE TABLE photos (
uid TEXT PRIMARY KEY,
capture_time INTEGER NOT NULL,
name TEXT,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL
);
CREATE INDEX idx_photos_seq ON photos(seq);
CREATE TABLE trash (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0
);
";
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
/// **Device** (a share + root folder on the main volume); `device` is a singleton
/// row cached so we reuse the same device across restarts instead of creating a
/// new one each run. `sync_folder` is one row per local folder the user added,
/// each mapped to a folder under the device root; `mode` is `mirror` (full local
/// copy, two-way synced) or `ondemand` (FUSE mount at `local_path`, no local
/// storage). `sync_entry` is the per-file baseline for three-way merge — added in
/// this migration so Phase 2 has the table, though Phase 1 leaves it empty.
const MIGRATION_V8: &str = "
CREATE TABLE device (
uid TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
root_uid TEXT NOT NULL,
name TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_folder (
id INTEGER PRIMARY KEY,
local_path TEXT NOT NULL UNIQUE,
remote_uid TEXT NOT NULL,
remote_share_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'mirror',
state TEXT NOT NULL DEFAULT 'idle',
last_sync INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_entry (
folder_id INTEGER NOT NULL,
rel_path TEXT NOT NULL,
remote_uid TEXT,
local_mtime INTEGER NOT NULL DEFAULT 0,
local_size INTEGER NOT NULL DEFAULT 0,
remote_hash TEXT,
remote_rev TEXT,
PRIMARY KEY (folder_id, rel_path)
);
";
const MIGRATION_V9: &str = "
CREATE TABLE activity (
id INTEGER PRIMARY KEY,
time INTEGER NOT NULL,
kind TEXT NOT NULL,
target TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX activity_time ON activity(time DESC);
";
#[cfg(test)]
mod tests {
use super::*;
/// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL
/// settings can only be checked against a real file.
#[test]
fn open_bounds_the_wal_size() {
let path = std::env::temp_dir().join(format!("pdfs-db-wal-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let db = Db::open(&path).unwrap();
let conn = db.conn.lock().unwrap();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert_eq!(mode, "wal");
// Without this the WAL is reused in place but never shrinks, so one
// oversized transaction strands its high-water mark on disk forever.
let limit: i64 = conn
.query_row("PRAGMA journal_size_limit", [], |r| r.get(0))
.unwrap();
assert_eq!(limit, WAL_SIZE_LIMIT);
drop(conn);
drop(db);
let _ = std::fs::remove_file(&path);
}
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid {
@@ -879,6 +1536,111 @@ mod tests {
serde_json::from_value(v).unwrap()
}
#[test]
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
("p1".into(), 300, None),
("p2".into(), 200, None),
("p3".into(), 100, None),
])
.unwrap();
// A thumbnail attempt teaches us p1's ratio and that p2 can never have one.
db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap();
db.photo_set_thumb("p2", THUMB_NONE, None).unwrap();
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
db.photos_replace(&[
("p0".into(), 400, Some("new.jpg".into())),
("p1".into(), 300, None),
("p2".into(), 200, None),
])
.unwrap();
let page = db.photos_page(0, 10).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p0", "p1", "p2"],
"server order is preserved, and the dropped photo is gone"
);
assert_eq!(page[0].name.as_deref(), Some("new.jpg"));
// Ratios and verdicts cost a download to rediscover: they survive a refresh.
assert_eq!(page[1].ratio, Some(1.5));
assert_eq!(page[1].thumb_state, THUMB_HAVE);
assert_eq!(page[2].thumb_state, THUMB_NONE);
// A photo we know nothing about yet starts blank.
assert_eq!(page[0].ratio, None);
assert_eq!(page[0].thumb_state, THUMB_UNKNOWN);
assert_eq!(db.photos_count().unwrap(), 3);
let by_uid = db.photos_by_uid(&["p2".into()]).unwrap();
assert_eq!(by_uid.len(), 1);
assert_eq!(by_uid[0].capture_time, 200);
}
#[test]
fn photos_page_slices_the_timeline_in_order() {
let db = Db::open_in_memory().unwrap();
let items: Vec<_> = (0..5)
.map(|i| (format!("p{i}"), 500 - i as i64, None))
.collect();
db.photos_replace(&items).unwrap();
let page = db.photos_page(2, 2).unwrap();
assert_eq!(
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
["p2", "p3"]
);
assert!(db.photos_page(9, 2).unwrap().is_empty());
}
#[test]
fn trash_replace_lists_folders_first() {
let db = Db::open_in_memory().unwrap();
db.trash_replace(&[
StoredTrash {
uid: "t1".into(),
name: "zeta.txt".into(),
is_dir: false,
size: 10,
mtime: 1,
},
StoredTrash {
uid: "t2".into(),
name: "Alpha".into(),
is_dir: true,
size: 0,
mtime: 2,
},
])
.unwrap();
let items = db.trash_list().unwrap();
assert_eq!(
items.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(),
["Alpha", "zeta.txt"]
);
// A replace is a replace: emptying the trash on the server empties it here.
db.trash_replace(&[]).unwrap();
assert!(db.trash_list().unwrap().is_empty());
}
#[test]
fn state_stamps_round_trip_and_clear() {
let db = Db::open_in_memory().unwrap();
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None);
db.set_state_i64("photos_synced_ms", 1234).unwrap();
assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234));
db.clear_state("photos_synced_ms").unwrap();
assert_eq!(
db.state_i64("photos_synced_ms").unwrap(),
None,
"a cleared stamp reads as never fetched, so the next request blocks on a refresh"
);
}
fn folder(link: &str, parent: Option<&str>, name: &str) -> Node {
let parent = match parent {
Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}),
@@ -1128,6 +1890,49 @@ mod tests {
assert_eq!(db.search("unique", 10).unwrap().len(), 0);
}
fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry {
ActivityEntry {
time: 1700,
kind,
target: target.into(),
detail: "detail".into(),
ok,
}
}
#[test]
fn activity_reads_back_newest_first() {
let db = Db::open_in_memory().unwrap();
db.activity_add(&activity("a.txt", ActivityKind::Upload, true))
.unwrap();
db.activity_add(&activity("b.txt", ActivityKind::Download, false))
.unwrap();
let items = db.activity_list(10).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].target, "b.txt");
assert_eq!(items[0].kind, ActivityKind::Download);
assert!(!items[0].ok);
assert_eq!(items[0].detail, "detail");
assert_eq!(items[0].time, 1700);
assert_eq!(items[1].target, "a.txt");
assert_eq!(db.activity_list(1).unwrap().len(), 1);
}
#[test]
fn activity_prunes_to_the_keep_limit() {
let db = Db::open_in_memory().unwrap();
for i in 0..(ACTIVITY_KEEP + 10) {
db.activity_add(&activity(&format!("f{i}"), ActivityKind::Upload, true))
.unwrap();
}
let items = db.activity_list(ACTIVITY_KEEP as usize * 2).unwrap();
assert_eq!(items.len(), ACTIVITY_KEEP as usize);
// The newest survive; the oldest are the ones dropped.
assert_eq!(items[0].target, format!("f{}", ACTIVITY_KEEP + 9));
}
#[test]
fn opens_and_migrates() {
let db = Db::open_in_memory().unwrap();
+6
View File
@@ -23,6 +23,12 @@ pub enum Error {
#[error("not logged in (no saved session)")]
NotLoggedIn,
/// The stored session can no longer unlock the account key chain: reading
/// the key salts needs the `locked` scope, which a refreshed access token
/// does not have. Only a fresh password login restores it.
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired,
#[error("{0}")]
Other(String),
}
+1
View File
@@ -10,6 +10,7 @@ pub mod db;
pub mod error;
pub mod localindex;
pub mod service;
pub mod shell;
pub use error::{Error, Result};
+97
View File
@@ -0,0 +1,97 @@
//! File-manager integration: right-click actions inside the mounted Drive.
//!
//! Nautilus (and Nemo/Caja, which read the same directories) runs any executable
//! in its scripts folder with the selected paths in the environment, and shows it
//! under a "Scripts" submenu. That gives us pin/unpin from the file manager
//! without shipping a Python extension or a GNOME-version-specific plugin.
//!
//! Installation is idempotent and best-effort: the scripts are (re)written on
//! every app start so an upgraded `pdfs` never leaves a stale script behind, and
//! any failure is logged rather than surfaced — a missing file manager is not an
//! error the user needs to act on.
use std::io;
use std::path::PathBuf;
/// Scripts shipped into the file manager: `(file name, `pdfs` subcommand)`.
const SCRIPTS: [(&str, &str); 2] = [
("Keep offline (Proton Drive)", "pin"),
("Remove offline copy (Proton Drive)", "unpin"),
];
/// The script body. Nautilus passes the selection as newline-separated paths in
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
/// selection when the script runs on a local (non-URI) folder, so we prefer the
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
fn script_body(subcommand: &str) -> String {
format!(
"#!/bin/sh\n\
# Installed by proton-drive-linux. Regenerated on every app start.\n\
set -eu\n\
\n\
paths=\"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
if [ -z \"$paths\" ]; then\n\
\x20 paths=\"${{NEMO_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
fi\n\
if [ -z \"$paths\" ]; then\n\
\x20 for arg in \"$@\"; do\n\
\x20 paths=\"$paths$arg\\n\"\n\
\x20 done\n\
fi\n\
\n\
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
\x20 [ -n \"$path\" ] || continue\n\
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
done\n"
)
}
/// The scripts directories to populate, for whichever file managers are present.
fn script_dirs() -> Vec<PathBuf> {
let Some(data) = dirs_data_home() else {
return Vec::new();
};
["nautilus/scripts", "nemo/scripts"]
.iter()
.map(|rel| data.join(rel))
.collect()
}
/// `$XDG_DATA_HOME`, or `~/.local/share`.
fn dirs_data_home() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
}
/// Install (or refresh) the pin/unpin scripts. Best-effort: logs and moves on.
pub fn install_file_manager_scripts() {
for dir in script_dirs() {
// Only populate a file manager the user actually has: creating
// `~/.local/share/nemo` on a GNOME box would be litter.
if !dir.parent().is_some_and(|p| p.exists()) {
continue;
}
if let Err(e) = write_scripts(&dir) {
tracing::warn!("couldn't install file-manager scripts in {dir:?}: {e}");
}
}
}
/// Write both scripts into `dir`, creating it if needed, and make them
/// executable — a script without the exec bit is silently ignored by Nautilus.
fn write_scripts(dir: &PathBuf) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
for (name, subcommand) in SCRIPTS {
let path = dir.join(name);
std::fs::write(&path, script_body(subcommand))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
}
}
tracing::debug!("installed file-manager scripts in {dir:?}");
Ok(())
}
+2
View File
@@ -15,3 +15,5 @@ tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
image.workspace = true
notify = "8"
+2548 -118
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+135 -12
View File
@@ -1,12 +1,17 @@
//! In-memory registry of in-flight transfers, behind [`Request::GetQueueStatus`].
//! In-memory registry of in-flight work, behind [`Request::GetQueueStatus`].
//!
//! The daemon downloads and uploads whole files through the SDK's streaming
//! [`Read`]/[`Write`] variants. Wrapping that reader/writer in a
//! Two kinds of work live here. **Transfers** move bytes: the daemon downloads
//! and uploads whole files through the SDK's streaming [`Read`]/[`Write`]
//! variants, and wrapping that reader/writer in a
//! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a
//! per-transfer counter without the SDK knowing anything about progress. A
//! [`TransferGuard`] registers a transfer on creation and deregisters it on
//! drop, so the registry always reflects exactly what is in flight — even if a
//! transfer fails partway and unwinds.
//! per-transfer counter without the SDK knowing anything about progress.
//! **Jobs** ([`JobGuard`]) cover the long stretches around them that move no
//! bytes — walking a local tree, creating the remote folder skeleton, indexing
//! `$HOME` — which otherwise look to a front-end exactly like an idle daemon.
//!
//! Both register on creation and deregister on guard drop, so the registry
//! always reflects exactly what is in flight — even if the work fails partway
//! and unwinds.
//!
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
@@ -16,7 +21,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use pdfs_core::control::{TransferDirection, TransferItem};
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
/// One registered transfer. `done` is bumped from the I/O wrapper without the
/// registry lock; the rest is immutable for the transfer's lifetime.
@@ -29,11 +34,22 @@ struct Entry {
started: Instant,
}
/// The set of transfers currently in flight. Cloned `Arc`-style across the FUSE
/// session and the control-socket task (both share one registry via [`Core`]).
/// One registered job. `detail`/`done`/`total` all move as the job runs; `total`
/// may grow when more work is discovered mid-job.
struct JobEntry {
title: String,
detail: Mutex<String>,
done: AtomicU64,
total: AtomicU64,
}
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
/// the FUSE session and the control-socket task (both share one registry via
/// [`Core`]).
#[derive(Default)]
pub struct TransferRegistry {
inner: Mutex<HashMap<u64, Arc<Entry>>>,
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
next: AtomicU64,
}
@@ -68,14 +84,58 @@ impl TransferRegistry {
}
}
/// Snapshot every in-flight transfer for [`Response::Transfers`]. Speed is the
/// Register a long-running non-transfer job titled `title`, returning a guard
/// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry {
title: title.into(),
detail: Mutex::new(String::new()),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
});
self.jobs.lock().unwrap().insert(id, entry.clone());
JobGuard {
reg: self.clone(),
id,
entry,
}
}
/// Snapshot every in-flight job for [`Response::Transfers`], oldest first, so
/// a front-end's rows keep their order across polls.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn jobs_snapshot(&self) -> Vec<JobItem> {
let map = self.jobs.lock().unwrap();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| {
let e = &map[id];
JobItem {
title: e.title.clone(),
detail: e.detail.lock().unwrap().clone(),
done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed),
}
})
.collect()
}
/// Snapshot every in-flight transfer for [`Response::Transfers`], oldest
/// first so a front-end's rows keep their order across polls. Speed is the
/// running average since the transfer began — simple, and stable enough for a
/// progress widget without per-tick sampling state.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn snapshot(&self) -> Vec<TransferItem> {
let map = self.inner.lock().unwrap();
map.values()
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| &map[id])
.map(|e| {
let done = e.done.load(Ordering::Relaxed);
let secs = e.started.elapsed().as_secs_f64();
@@ -120,6 +180,44 @@ impl Drop for TransferGuard {
}
}
/// Lifetime handle for a registered job: describe what it is doing with
/// [`detail`], size it with [`set_total`], advance it with [`step`], and the job
/// leaves the registry when this drops. Shared across the tasks of one batch as
/// an `Arc`, so a concurrent phase can report a single "N of M" line.
///
/// [`detail`]: JobGuard::detail
/// [`set_total`]: JobGuard::set_total
/// [`step`]: JobGuard::step
pub struct JobGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<JobEntry>,
}
impl JobGuard {
/// Say what the job is working on right now; empty clears it.
pub fn detail(&self, what: impl Into<String>) {
*self.entry.detail.lock().unwrap() = what.into();
}
/// Set how many steps the job now knows it has. Safe to raise mid-job as more
/// work is discovered.
pub fn set_total(&self, total: u64) {
self.entry.total.store(total, Ordering::Relaxed);
}
/// Mark one more step done.
pub fn step(&self) {
self.entry.done.fetch_add(1, Ordering::Relaxed);
}
}
impl Drop for JobGuard {
fn drop(&mut self) {
self.reg.jobs.lock().unwrap().remove(&self.id);
}
}
/// A [`Write`] that tallies bytes written to a [`TransferGuard`] (download path).
pub struct CountingWriter<'a, W> {
inner: W,
@@ -148,6 +246,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,
+1
View File
@@ -24,6 +24,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
tokio.workspace = true
serde_json.workspace = true
ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
+28
View File
@@ -0,0 +1,28 @@
use std::env;
use std::process::Command;
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let status = Command::new("glib-compile-resources")
.args([
"--target",
&format!("{}/pdfs.gresource", out_dir),
"--sourcedir",
"resources/icons",
"resources/pdfs.gresource.xml",
])
.status()
.expect("Failed to execute glib-compile-resources");
if !status.success() {
panic!(
"glib-compile-resources failed with exit code: {:?}",
status.code()
);
}
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
);
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Upload-a-folder action: a folder silhouette with an up arrow knocked out of
it (one path, fill-rule evenodd, so symbolic recolouring keeps working).
Matches the Papirus folder shape (width 14, height 12) with a pixel-aligned,
proportionate up arrow cutout for size consistency. -->
<path fill="#222222" fill-rule="evenodd" d="M 1,3 V 14 C 1,14 1,15 2,15 H 14 C 14,15 15,15 15,14 V 5 C 15,4 14,4 14,4 H 9 L 7,2 H 2 C 2,2 1,2 1,3 Z M 7,12 V 8 H 5 L 8,5 L 11,8 H 9 V 12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<!-- Icons we ship ourselves because no icon theme has them. Registered as an
icon-theme resource path at startup, so they resolve by name (and recolour
with the theme) exactly like a stock symbolic icon. -->
<gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource>
</gresources>
+5029 -505
View File
File diff suppressed because it is too large. Load diff
+12 -6
View File
@@ -18,7 +18,7 @@ use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, TransferDirection, TransferItem, send};
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu.
@@ -38,12 +38,18 @@ struct DriveState {
sync: String,
}
/// Summarise a transfer snapshot into one menu line, or empty when idle. A single
/// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth.
fn sync_line(items: &[TransferItem]) -> String {
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
/// running job speaks for itself — a scan or an index rebuild is still "busy",
/// and the tray saying nothing there reads as "finished".
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => String::new(),
[] => match jobs.first() {
Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total),
Some(j) => format!("{}…", j.title),
None => String::new(),
},
[t] => {
let arrow = match t.direction {
TransferDirection::Download => "↓",
@@ -87,7 +93,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
mountpoint: PathBuf::from(mountpoint),
// Same daemon is up, so a cheap follow-up poll gives the sync line.
sync: match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items }) => sync_line(&items),
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(),
},
},
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 190 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 190 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=0.2.5
pkgver=0.4.0
pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64')