Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb9804498c | ||
|
|
a38d0371ec | ||
|
|
cc744d56c7 | ||
|
|
ae0d374ec0 | ||
|
|
c52d829a43 | ||
|
|
30039c43d7 | ||
|
|
6c67226ab0 | ||
|
|
6f7269b9b6 | ||
|
|
dacacf51bb | ||
|
|
8f28f5fd22 | ||
|
|
924e07e85f | ||
|
|
e77ab0ffa9 |
No files matched your search
@@ -62,6 +62,7 @@ jobs:
|
|||||||
cp target/release/pdfs dist/binaries/
|
cp target/release/pdfs dist/binaries/
|
||||||
cp target/release/pdfs-tray dist/binaries/
|
cp target/release/pdfs-tray dist/binaries/
|
||||||
cp target/release/pdfs-app dist/binaries/
|
cp target/release/pdfs-app dist/binaries/
|
||||||
|
cp target/release/pdfs-prompt dist/binaries/
|
||||||
|
|
||||||
# Create tarball
|
# Create tarball
|
||||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||||
@@ -78,6 +79,7 @@ jobs:
|
|||||||
cp target/release/pdfs deb/usr/bin/
|
cp target/release/pdfs deb/usr/bin/
|
||||||
cp target/release/pdfs-tray deb/usr/bin/
|
cp target/release/pdfs-tray deb/usr/bin/
|
||||||
cp target/release/pdfs-app deb/usr/bin/
|
cp target/release/pdfs-app deb/usr/bin/
|
||||||
|
cp target/release/pdfs-prompt deb/usr/bin/
|
||||||
|
|
||||||
# Copy desktop integration files
|
# Copy desktop integration files
|
||||||
cp packaging/io.narl.proton-drive-linux.desktop deb/usr/share/applications/
|
cp packaging/io.narl.proton-drive-linux.desktop deb/usr/share/applications/
|
||||||
@@ -101,7 +103,7 @@ jobs:
|
|||||||
Proton Drive client for Linux featuring:
|
Proton Drive client for Linux featuring:
|
||||||
- FUSE files-on-demand mount
|
- FUSE files-on-demand mount
|
||||||
- Command-line interface (CLI)
|
- Command-line interface (CLI)
|
||||||
- GTK4 system tray and desktop application
|
- GTK4 system tray, desktop application, and search launcher
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Build Debian package
|
# Build Debian package
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.3"
|
version = "0.4.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Local Proton SDK crates (path deps until published; switch to versioned later).
|
proton-sdk = "0.1.6"
|
||||||
proton-sdk = "0.1.3"
|
proton-drive-rs = "0.1.6"
|
||||||
proton-drive-rs = "0.1.3"
|
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -26,7 +25,12 @@ anyhow = "1"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
ignore = "0.4"
|
||||||
sha2 = "0.10"
|
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"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
@@ -34,3 +38,8 @@ rpassword = "7"
|
|||||||
fuser = "0.17"
|
fuser = "0.17"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
# Development override: build against the local SDK checkout until 0.1.6 is
|
||||||
|
# published to crates.io. Remove this block to consume the released crate.
|
||||||
|
[patch.crates-io]
|
||||||
|
proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" }
|
||||||
|
proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" }
|
||||||
@@ -8,6 +8,7 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
|
|||||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, and configure options through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, and configure options through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||||
|
- **Fuzzy Search Launcher Prompt (HUD)**: A Google Drive-style search launcher (`pdfs-prompt`) for finding and opening files or folders instantly, ideal for binding to a system-wide hotkey.
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||||
|
|
||||||
@@ -23,15 +24,21 @@ The client includes several optimizations designed for high efficiency, a low me
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### GUI Application
|
### GUI Application & Launcher
|
||||||
|
|
||||||
| Login Screen | Account Overview |
|
<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>
|
||||||
| Files (Grid View) | Files (List View) |
|
</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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,6 +83,7 @@ The compiled binaries will be available under `target/release/`:
|
|||||||
- `pdfs`: The CLI utility.
|
- `pdfs`: The CLI utility.
|
||||||
- `pdfs-app`: The GTK4 application.
|
- `pdfs-app`: The GTK4 application.
|
||||||
- `pdfs-tray`: The tray status notifier.
|
- `pdfs-tray`: The tray status notifier.
|
||||||
|
- `pdfs-prompt`: The launcher prompt for quick HUD search.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,7 +116,7 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
|
|||||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
- Sets up the Rust compiler and caches build targets to speed up runs.
|
||||||
- Compiles the workspace members in release mode.
|
- Compiles the workspace members in release mode.
|
||||||
3. **Artifact Packaging**:
|
3. **Artifact Packaging**:
|
||||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`).
|
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
|
||||||
- Packs them into a Debian package (`.deb`).
|
- Packs them into a Debian package (`.deb`).
|
||||||
4. **Publishing**:
|
4. **Publishing**:
|
||||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
|
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! `pdfs` — command-line front-end for the Proton Drive Linux client.
|
//! `pdfs` — command-line front-end for the Proton Drive Linux client.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -9,7 +10,9 @@ use clap::{Parser, Subcommand};
|
|||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::config::AppDirs;
|
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;
|
use pdfs_core::db::Db;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -112,15 +115,230 @@ enum Command {
|
|||||||
/// New folder name (a single path component).
|
/// New folder name (a single path component).
|
||||||
name: String,
|
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 {
|
Upload {
|
||||||
/// Local file to upload.
|
/// Local files and/or folders to upload.
|
||||||
file: PathBuf,
|
#[arg(required = true)]
|
||||||
/// Destination folder path, inside the mountpoint or relative to it.
|
sources: Vec<PathBuf>,
|
||||||
|
/// Destination folder, inside the mountpoint or relative to it.
|
||||||
|
#[arg(short = 't', long = "to", default_value = ".")]
|
||||||
parent: PathBuf,
|
parent: PathBuf,
|
||||||
},
|
},
|
||||||
/// Show the daemon's in-flight transfers (active uploads/downloads).
|
/// Show the daemon's in-flight transfers (active uploads/downloads).
|
||||||
Transfers,
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -152,11 +370,395 @@ fn main() -> Result<()> {
|
|||||||
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||||
Command::Rm { path } => cmd_rm(path),
|
Command::Rm { path } => cmd_rm(path),
|
||||||
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
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::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).
|
/// Read a line from stdin with a prompt (echoed).
|
||||||
fn prompt_line(label: &str) -> Result<String> {
|
fn prompt_line(label: &str) -> Result<String> {
|
||||||
print!("{label}: ");
|
print!("{label}: ");
|
||||||
@@ -251,7 +853,9 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "mount failed; retrying in 5s");
|
// `{:#}` so the whole anyhow chain lands in the log — the top
|
||||||
|
// context alone ("mount failed") never says *why*.
|
||||||
|
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
||||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,39 +896,11 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
.block_on(auth::resume_client())
|
.block_on(auth::resume_client())
|
||||||
.context("resume session (run `pdfs login` first)")?;
|
.context("resume session (run `pdfs login` first)")?;
|
||||||
|
|
||||||
// Persist rotated tokens for the daemon's lifetime. Proton refresh tokens are
|
// Token updates are persisted automatically in real-time. The SDK session has
|
||||||
// single-use: the 401-refresh path swaps in a new access+refresh pair, and if
|
// a token refresh callback registered during `resume_client` that writes updated
|
||||||
// that pair is never written back the keyring keeps a now-dead refresh token,
|
// credentials back to the OS keyring immediately upon rotation. This prevents
|
||||||
// so the next `pdfs mount` fails with `InvalidRefreshToken`. The session
|
// the system keyring from holding stale refresh tokens even if the daemon crashes,
|
||||||
// shares its token store with the Drive client (one `Arc`-backed token store),
|
// the system is suspended, or the network changes.
|
||||||
// so both the poll below and the shutdown flush see every rotation.
|
|
||||||
//
|
|
||||||
// `ProtonApiSession` is `Clone` over that shared store, so the polling task
|
|
||||||
// gets its own handle and we keep `session` here for a final flush. The poll
|
|
||||||
// is a backstop for long-lived mounts; the flush after `mount` returns is what
|
|
||||||
// makes the common case correct — a refresh shortly before a clean unmount
|
|
||||||
// would otherwise be lost in the poll gap, leaving a dead token behind.
|
|
||||||
let poll_session = session.clone();
|
|
||||||
rt.spawn(async move {
|
|
||||||
let mut last = poll_session.current_tokens().await;
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
|
||||||
let current = poll_session.current_tokens().await;
|
|
||||||
if current.access_token == last.access_token
|
|
||||||
&& current.refresh_token == last.refresh_token
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match auth::persist(&poll_session).await {
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::info!("persisted refreshed session tokens");
|
|
||||||
last = current;
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "failed to persist refreshed tokens"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let handle = rt.handle().clone();
|
let handle = rt.handle().clone();
|
||||||
let result = pdfs_fuse::mount(
|
let result = pdfs_fuse::mount(
|
||||||
client,
|
client,
|
||||||
@@ -336,11 +912,9 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
username,
|
username,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean unmount: flush whatever tokens the store holds now so the keyring
|
// Clean unmount check (as a best-effort final backup).
|
||||||
// always ends a session with the live refresh token, not a stale one. Runs
|
|
||||||
// even if `mount` errored — a rotation may still have happened first.
|
|
||||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
||||||
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
|
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
||||||
}
|
}
|
||||||
|
|
||||||
let outcome = result.context("mount failed")?;
|
let outcome = result.context("mount failed")?;
|
||||||
@@ -372,8 +946,25 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
|
|||||||
fn cmd_transfers() -> Result<()> {
|
fn cmd_transfers() -> Result<()> {
|
||||||
use pdfs_core::control::TransferDirection;
|
use pdfs_core::control::TransferDirection;
|
||||||
match control_request(CtlRequest::GetQueueStatus)? {
|
match control_request(CtlRequest::GetQueueStatus)? {
|
||||||
CtlResponse::Transfers { items } if items.is_empty() => println!("No active transfers."),
|
CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => {
|
||||||
CtlResponse::Transfers { items } => {
|
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 {
|
for t in items {
|
||||||
let arrow = match t.direction {
|
let arrow = match t.direction {
|
||||||
TransferDirection::Download => "↓",
|
TransferDirection::Download => "↓",
|
||||||
@@ -443,8 +1034,22 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
|||||||
}
|
}
|
||||||
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||||
CtlResponse::Photos { items, .. } => {
|
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 {
|
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);
|
println!("{} {} {thumb}", p.capture_time, p.uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,6 +1119,67 @@ fn cmd_rm(path: PathBuf) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
|
||||||
match control_request(CtlRequest::CreateFolder {
|
match control_request(CtlRequest::CreateFolder {
|
||||||
parent: path_arg(&parent)?,
|
parent: path_arg(&parent)?,
|
||||||
@@ -526,17 +1192,21 @@ fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_upload(file: PathBuf, parent: PathBuf) -> Result<()> {
|
fn cmd_upload(sources: Vec<PathBuf>, parent: PathBuf) -> Result<()> {
|
||||||
let bytes = std::fs::read(&file).with_context(|| format!("read {}", file.display()))?;
|
// The daemon reads the sources off its own (local) filesystem, so send it
|
||||||
let name = file
|
// absolute, canonicalized paths.
|
||||||
.file_name()
|
let mut abs = Vec::with_capacity(sources.len());
|
||||||
.and_then(|n| n.to_str())
|
for src in &sources {
|
||||||
.ok_or_else(|| anyhow!("source file has no valid name"))?
|
let p = std::fs::canonicalize(src).with_context(|| format!("resolve {}", src.display()))?;
|
||||||
.to_owned();
|
abs.push(
|
||||||
match control_request(CtlRequest::UploadFile {
|
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)?,
|
parent: path_arg(&parent)?,
|
||||||
name,
|
sources: abs,
|
||||||
bytes,
|
|
||||||
})? {
|
})? {
|
||||||
CtlResponse::Ok { message } => println!("{message}"),
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
CtlResponse::Error { message } => bail!("{message}"),
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ serde_json.workspace = true
|
|||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
|
ignore.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
||||||
|
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use proton_drive_rs::ProtonDriveClient;
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||||
use proton_sdk::config::ProtonClientConfiguration;
|
use proton_sdk::config::ProtonClientConfiguration;
|
||||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -32,6 +32,20 @@ pub struct StoredSession {
|
|||||||
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
|
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
|
||||||
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
|
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
|
||||||
pub mailbox_password: String,
|
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 {
|
impl StoredSession {
|
||||||
@@ -84,12 +98,24 @@ pub async fn login(
|
|||||||
session.apply_second_factor_code(code.trim()).await?;
|
session.apply_second_factor_code(code.trim()).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
save(&session, password).await?;
|
// 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?;
|
||||||
|
|
||||||
|
register_refresh_handler(&session, password.to_owned(), key_salts.clone());
|
||||||
|
|
||||||
|
save(&session, password, key_salts).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the session's current tokens + mailbox password to the keyring.
|
/// Persist the session's current tokens, mailbox password and key salts.
|
||||||
pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<()> {
|
pub async fn save(
|
||||||
|
session: &ProtonApiSession,
|
||||||
|
mailbox_password: &str,
|
||||||
|
key_salts: Vec<KeySalt>,
|
||||||
|
) -> Result<()> {
|
||||||
let tokens = session.current_tokens().await;
|
let tokens = session.current_tokens().await;
|
||||||
let stored = StoredSession {
|
let stored = StoredSession {
|
||||||
session_id: session.session_id().as_str().to_owned(),
|
session_id: session.session_id().as_str().to_owned(),
|
||||||
@@ -103,6 +129,7 @@ pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<
|
|||||||
PasswordMode::Dual => 2,
|
PasswordMode::Dual => 2,
|
||||||
},
|
},
|
||||||
mailbox_password: mailbox_password.to_owned(),
|
mailbox_password: mailbox_password.to_owned(),
|
||||||
|
key_salts,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&stored)?;
|
let json = serde_json::to_string(&stored)?;
|
||||||
keyring_entry()?.set_password(&json)?;
|
keyring_entry()?.set_password(&json)?;
|
||||||
@@ -137,7 +164,30 @@ pub fn logout() -> Result<()> {
|
|||||||
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||||
let stored = load()?;
|
let stored = load()?;
|
||||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||||
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
|
let password = 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
|
||||||
|
};
|
||||||
|
|
||||||
|
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
||||||
|
|
||||||
|
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
|
||||||
Ok((client, session))
|
Ok((client, session))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +198,44 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
|||||||
/// preserved. Call whenever the session may have rotated its tokens through the
|
/// 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.
|
/// 401-refresh path so a later [`resume_client`] presents a live refresh token.
|
||||||
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||||
let mailbox_password = load()?.mailbox_password;
|
let stored = load()?;
|
||||||
save(session, &mailbox_password).await
|
save(session, &stored.mailbox_password, stored.key_salts).await
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
let scopes = session.scopes().to_vec();
|
||||||
|
let password_mode = match session.password_mode() {
|
||||||
|
PasswordMode::Single => 1,
|
||||||
|
PasswordMode::Dual => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||||
|
let stored = StoredSession {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
username: username.clone(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
if let Err(e) = entry.set_password(&json) {
|
||||||
|
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
|
||||||
|
} else {
|
||||||
|
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -142,6 +142,12 @@ impl AppDirs {
|
|||||||
self.state_dir().join("tray.sock")
|
self.state_dir().join("tray.sock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's home directory — the root the daemon indexes for local
|
||||||
|
/// (non-Drive) file search. `None` if it cannot be resolved.
|
||||||
|
pub fn home_dir(&self) -> Option<PathBuf> {
|
||||||
|
directories::UserDirs::new().map(|u| u.home_dir().to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
/// Default mountpoint when the user does not pass one explicitly.
|
/// Default mountpoint when the user does not pass one explicitly.
|
||||||
pub fn default_mountpoint(&self) -> PathBuf {
|
pub fn default_mountpoint(&self) -> PathBuf {
|
||||||
directories::UserDirs::new()
|
directories::UserDirs::new()
|
||||||
|
|||||||
@@ -44,9 +44,15 @@ pub enum Request {
|
|||||||
/// List a directory for the in-app file browser. `path` is
|
/// List a directory for the in-app file browser. `path` is
|
||||||
/// mountpoint-relative (empty or "." = the mount root).
|
/// mountpoint-relative (empty or "." = the mount root).
|
||||||
ListDir { path: String },
|
ListDir { path: String },
|
||||||
/// Fetch a page of the photos timeline, newest first. Thumbnails for the
|
/// Fetch a page of the photos timeline, newest first. Returns metadata only:
|
||||||
/// page are fetched into the cache and their on-disk paths returned.
|
/// a thumbnail path comes back only for photos already in the cache, so the
|
||||||
|
/// reply never waits on the network. Front-ends ask for the thumbnails they
|
||||||
|
/// actually display with [`Request::PhotoThumbs`].
|
||||||
PhotosTimeline { offset: usize, limit: usize },
|
PhotosTimeline { offset: usize, limit: usize },
|
||||||
|
/// 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.
|
/// Download a photo's full content into the cache; replies with its path.
|
||||||
OpenPhoto { uid: String },
|
OpenPhoto { uid: String },
|
||||||
/// Upload a photo with the given name, media type, and content bytes.
|
/// Upload a photo with the given name, media type, and content bytes.
|
||||||
@@ -63,6 +69,10 @@ pub enum Request {
|
|||||||
/// Full-text search node names against the daemon's local metadata index.
|
/// Full-text search node names against the daemon's local metadata index.
|
||||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||||
Search { query: String, limit: usize },
|
Search { query: String, limit: usize },
|
||||||
|
/// Search the daemon's index of *local* (non-Drive) files on this machine.
|
||||||
|
/// Independent of [`Request::Search`] so a front-end can fire both at once and
|
||||||
|
/// render whichever lands first. Replies with [`Response::LocalResults`].
|
||||||
|
SearchLocal { query: String, limit: usize },
|
||||||
/// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a
|
/// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a
|
||||||
/// single path component (no separators). Replies with [`Response::Ok`].
|
/// single path component (no separators). Replies with [`Response::Ok`].
|
||||||
Rename { path: String, new_name: String },
|
Rename { path: String, new_name: String },
|
||||||
@@ -82,6 +92,18 @@ pub enum Request {
|
|||||||
name: String,
|
name: String,
|
||||||
bytes: Vec<u8>,
|
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
|
/// Delete all unpinned cached blobs and on-demand blocks, keeping pinned
|
||||||
/// files intact. Replies with [`Response::Ok`] reporting the bytes freed.
|
/// files intact. Replies with [`Response::Ok`] reporting the bytes freed.
|
||||||
PurgeCache,
|
PurgeCache,
|
||||||
@@ -89,10 +111,331 @@ pub enum Request {
|
|||||||
/// persist it to config so the next mount keeps it. Replies with
|
/// persist it to config so the next mount keeps it. Replies with
|
||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
SetCacheBudget { bytes: u64 },
|
SetCacheBudget { bytes: u64 },
|
||||||
/// Snapshot the daemon's in-flight transfers (active uploads/downloads).
|
/// Snapshot what the daemon is working on: in-flight transfers (active
|
||||||
/// Replies with [`Response::Transfers`]. Cheap to poll: the daemon keeps the
|
/// uploads/downloads) and the longer jobs around them (scans, folder
|
||||||
/// registry in memory, so a front-end can render a live progress widget.
|
/// 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,
|
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`].
|
/// Which way an active transfer is moving, in a [`TransferItem`].
|
||||||
@@ -102,6 +445,23 @@ pub enum TransferDirection {
|
|||||||
Upload,
|
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.
|
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct TransferItem {
|
pub struct TransferItem {
|
||||||
@@ -140,8 +500,10 @@ pub struct DirEntry {
|
|||||||
pub uid: String,
|
pub uid: String,
|
||||||
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
|
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
|
||||||
/// (the entry lives in the requested directory, so the caller derives the
|
/// (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
|
/// path by joining its name) and for a [`Request::ListTrash`] listing (a
|
||||||
/// the tree, as for search hits rendered through the browser.
|
/// 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)]
|
#[serde(default)]
|
||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
@@ -162,6 +524,20 @@ pub struct SearchHit {
|
|||||||
pub uid: String,
|
pub uid: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One hit in a [`Request::SearchLocal`] result: a file on this machine, outside
|
||||||
|
/// Proton Drive. Unlike [`SearchHit`] there is no uid or pin state — the file is
|
||||||
|
/// already local, so the front-end opens `path` directly.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct LocalHit {
|
||||||
|
pub name: String,
|
||||||
|
/// Absolute path on this machine.
|
||||||
|
pub path: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
pub size: u64,
|
||||||
|
/// Modification time, epoch seconds.
|
||||||
|
pub modified: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// One photo in a [`Request::PhotosTimeline`] page.
|
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct PhotoItem {
|
pub struct PhotoItem {
|
||||||
@@ -169,8 +545,38 @@ pub struct PhotoItem {
|
|||||||
pub uid: String,
|
pub uid: String,
|
||||||
/// Capture time, epoch seconds (the timeline is newest-first).
|
/// Capture time, epoch seconds (the timeline is newest-first).
|
||||||
pub capture_time: i64,
|
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>,
|
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`].
|
/// The daemon's reply to a [`Request`].
|
||||||
@@ -194,7 +600,8 @@ pub enum Response {
|
|||||||
Ok { message: String },
|
Ok { message: String },
|
||||||
/// The pin registry.
|
/// The pin registry.
|
||||||
Pins { pins: Vec<Pin> },
|
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> },
|
Entries { entries: Vec<DirEntry> },
|
||||||
/// A page of the photos timeline. `available` is false when the account
|
/// A page of the photos timeline. `available` is false when the account
|
||||||
/// has no photos volume.
|
/// has no photos volume.
|
||||||
@@ -202,12 +609,47 @@ pub enum Response {
|
|||||||
available: bool,
|
available: bool,
|
||||||
items: Vec<PhotoItem>,
|
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).
|
/// An on-disk path the front-end can open (e.g. a downloaded photo).
|
||||||
FilePath { path: String },
|
FilePath { path: String },
|
||||||
/// Full-text search results (reply to [`Request::Search`]).
|
/// Full-text search results (reply to [`Request::Search`]).
|
||||||
SearchResults { hits: Vec<SearchHit> },
|
SearchResults { hits: Vec<SearchHit> },
|
||||||
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
|
/// Local-file search results (reply to [`Request::SearchLocal`]). `indexing`
|
||||||
Transfers { items: Vec<TransferItem> },
|
/// 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 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.
|
/// The request failed.
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
@@ -259,6 +701,10 @@ mod tests {
|
|||||||
name: "f.bin".into(),
|
name: "f.bin".into(),
|
||||||
bytes: vec![0, 1, 2, 255],
|
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 {
|
for req in reqs {
|
||||||
let line = serde_json::to_string(&req).unwrap();
|
let line = serde_json::to_string(&req).unwrap();
|
||||||
@@ -268,4 +714,107 @@ mod tests {
|
|||||||
assert_eq!(line, serde_json::to_string(&back).unwrap());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,12 @@ pub enum Error {
|
|||||||
#[error("not logged in (no saved session)")]
|
#[error("not logged in (no saved session)")]
|
||||||
NotLoggedIn,
|
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}")]
|
#[error("{0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ pub mod config;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod localindex;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod shell;
|
||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! Filesystem walker behind the daemon's index of *local* (non-Drive) files.
|
||||||
|
//!
|
||||||
|
//! The launcher prompt searches Proton Drive and the machine's own files side by
|
||||||
|
//! side. Drive names come from the `nodes` FTS index; local names come from the
|
||||||
|
//! `local_files` index this module feeds. The daemon runs [`scan`] on a
|
||||||
|
//! background thread — never on a FUSE or control-socket thread — and streams
|
||||||
|
//! batches into [`crate::db::Db::local_upsert_batch`].
|
||||||
|
//!
|
||||||
|
//! Walking is deliberately shallow on cost: we stat each entry once (the walker
|
||||||
|
//! already has the `DirEntry` metadata) and skip the directories that dominate a
|
||||||
|
//! home directory's inode count without ever being interesting to search
|
||||||
|
//! (`node_modules`, `target`, caches, VCS internals). The Drive mountpoint is
|
||||||
|
//! always excluded — walking it would fault in every remote node through FUSE.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
|
use ignore::{WalkBuilder, WalkState};
|
||||||
|
|
||||||
|
/// Directory names skipped anywhere in the tree. These hold build artefacts,
|
||||||
|
/// dependency trees, and caches: high inode count, no search value.
|
||||||
|
const SKIP_DIRS: &[&str] = &[
|
||||||
|
"node_modules",
|
||||||
|
"target",
|
||||||
|
"__pycache__",
|
||||||
|
"venv",
|
||||||
|
".venv",
|
||||||
|
"vendor",
|
||||||
|
"dist-newstyle",
|
||||||
|
"Trash",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||||
|
/// database without bound. Reaching it stops the walk early.
|
||||||
|
const MAX_ENTRIES: usize = 400_000;
|
||||||
|
|
||||||
|
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
|
||||||
|
/// amortises, small enough that the writer lock is released often (FUSE
|
||||||
|
/// write-through shares the same connection).
|
||||||
|
const BATCH: usize = 2_000;
|
||||||
|
|
||||||
|
/// One indexed local file or directory.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LocalEntry {
|
||||||
|
/// Absolute path.
|
||||||
|
pub path: String,
|
||||||
|
/// Final path component.
|
||||||
|
pub name: String,
|
||||||
|
pub is_dir: bool,
|
||||||
|
/// Size in bytes (0 for directories).
|
||||||
|
pub size: i64,
|
||||||
|
/// Modification time, epoch seconds.
|
||||||
|
pub mtime: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk `roots` in parallel, calling `sink` with batches of entries.
|
||||||
|
///
|
||||||
|
/// Hidden files and anything under `excludes` (the Drive mountpoint, our own
|
||||||
|
/// state/cache dirs) are skipped, as are the [`SKIP_DIRS`] names at any depth.
|
||||||
|
/// Symlinks are not followed, so a link loop cannot hang the scan. Returns the
|
||||||
|
/// number of entries handed to `sink`.
|
||||||
|
///
|
||||||
|
/// `sink` is called from the walker's worker threads, serialized by an internal
|
||||||
|
/// mutex; it must not block for long.
|
||||||
|
pub fn scan(
|
||||||
|
roots: &[PathBuf],
|
||||||
|
excludes: &[PathBuf],
|
||||||
|
sink: impl FnMut(Vec<LocalEntry>) + Send,
|
||||||
|
) -> usize {
|
||||||
|
let Some((first, rest)) = roots.split_first() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut builder = WalkBuilder::new(first);
|
||||||
|
for root in rest {
|
||||||
|
builder.add(root);
|
||||||
|
}
|
||||||
|
let excludes: Vec<PathBuf> = excludes.to_vec();
|
||||||
|
builder
|
||||||
|
// `standard_filters` would honour .gitignore/.ignore files: a source
|
||||||
|
// tree's ignored-but-present files are still files the user may want to
|
||||||
|
// find, so only the hidden filter stays on.
|
||||||
|
.standard_filters(false)
|
||||||
|
.hidden(true)
|
||||||
|
.follow_links(false)
|
||||||
|
.same_file_system(true)
|
||||||
|
.threads(std::thread::available_parallelism().map_or(4, |n| n.get().min(8)))
|
||||||
|
.filter_entry(move |entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
if excludes.iter().any(|e| path.starts_with(e)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = std::sync::Mutex::new(SinkState {
|
||||||
|
buf: Vec::with_capacity(BATCH),
|
||||||
|
total: 0,
|
||||||
|
sink,
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.build_parallel().run(|| {
|
||||||
|
Box::new(|result| {
|
||||||
|
let Ok(entry) = result else {
|
||||||
|
return WalkState::Continue;
|
||||||
|
};
|
||||||
|
// Depth 0 is a root itself; indexing it adds nothing to search.
|
||||||
|
if entry.depth() == 0 {
|
||||||
|
return WalkState::Continue;
|
||||||
|
}
|
||||||
|
let Some(local) = to_entry(&entry) else {
|
||||||
|
return WalkState::Continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = state.lock().unwrap();
|
||||||
|
state.push(local);
|
||||||
|
if state.total >= MAX_ENTRIES {
|
||||||
|
return WalkState::Quit;
|
||||||
|
}
|
||||||
|
WalkState::Continue
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut state = state.into_inner().unwrap();
|
||||||
|
state.flush();
|
||||||
|
state.total
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batching buffer shared by the walker's worker threads.
|
||||||
|
struct SinkState<F: FnMut(Vec<LocalEntry>)> {
|
||||||
|
buf: Vec<LocalEntry>,
|
||||||
|
total: usize,
|
||||||
|
sink: F,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
||||||
|
fn push(&mut self, entry: LocalEntry) {
|
||||||
|
self.buf.push(entry);
|
||||||
|
self.total += 1;
|
||||||
|
if self.buf.len() >= BATCH {
|
||||||
|
self.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) {
|
||||||
|
if !self.buf.is_empty() {
|
||||||
|
let batch = std::mem::take(&mut self.buf);
|
||||||
|
(self.sink)(batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
||||||
|
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
||||||
|
/// JSON wire format) is UTF-8 only.
|
||||||
|
fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
||||||
|
let path = entry.path().to_str()?.to_string();
|
||||||
|
let name = entry.file_name().to_str()?.to_string();
|
||||||
|
let meta = entry.metadata().ok()?;
|
||||||
|
let is_dir = meta.is_dir();
|
||||||
|
let mtime = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map_or(0, |d| d.as_secs() as i64);
|
||||||
|
Some(LocalEntry {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
is_dir,
|
||||||
|
size: if is_dir { 0 } else { meta.len() as i64 },
|
||||||
|
mtime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||||
|
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||||
|
/// dirs, which hold blobs no user searches for by name.
|
||||||
|
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||||
|
vec![
|
||||||
|
mountpoint.to_path_buf(),
|
||||||
|
state_dir.to_path_buf(),
|
||||||
|
cache_dir.to_path_buf(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A scan indexes plain files, skips the excluded subtree and the junk dirs,
|
||||||
|
/// and reports names/paths the search index can match on.
|
||||||
|
#[test]
|
||||||
|
fn scan_indexes_files_and_honours_excludes() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("pdfs-scan-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
|
std::fs::create_dir_all(tmp.join("docs")).unwrap();
|
||||||
|
std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap();
|
||||||
|
std::fs::create_dir_all(tmp.join("mnt")).unwrap();
|
||||||
|
std::fs::write(tmp.join("docs/report.pdf"), b"x").unwrap();
|
||||||
|
std::fs::write(tmp.join("node_modules/pkg/index.js"), b"x").unwrap();
|
||||||
|
std::fs::write(tmp.join("mnt/remote.txt"), b"x").unwrap();
|
||||||
|
|
||||||
|
let mut got = Vec::new();
|
||||||
|
let n = scan(std::slice::from_ref(&tmp), &[tmp.join("mnt")], |batch| {
|
||||||
|
got.extend(batch)
|
||||||
|
});
|
||||||
|
|
||||||
|
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||||
|
assert_eq!(n, got.len());
|
||||||
|
assert!(names.contains(&"report.pdf"));
|
||||||
|
assert!(names.contains(&"docs"));
|
||||||
|
// Junk dir pruned, mountpoint-style exclude pruned.
|
||||||
|
assert!(!names.contains(&"index.js"));
|
||||||
|
assert!(!names.contains(&"node_modules"));
|
||||||
|
assert!(!names.contains(&"remote.txt"));
|
||||||
|
|
||||||
|
let report = got.iter().find(|e| e.name == "report.pdf").unwrap();
|
||||||
|
assert!(!report.is_dir);
|
||||||
|
assert_eq!(report.size, 1);
|
||||||
|
assert!(report.path.ends_with("docs/report.pdf"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -15,3 +15,5 @@ tracing.workspace = true
|
|||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
image.workspace = true
|
||||||
|
notify = "8"
|
||||||
@@ -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
|
//! Two kinds of work live here. **Transfers** move bytes: the daemon downloads
|
||||||
//! [`Read`]/[`Write`] variants. Wrapping that reader/writer in a
|
//! 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
|
//! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a
|
||||||
//! per-transfer counter without the SDK knowing anything about progress. A
|
//! per-transfer counter without the SDK knowing anything about progress.
|
||||||
//! [`TransferGuard`] registers a transfer on creation and deregisters it on
|
//! **Jobs** ([`JobGuard`]) cover the long stretches around them that move no
|
||||||
//! drop, so the registry always reflects exactly what is in flight — even if a
|
//! bytes — walking a local tree, creating the remote folder skeleton, indexing
|
||||||
//! transfer fails partway and unwinds.
|
//! `$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
|
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||||
|
|
||||||
@@ -16,7 +21,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
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
|
/// One registered transfer. `done` is bumped from the I/O wrapper without the
|
||||||
/// registry lock; the rest is immutable for the transfer's lifetime.
|
/// registry lock; the rest is immutable for the transfer's lifetime.
|
||||||
@@ -29,11 +34,22 @@ struct Entry {
|
|||||||
started: Instant,
|
started: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The set of transfers currently in flight. Cloned `Arc`-style across the FUSE
|
/// One registered job. `detail`/`done`/`total` all move as the job runs; `total`
|
||||||
/// session and the control-socket task (both share one registry via [`Core`]).
|
/// 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)]
|
#[derive(Default)]
|
||||||
pub struct TransferRegistry {
|
pub struct TransferRegistry {
|
||||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||||
|
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||||
next: AtomicU64,
|
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
|
/// running average since the transfer began — simple, and stable enough for a
|
||||||
/// progress widget without per-tick sampling state.
|
/// progress widget without per-tick sampling state.
|
||||||
///
|
///
|
||||||
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
|
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
|
||||||
pub fn snapshot(&self) -> Vec<TransferItem> {
|
pub fn snapshot(&self) -> Vec<TransferItem> {
|
||||||
let map = self.inner.lock().unwrap();
|
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| {
|
.map(|e| {
|
||||||
let done = e.done.load(Ordering::Relaxed);
|
let done = e.done.load(Ordering::Relaxed);
|
||||||
let secs = e.started.elapsed().as_secs_f64();
|
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).
|
/// A [`Write`] that tallies bytes written to a [`TransferGuard`] (download path).
|
||||||
pub struct CountingWriter<'a, W> {
|
pub struct CountingWriter<'a, W> {
|
||||||
inner: 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).
|
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
|
||||||
pub struct CountingReader<'a, R> {
|
pub struct CountingReader<'a, R> {
|
||||||
inner: R,
|
inner: R,
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
ksni = "0.2"
|
ksni = "0.2"
|
||||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
@@ -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>
|
||||||
@@ -18,7 +18,7 @@ use ksni::menu::StandardItem;
|
|||||||
use ksni::{MenuItem, Tray, TrayService};
|
use ksni::{MenuItem, Tray, TrayService};
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
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;
|
use pdfs_core::service;
|
||||||
|
|
||||||
/// How often the tray re-polls the daemon to refresh its menu.
|
/// How often the tray re-polls the daemon to refresh its menu.
|
||||||
@@ -38,12 +38,18 @@ struct DriveState {
|
|||||||
sync: String,
|
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
|
/// transfer names the file and its percentage; several collapse to counts so the
|
||||||
/// menu stays one line regardless of queue depth.
|
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
||||||
fn sync_line(items: &[TransferItem]) -> String {
|
/// 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 {
|
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] => {
|
[t] => {
|
||||||
let arrow = match t.direction {
|
let arrow = match t.direction {
|
||||||
TransferDirection::Download => "↓",
|
TransferDirection::Download => "↓",
|
||||||
@@ -87,7 +93,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
mountpoint: PathBuf::from(mountpoint),
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
||||||
sync: match send(socket, &Request::GetQueueStatus) {
|
sync: match send(socket, &Request::GetQueueStatus) {
|
||||||
Ok(Response::Transfers { items }) => sync_line(&items),
|
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 239 KiB |
@@ -1,21 +1,22 @@
|
|||||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
#
|
#
|
||||||
# Local-build PKGBUILD. Builds the proton-drive-linux workspace from the
|
# Local-build PKGBUILD. Builds the proton-drive-linux workspace from the
|
||||||
# checkout this file lives in. Path deps point at a sibling ../proton-sdk-rs
|
# checkout this file lives in: this is NOT a source-fetch package, it compiles
|
||||||
# checkout (the Proton SDK is unpublished), so this is NOT a source-fetch
|
# the working tree in place. The Proton SDK is unpublished and comes from its
|
||||||
# package: it compiles the working tree in place.
|
# GitHub repo (pinned by Cargo.lock), so the build needs network access — no
|
||||||
|
# sibling checkout required any more.
|
||||||
#
|
#
|
||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=0.2.1
|
pkgver=0.4.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/narl/proton-drive-linux"
|
url="https://github.com/narl/proton-drive-linux"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('fuse3' 'gtk4' 'libadwaita')
|
depends=('fuse3' 'gtk4' 'libadwaita')
|
||||||
makedepends=('cargo')
|
makedepends=('cargo' 'git')
|
||||||
provides=('pdfs')
|
provides=('pdfs')
|
||||||
# Files-on-demand mount + keyring need these at runtime.
|
# Files-on-demand mount + keyring need these at runtime.
|
||||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||||
@@ -40,7 +41,9 @@ build() {
|
|||||||
cd "$_workspace"
|
cd "$_workspace"
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
export CARGO_TARGET_DIR="$_workspace/target"
|
export CARGO_TARGET_DIR="$_workspace/target"
|
||||||
cargo build --release --frozen \
|
# `--locked` not `--frozen`: the lock is authoritative, but cargo must still be
|
||||||
|
# allowed to fetch the git-sourced SDK.
|
||||||
|
cargo build --release --locked \
|
||||||
--bin pdfs \
|
--bin pdfs \
|
||||||
--bin pdfs-tray \
|
--bin pdfs-tray \
|
||||||
--bin pdfs-app \
|
--bin pdfs-app \
|
||||||
|
|||||||