mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
Conflict copies under My Files are paired with their original and listed over the control socket. The Sync page shows them with a resolve dialog (keep original, keep copy, keep both under a new name), and the CLI gains `pdfs conflicts` and `pdfs conflicts resolve`. Every resolution goes through the Proton trash, and files that are open, being written or still owed an upload are refused.
3162 lines
116 KiB
Rust
3162 lines
116 KiB
Rust
//! `pdfs` — command-line front-end for the Proton Drive Linux client.
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs::OpenOptions;
|
|
use std::io::Write;
|
|
use std::os::unix::fs::FileTypeExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use clap::{Parser, Subcommand};
|
|
use pdfs_core::auth;
|
|
use pdfs_core::cache::ContentCache;
|
|
use pdfs_core::config::AppDirs;
|
|
use pdfs_core::control::{
|
|
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase,
|
|
pending_summary,
|
|
};
|
|
use pdfs_core::db::Db;
|
|
use pdfs_core::service;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "pdfs",
|
|
version,
|
|
about = "Proton Drive for Linux (Files On-Demand)"
|
|
)]
|
|
struct Cli {
|
|
/// Emit machine-readable JSON instead of formatted text.
|
|
///
|
|
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
|
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
|
|
/// perform an action keep their human output — a script that needs to know
|
|
/// whether one succeeded has the exit code.
|
|
#[arg(long, global = true)]
|
|
json: bool,
|
|
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
/// Whether `--json` was passed. A global rather than a threaded parameter
|
|
/// because it is read at the leaves — one line per query command — and
|
|
/// threading a display flag through every command signature would cost more
|
|
/// than it explains.
|
|
static JSON_OUTPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
|
|
|
fn json_enabled() -> bool {
|
|
JSON_OUTPUT.load(std::sync::atomic::Ordering::Relaxed)
|
|
}
|
|
|
|
/// The daemon's reply as JSON, with the response-variant tag stripped.
|
|
///
|
|
/// Serializing [`CtlResponse`] directly yields serde's externally tagged form,
|
|
/// `{"SyncFolders": {"items": […]}}`, which makes every script write
|
|
/// `jq '.SyncFolders.items[]'` and couples it to the name of an internal enum
|
|
/// variant. The payload is what the caller asked for, so that is what is
|
|
/// emitted: `{"items": […]}`.
|
|
///
|
|
/// The reply is still serialized from the daemon's own type rather than
|
|
/// rebuilt by hand, so new fields appear in the JSON without anyone
|
|
/// remembering to add them here.
|
|
fn response_payload(response: &CtlResponse) -> Result<serde_json::Value> {
|
|
let value = serde_json::to_value(response)?;
|
|
// Every variant serializes as a one-key object keyed by the variant name;
|
|
// anything else is left alone rather than guessed at.
|
|
if let serde_json::Value::Object(map) = &value
|
|
&& map.len() == 1
|
|
&& let Some((_, payload)) = map.iter().next()
|
|
{
|
|
return Ok(payload.clone());
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
/// Print the daemon's reply as JSON if `--json` is in effect, reporting whether
|
|
/// it did so — a `true` means the caller should skip its human formatting.
|
|
///
|
|
/// A daemon-side error still prints its payload, so a script can read the
|
|
/// `kind`, but then fails the command. Emitting the error and exiting `0` would
|
|
/// make every `--json` caller inspect the body to find out whether it worked,
|
|
/// when the exit code is the thing they already check.
|
|
fn emit_json(response: &CtlResponse) -> Result<bool> {
|
|
if !json_enabled() {
|
|
return Ok(false);
|
|
}
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&response_payload(response)?)?
|
|
);
|
|
if let CtlResponse::Error { message, kind } = response {
|
|
bail!("{}", cli_error(*kind, message));
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Command {
|
|
/// Log in to a Proton account (SRP + optional 2FA) and store the session.
|
|
Login {
|
|
/// Account email; prompted if omitted.
|
|
#[arg(short, long)]
|
|
username: Option<String>,
|
|
},
|
|
/// Forget the stored session.
|
|
Logout,
|
|
/// Show account login state, and mount status if a daemon is running.
|
|
Status,
|
|
/// List every local Proton Drive location.
|
|
Locations,
|
|
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
|
|
Mount {
|
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
|
mountpoint: Option<PathBuf>,
|
|
},
|
|
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
|
|
/// until stopped. This is what the systemd user service runs; you normally
|
|
/// don't invoke it by hand.
|
|
Daemon {
|
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
|
mountpoint: Option<PathBuf>,
|
|
},
|
|
/// Keep a file on this device (download + cache it). Needs a running mount.
|
|
Pin {
|
|
/// File path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
},
|
|
/// Stop keeping a file on this device, evicting its cached content.
|
|
Unpin {
|
|
/// File path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
},
|
|
/// List files pinned to this device.
|
|
Pins,
|
|
/// List a directory via the running daemon (in-app browser backend).
|
|
Ls {
|
|
/// Directory path, inside the mountpoint or relative to it. Root if omitted.
|
|
path: Option<PathBuf>,
|
|
},
|
|
/// List the photos timeline (newest first) via the running daemon.
|
|
Photos {
|
|
/// Max photos to list.
|
|
#[arg(long, default_value_t = 50)]
|
|
limit: usize,
|
|
/// Skip this many photos from the start of the timeline.
|
|
#[arg(long, default_value_t = 0)]
|
|
offset: usize,
|
|
/// List only favourited photos.
|
|
#[arg(long)]
|
|
favorites: bool,
|
|
},
|
|
/// Add or remove a photo's favourite mark.
|
|
Favorite {
|
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
|
uid: String,
|
|
/// Remove the mark instead of adding it.
|
|
#[arg(long)]
|
|
remove: bool,
|
|
},
|
|
/// List the photo albums (including ones shared with this account).
|
|
Albums,
|
|
/// Import a Google Photos Takeout export into Proton Photos.
|
|
///
|
|
/// Pass every zip of the export at once — Google splits one export across
|
|
/// numbered parts, and a photo's metadata often sits in a different part
|
|
/// than the photo. Albums in the export are recreated as Proton albums, and
|
|
/// photos already on the account (same name and same content) are skipped,
|
|
/// so an interrupted import can simply be run again.
|
|
ImportGooglePhotos {
|
|
/// The export's `.zip` files (e.g. `takeout-*.zip`).
|
|
#[arg(required = true)]
|
|
archives: Vec<PathBuf>,
|
|
/// Report what would be imported without uploading anything.
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
/// Stay attached and print progress until the import finishes.
|
|
#[arg(long)]
|
|
wait: bool,
|
|
},
|
|
/// Fix photos filed under the day they were imported instead of the day
|
|
/// they were taken.
|
|
///
|
|
/// A Takeout whose metadata sidecars did not match imports with no capture
|
|
/// time, and Proton then stamps every photo with the moment it arrived. The
|
|
/// date is still in the file name (`IMG-20230219-WA0001.jpg`), so this finds
|
|
/// photos whose name disagrees with their capture time by more than a day
|
|
/// and rewrites them at the right time.
|
|
///
|
|
/// Proton cannot edit a sealed capture time, so a rewrite means re-uploading
|
|
/// the photo and trashing the original — favourites and albums are carried
|
|
/// across, and the original goes to Proton's trash, not away. Run it with
|
|
/// `--dry-run` first.
|
|
RedatePhotos {
|
|
/// Report what would be re-dated without uploading or trashing anything.
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
/// Stop after this many photos.
|
|
#[arg(long)]
|
|
limit: Option<usize>,
|
|
/// Only touch photos whose current capture time is on or after this
|
|
/// date (`YYYY-MM-DD`, UTC) — aim the repair at the days an import
|
|
/// landed on.
|
|
#[arg(long)]
|
|
from: Option<String>,
|
|
/// Only touch photos whose current capture time is before this date
|
|
/// (`YYYY-MM-DD`, UTC).
|
|
#[arg(long)]
|
|
to: Option<String>,
|
|
/// Stay attached and print the report when the run finishes.
|
|
#[arg(long)]
|
|
wait: bool,
|
|
},
|
|
/// Show how the running (or last) photo re-date is doing.
|
|
RedateStatus,
|
|
/// Stop the running photo re-date.
|
|
CancelRedate,
|
|
/// Show how the running (or last) Google Photos import is doing.
|
|
ImportStatus,
|
|
/// Stop the running Google Photos import.
|
|
CancelImport,
|
|
/// List the photos in one album (newest capture first).
|
|
Album {
|
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
|
uid: String,
|
|
/// Max photos to list.
|
|
#[arg(long, default_value_t = 50)]
|
|
limit: usize,
|
|
/// Skip this many photos from the start of the album.
|
|
#[arg(long, default_value_t = 0)]
|
|
offset: usize,
|
|
},
|
|
/// Download a photo by uid (`volume~link`) and print its cached path.
|
|
OpenPhoto {
|
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
|
uid: String,
|
|
},
|
|
/// Search file/folder names via the running daemon's local index.
|
|
Search {
|
|
/// Query string (substring match).
|
|
query: String,
|
|
/// Max hits to return.
|
|
#[arg(long, default_value_t = 50)]
|
|
limit: usize,
|
|
},
|
|
/// Rename a file or folder via the running daemon.
|
|
Rename {
|
|
/// File/folder path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
/// New name (a single path component).
|
|
new_name: String,
|
|
},
|
|
/// Move a file or folder into another folder via the running daemon.
|
|
Move {
|
|
/// File/folder path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
/// Destination folder path, inside the mountpoint or relative to it.
|
|
new_parent: PathBuf,
|
|
},
|
|
/// Trash a file or folder via the running daemon.
|
|
Rm {
|
|
/// File/folder path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
},
|
|
/// Create a folder via the running daemon.
|
|
Mkdir {
|
|
/// Parent folder path, inside the mountpoint or relative to it.
|
|
parent: PathBuf,
|
|
/// New folder name (a single path component).
|
|
name: String,
|
|
},
|
|
/// Upload local files and/or folders into a Drive folder via the running
|
|
/// daemon. Folders are uploaded recursively. The daemon uploads in the
|
|
/// background; watch progress with `pdfs transfers`.
|
|
Upload {
|
|
/// Local files and/or folders to upload.
|
|
#[arg(required = true)]
|
|
sources: Vec<PathBuf>,
|
|
/// Destination folder, inside the mountpoint or relative to it.
|
|
#[arg(short = 't', long = "to", default_value = ".")]
|
|
parent: PathBuf,
|
|
},
|
|
/// Show the daemon's in-flight transfers (active uploads/downloads).
|
|
Transfers,
|
|
/// Show what the daemon is doing right now: worker threads, queue depths,
|
|
/// in-flight control requests and memory. For a daemon that has stopped
|
|
/// answering rather than one that is merely busy.
|
|
Diagnostics,
|
|
/// 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,
|
|
|
|
/// Drop a cached listing so the next read re-fetches it from the server.
|
|
/// Use it when another client changed something and the daemon hasn't
|
|
/// noticed yet.
|
|
Refresh {
|
|
/// What to refresh: a folder path (inside the mountpoint or relative to
|
|
/// it), `trash`, or `photos`. The mount root if omitted.
|
|
target: Option<String>,
|
|
/// With `photos`: read every photo's metadata again instead of only what
|
|
/// changed. Minutes on a large library; for a gallery that disagrees with
|
|
/// the account.
|
|
#[arg(long)]
|
|
full: bool,
|
|
},
|
|
|
|
/// 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,
|
|
},
|
|
/// List `(sync-conflict …)` copies, or resolve one.
|
|
Conflicts {
|
|
#[command(subcommand)]
|
|
action: Option<ConflictCmd>,
|
|
},
|
|
/// 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,
|
|
},
|
|
/// Inspect and restore a file's earlier versions.
|
|
Versions {
|
|
#[command(subcommand)]
|
|
action: VersionCmd,
|
|
},
|
|
/// 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, or the children of one.
|
|
SharedWithMe {
|
|
/// Folder uid to list into, in `volume~link` form (from a previous run).
|
|
/// Omit for the top level.
|
|
uid: Option<String>,
|
|
},
|
|
/// Download a file shared with me by its uid (from `pdfs shared-with-me`).
|
|
SharedGet {
|
|
/// Node uid in `volume~link` form.
|
|
uid: String,
|
|
/// Where to write it. Omit to leave it in the cache and print the path.
|
|
dest: Option<PathBuf>,
|
|
},
|
|
/// 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,
|
|
},
|
|
/// Show account storage usage (used of total, across all Proton products).
|
|
Quota,
|
|
/// Inspect and maintain the local metadata database and content cache.
|
|
Cache {
|
|
#[command(subcommand)]
|
|
action: CacheCmd,
|
|
},
|
|
/// Check this installation for problems and print a report.
|
|
///
|
|
/// Runs without a daemon on purpose: the state worth diagnosing is usually
|
|
/// the state where the daemon will not start.
|
|
Diagnose,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum CacheCmd {
|
|
/// Report database size, row counts, and cache usage.
|
|
Inspect {
|
|
/// Also run SQLite's integrity check. Reads every page, so it is slow
|
|
/// on a large database — worth it when you suspect corruption.
|
|
#[arg(long)]
|
|
deep: bool,
|
|
},
|
|
/// Checkpoint the write-ahead log and compact the database.
|
|
///
|
|
/// Takes a write lock for the duration and needs room for a second copy of
|
|
/// the database while it runs.
|
|
Vacuum,
|
|
/// Delete cached file content, keeping pinned files.
|
|
Clear,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `pdfs versions …` — a file's revision history.
|
|
#[derive(Subcommand)]
|
|
enum VersionCmd {
|
|
/// List a file's versions, newest first.
|
|
List {
|
|
/// File path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
},
|
|
/// Make an earlier version the file's current content.
|
|
///
|
|
/// The server applies this in the background, so the new content may take a
|
|
/// moment to show up in the mount.
|
|
Restore {
|
|
/// File path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
/// Version id, from `pdfs versions list`.
|
|
id: String,
|
|
},
|
|
/// Write an earlier version to a local file, leaving the file untouched.
|
|
Save {
|
|
/// File path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
/// Version id, from `pdfs versions list`.
|
|
id: String,
|
|
/// Where to write it. Must not already exist.
|
|
dest: PathBuf,
|
|
},
|
|
/// Permanently delete an earlier version. The content is unrecoverable, and
|
|
/// the version that is currently active cannot be deleted.
|
|
Rm {
|
|
/// File path, inside the mountpoint or relative to it.
|
|
path: PathBuf,
|
|
/// Version id, from `pdfs versions list`.
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[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 },
|
|
/// Adopt an existing device as this machine's, so a hostname change or a
|
|
/// reinstall re-attaches to it instead of registering a duplicate.
|
|
Adopt {
|
|
/// Device uid (from `devices list`). Omit with `--clear`.
|
|
uid: Option<String>,
|
|
/// Drop the adoption and go back to matching by hostname.
|
|
#[arg(long, conflicts_with = "uid")]
|
|
clear: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ConflictCmd {
|
|
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
|
Resolve {
|
|
/// The conflict copy, absolute or relative to the mountpoint.
|
|
path: PathBuf,
|
|
/// `original` trashes the copy; `copy` trashes the original and gives the
|
|
/// copy its name; `both` renames the copy to `--name`.
|
|
#[arg(long, value_parser = ["original", "copy", "both"])]
|
|
keep: String,
|
|
/// New name for the copy with `--keep both`.
|
|
#[arg(long, required_if_eq("keep", "both"))]
|
|
name: Option<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>,
|
|
},
|
|
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
|
Pause {
|
|
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
|
|
/// Omit to pause until `pdfs sync resume`.
|
|
#[arg(long = "for", value_parser = parse_pause_duration)]
|
|
duration: Option<u64>,
|
|
},
|
|
/// Resume syncing after `pdfs sync pause`.
|
|
Resume,
|
|
/// List uploads and changes not yet on Proton Drive.
|
|
Queue,
|
|
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
|
Retry {
|
|
/// Queue id; omit to retry every failed operation.
|
|
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,
|
|
},
|
|
/// Re-attach this machine's device folders to local directories, then sync
|
|
/// them down. Use after adopting a device on a new machine.
|
|
Restore {
|
|
/// Accept every proposed local path without asking.
|
|
#[arg(long)]
|
|
yes: bool,
|
|
},
|
|
}
|
|
|
|
#[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 },
|
|
}
|
|
|
|
/// Render a daemon failure for a terminal.
|
|
///
|
|
/// Offline is the one class worth rewording: the daemon's prose names whichever
|
|
/// call happened to hit the network, which tells someone at a shell nothing they
|
|
/// can act on. Every other class already says something specific, so it is
|
|
/// printed as sent.
|
|
fn cli_error(kind: ErrorKind, message: &str) -> String {
|
|
match kind {
|
|
ErrorKind::Offline => "offline: the Proton Drive API is unreachable".to_string(),
|
|
// The daemon's wording names the failed call ("upload x: ..."), which
|
|
// does not say the thing the user has to act on.
|
|
ErrorKind::Quota => format!("out of storage: {message}"),
|
|
_ => message.to_string(),
|
|
}
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
// `pgp` logs a benign WARN when it re-serializes a Proton secret-key
|
|
// packet with a longer (but equivalent) length encoding than the
|
|
// original; drop it to error so normal runs stay quiet.
|
|
.unwrap_or_else(|_| "info,pgp=error".into()),
|
|
)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
|
match cli.command {
|
|
Command::Login { username } => cmd_login(username),
|
|
Command::Logout => cmd_logout(),
|
|
Command::Status => cmd_status(),
|
|
Command::Locations => cmd_locations(),
|
|
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
|
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
|
|
Command::Pin { path } => cmd_pin(path),
|
|
Command::Unpin { path } => cmd_unpin(path),
|
|
Command::Pins => cmd_pins(),
|
|
Command::Ls { path } => cmd_ls(path),
|
|
Command::Photos {
|
|
limit,
|
|
offset,
|
|
favorites,
|
|
} => cmd_photos(limit, offset, favorites),
|
|
Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
|
|
Command::Albums => cmd_albums(),
|
|
Command::ImportGooglePhotos {
|
|
archives,
|
|
dry_run,
|
|
wait,
|
|
} => cmd_import_google_photos(archives, dry_run, wait),
|
|
Command::RedatePhotos {
|
|
dry_run,
|
|
limit,
|
|
from,
|
|
to,
|
|
wait,
|
|
} => cmd_redate_photos(dry_run, limit, from, to, wait),
|
|
Command::RedateStatus => cmd_redate_status(),
|
|
Command::CancelRedate => cmd_cancel_redate(),
|
|
Command::ImportStatus => cmd_import_status(),
|
|
Command::CancelImport => cmd_cancel_import(),
|
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
|
Command::Search { query, limit } => cmd_search(query, limit),
|
|
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
|
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
|
Command::Rm { path } => cmd_rm(path),
|
|
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
|
Command::Upload { sources, parent } => cmd_upload(sources, parent),
|
|
Command::Transfers => cmd_transfers(),
|
|
Command::Diagnostics => cmd_diagnostics(),
|
|
Command::Trash => cmd_trash(),
|
|
Command::Restore { uids } => cmd_restore(uids),
|
|
Command::DeleteForever { uids } => cmd_delete_forever(uids),
|
|
Command::EmptyTrash => cmd_empty_trash(),
|
|
Command::Refresh { target, full } => cmd_refresh(target, full),
|
|
Command::Devices { action } => cmd_devices(action),
|
|
Command::Sync { action } => cmd_sync(action),
|
|
Command::Conflicts { action } => cmd_conflicts(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::Versions { action } => cmd_versions(action),
|
|
Command::PublicLink { action } => cmd_public_link(action),
|
|
Command::Shared => cmd_shared(),
|
|
Command::SharedWithMe { uid } => cmd_shared_with_me(uid),
|
|
Command::SharedGet { uid, dest } => cmd_shared_get(uid, dest),
|
|
Command::Leave { uid } => cmd_leave(uid),
|
|
Command::Invitations { action } => cmd_invitations(action),
|
|
Command::Bookmarks { action } => cmd_bookmarks(action),
|
|
Command::Activity { limit } => cmd_activity(limit),
|
|
Command::Quota => cmd_quota(),
|
|
Command::Cache { action } => cmd_cache(action),
|
|
Command::Diagnose => cmd_diagnose(),
|
|
}
|
|
}
|
|
|
|
fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
|
match action {
|
|
DeviceCmd::List => {
|
|
let response = control_request(CtlRequest::ListDevices)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
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());
|
|
let tag = match (d.adopted, d.this_device) {
|
|
(true, _) => " *adopted*",
|
|
(false, true) => " *this machine*",
|
|
_ => "",
|
|
};
|
|
println!(
|
|
"{} {} (synced: {sync}) [{}]{tag}",
|
|
d.device_type, d.name, d.uid
|
|
);
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &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 })?)?,
|
|
DeviceCmd::Adopt { uid, clear } => {
|
|
if uid.is_none() && !clear {
|
|
bail!("give a device uid (see `pdfs devices list`), or --clear");
|
|
}
|
|
let uid = if clear { None } else { uid };
|
|
ok_or_bail(control_request(CtlRequest::AdoptDevice { 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 => {
|
|
let response = control_request(CtlRequest::ListSyncFolders)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
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()
|
|
};
|
|
// A queued switch is reported as the mode the folder is moving
|
|
// to, so `sync list` never reads as if the request was dropped.
|
|
let mode = match &f.pending_mode {
|
|
Some(pending) => format!("{} → {pending}", f.mode),
|
|
None => f.mode.clone(),
|
|
};
|
|
println!(
|
|
"[{}] {} ({mode}, {}, synced: {sync})",
|
|
f.id, f.local_path, f.state
|
|
);
|
|
// A pass in flight says what it is doing, indented under it.
|
|
if let Some(p) = &f.progress {
|
|
match p.phase {
|
|
SyncPhase::Scanning if p.total == 0 => println!(" scanning…"),
|
|
SyncPhase::Scanning => {
|
|
println!(
|
|
" scanning: {} of {}",
|
|
p.done,
|
|
p.total.max(p.done)
|
|
)
|
|
}
|
|
SyncPhase::Applying => println!(
|
|
" {} of {} {}",
|
|
p.done + 1,
|
|
p.total.max(p.done + 1),
|
|
p.current
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &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::Pause { duration } => {
|
|
let until = duration.map(|secs| unix_now().saturating_add(secs as i64));
|
|
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
|
paused: true,
|
|
until,
|
|
})?)?
|
|
}
|
|
SyncCmd::Queue => return cmd_sync_queue(),
|
|
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
|
SyncCmd::Resume => ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
|
paused: false,
|
|
until: None,
|
|
})?)?,
|
|
SyncCmd::Mode { id, mode } => {
|
|
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
|
|
}
|
|
SyncCmd::Restore { yes } => return cmd_sync_restore(yes),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `pdfs conflicts`: list conflict copies, or resolve one.
|
|
fn cmd_conflicts(action: Option<ConflictCmd>) -> Result<()> {
|
|
if let Some(ConflictCmd::Resolve { path, keep, name }) = action {
|
|
let keep = match keep.as_str() {
|
|
"original" => ConflictKeep::Original,
|
|
"copy" => ConflictKeep::Copy,
|
|
_ => ConflictKeep::Both {
|
|
name: name.unwrap_or_default(),
|
|
},
|
|
};
|
|
let path = path_arg(&path)?;
|
|
return ok_or_bail(control_request(CtlRequest::ResolveConflict { path, keep })?);
|
|
}
|
|
let response = control_request(CtlRequest::ListConflicts)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::Conflicts { items } if items.is_empty() => println!("No conflicts."),
|
|
CtlResponse::Conflicts { items } => {
|
|
for c in items {
|
|
println!("{}", c.path);
|
|
let verdict = match (c.original_exists, c.identical) {
|
|
(false, _) => "original is gone".to_string(),
|
|
(true, true) => format!("identical to {}", c.original_path),
|
|
(true, false) => format!(
|
|
"differs from {} ({} vs {})",
|
|
c.original_path,
|
|
human_bytes(c.size),
|
|
human_bytes(c.original_size.unwrap_or_default()),
|
|
),
|
|
};
|
|
println!(" {verdict}");
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `pdfs sync queue`: what has not reached Proton Drive yet, and why.
|
|
fn cmd_sync_queue() -> Result<()> {
|
|
let response = control_request(CtlRequest::ListPendingOps)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::PendingOps { items } if items.is_empty() => {
|
|
println!("Nothing queued: everything is on Proton Drive.");
|
|
}
|
|
CtlResponse::PendingOps { items } => {
|
|
let now = unix_now();
|
|
for op in items {
|
|
let state = match (op.parked, op.next_attempt_at) {
|
|
(true, _) => "waiting for its final name".to_string(),
|
|
(false, Some(at)) if at > now => {
|
|
format!("next try in {}", human_age((at - now) as u64))
|
|
}
|
|
(false, _) => "due now".to_string(),
|
|
};
|
|
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
|
if let Some(error) = &op.last_error {
|
|
println!(" failed {} time(s): {error}", op.attempts);
|
|
}
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `pdfs sync restore`: list what this machine's device holds, confirm where
|
|
/// each folder should land, then hand the mapping to the daemon.
|
|
///
|
|
/// The daemon only ever *proposes* local paths — a path recorded by another
|
|
/// machine may name a home directory that does not exist here — so the default
|
|
/// flow is propose-and-confirm. `--yes` accepts the proposals for scripting.
|
|
fn cmd_sync_restore(yes: bool) -> Result<()> {
|
|
let response = control_request(CtlRequest::ListRestorableFolders)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
let items = match response {
|
|
CtlResponse::RestorableFolders { items } => items,
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
};
|
|
let candidates: Vec<_> = items.into_iter().filter(|f| !f.already_synced).collect();
|
|
if candidates.is_empty() {
|
|
println!("Nothing to restore: this device has no folders that aren't already synced here.");
|
|
return Ok(());
|
|
}
|
|
|
|
let mut chosen: Vec<CtlRestoreItem> = Vec::new();
|
|
for f in candidates {
|
|
if yes {
|
|
println!("{} -> {} [{}]", f.name, f.local_path, f.mode);
|
|
chosen.push(CtlRestoreItem {
|
|
remote_uid: f.remote_uid,
|
|
local_path: f.local_path,
|
|
mode: f.mode,
|
|
});
|
|
continue;
|
|
}
|
|
// Empty answer takes the proposal, `n` skips, anything else is read as
|
|
// the path to use instead — the three things a user wants here.
|
|
let answer = prompt_line(&format!(
|
|
"{} [{}] -> {} (Enter to accept, 'n' to skip, or a path)",
|
|
f.name, f.mode, f.local_path
|
|
))?;
|
|
match answer.as_str() {
|
|
"n" | "N" => continue,
|
|
"" => chosen.push(CtlRestoreItem {
|
|
remote_uid: f.remote_uid,
|
|
local_path: f.local_path,
|
|
mode: f.mode,
|
|
}),
|
|
path => chosen.push(CtlRestoreItem {
|
|
remote_uid: f.remote_uid,
|
|
local_path: expand_tilde(path),
|
|
mode: f.mode,
|
|
}),
|
|
}
|
|
}
|
|
if chosen.is_empty() {
|
|
println!("Nothing selected.");
|
|
return Ok(());
|
|
}
|
|
ok_or_bail(control_request(CtlRequest::RestoreSyncFolders {
|
|
items: chosen,
|
|
})?)
|
|
}
|
|
|
|
/// Expand a leading `~` in a hand-typed path. The daemon requires an absolute
|
|
/// path, and a shell is not always the one doing the typing here.
|
|
fn expand_tilde(path: &str) -> String {
|
|
match path.strip_prefix("~/") {
|
|
Some(rest) => match std::env::var_os("HOME") {
|
|
Some(home) => PathBuf::from(home)
|
|
.join(rest)
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
None => path.to_string(),
|
|
},
|
|
None => path.to_string(),
|
|
}
|
|
}
|
|
|
|
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_versions(action: VersionCmd) -> Result<()> {
|
|
match action {
|
|
VersionCmd::List { path } => {
|
|
let response = control_request(CtlRequest::ListRevisions {
|
|
path: path_arg(&path)?,
|
|
})?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::Revisions { items } if items.is_empty() => {
|
|
println!("No versions.");
|
|
}
|
|
CtlResponse::Revisions { items } => {
|
|
for r in items {
|
|
// The claimed plaintext size is what the user recognises;
|
|
// the storage size is ciphertext and always larger.
|
|
let size = r
|
|
.claimed_size
|
|
.filter(|size| *size >= 0)
|
|
.unwrap_or(r.size_on_storage)
|
|
.max(0) as u64;
|
|
let mark = if r.is_active { "*" } else { " " };
|
|
let by = r
|
|
.signed_by
|
|
.map(|email| format!(" {email}"))
|
|
.unwrap_or_default();
|
|
println!(
|
|
"{mark} {:<20} {:>10} {}{by}",
|
|
r.id,
|
|
human_bytes(size),
|
|
format_epoch(r.created),
|
|
);
|
|
}
|
|
println!("(* = current)");
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
VersionCmd::Restore { path, id } => {
|
|
ok_or_bail(control_request(CtlRequest::RestoreRevision {
|
|
path: path_arg(&path)?,
|
|
revision_id: id,
|
|
})?)
|
|
}
|
|
VersionCmd::Save { path, id, dest } => {
|
|
// The daemon writes the file, so it needs a path that means the same
|
|
// thing in its process as in this one.
|
|
let dest = std::path::absolute(&dest)
|
|
.with_context(|| format!("resolve {}", dest.display()))?;
|
|
ok_or_bail(control_request(CtlRequest::SaveRevisionAs {
|
|
path: path_arg(&path)?,
|
|
revision_id: id,
|
|
dest: dest.to_string_lossy().into_owned(),
|
|
})?)
|
|
}
|
|
VersionCmd::Rm { path, id } => ok_or_bail(control_request(CtlRequest::DeleteRevision {
|
|
path: path_arg(&path)?,
|
|
revision_id: id,
|
|
})?),
|
|
}
|
|
}
|
|
|
|
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, kind } => bail!("{}", cli_error(kind, &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, kind } => bail!("{}", cli_error(kind, &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, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// List what is shared with me, or — given a folder uid — that folder's
|
|
/// children. Shared items have no path in the mount, so a uid is the only handle
|
|
/// there is: every row prints one, and it is what the next level down (or
|
|
/// `shared-get`) is addressed with.
|
|
fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
|
let request = match uid {
|
|
Some(uid) => CtlRequest::ListSharedFolder { uid },
|
|
None => CtlRequest::ListSharedWithMe,
|
|
};
|
|
let response = control_request(request)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
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 { "-" };
|
|
// My role decides whether this is writable at all, so it is
|
|
// printed whenever the API reported one; an empty role prints
|
|
// nothing rather than a guess.
|
|
let role = if e.role.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" ({})", e.role)
|
|
};
|
|
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
|
|
if let Some(line) = shared_by_line(&e) {
|
|
println!("{:>15}{line}", "");
|
|
}
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Who shared an entry with me, and when, as a line printed under it — `None`
|
|
/// when the invitation said nothing (owned content, or a node inside a shared
|
|
/// folder rather than its root). An inviter whose signature did not verify is
|
|
/// said to be claimed, not stated: the name may be forged.
|
|
fn shared_by_line(entry: &pdfs_core::control::DirEntry) -> Option<String> {
|
|
if entry.shared_by.is_empty() {
|
|
return None;
|
|
}
|
|
let mut line = format!("shared by {}", entry.shared_by);
|
|
if entry.shared_at > 0 {
|
|
line.push_str(&format!(" at {}", format_epoch_utc(entry.shared_at)));
|
|
}
|
|
if entry.shared_by_unverified {
|
|
line.push_str(" WARNING: invitation signature did not verify; the sender may be forged");
|
|
}
|
|
Some(line)
|
|
}
|
|
|
|
/// `YYYY-MM-DD` (UTC) for epoch seconds, without pulling a date crate into the
|
|
/// CLI for one column. Civil-from-days per Howard Hinnant's algorithm.
|
|
fn format_epoch_utc(secs: i64) -> String {
|
|
let z = secs.div_euclid(86_400) + 719_468;
|
|
let era = z.div_euclid(146_097);
|
|
let doe = z - era * 146_097;
|
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let day = doy - (153 * mp + 2) / 5 + 1;
|
|
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
let year = yoe + era * 400 + i64::from(month <= 2);
|
|
format!("{year:04}-{month:02}-{day:02}")
|
|
}
|
|
|
|
/// Download a file shared with me. The daemon puts the plaintext in its content
|
|
/// cache and replies with that path; with a `dest` we copy it out, so the caller
|
|
/// gets a file it owns rather than one the cache may later evict.
|
|
fn cmd_shared_get(uid: String, dest: Option<PathBuf>) -> Result<()> {
|
|
let path = match control_request(CtlRequest::OpenSharedFile { uid })? {
|
|
CtlResponse::FilePath { path } => PathBuf::from(path),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
};
|
|
match dest {
|
|
Some(dest) => {
|
|
std::fs::copy(&path, &dest).with_context(|| format!("writing {}", dest.display()))?;
|
|
println!("{}", dest.display());
|
|
}
|
|
None => println!("{}", path.display()),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_quota() -> Result<()> {
|
|
let response = control_request(CtlRequest::AccountQuota)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::AccountQuota {
|
|
max_space,
|
|
used_space,
|
|
} => {
|
|
let used = used_space.max(0) as u64;
|
|
if max_space > 0 {
|
|
let total = max_space as u64;
|
|
let pct = (used as f64 / total as f64 * 100.0).round() as u64;
|
|
println!(
|
|
"{} of {} used ({pct}%)",
|
|
human_bytes(used),
|
|
human_bytes(total)
|
|
);
|
|
} else {
|
|
println!("{} used", human_bytes(used));
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_activity(limit: usize) -> Result<()> {
|
|
let response = control_request(CtlRequest::ListActivity { limit })?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
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, kind } => bail!("{}", cli_error(kind, &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",
|
|
Conflict => "conflict",
|
|
}
|
|
}
|
|
|
|
/// 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, kind } => bail!("{}", cli_error(kind, &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, kind } => bail!("{}", cli_error(kind, &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, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Read a line from stdin with a prompt (echoed).
|
|
fn prompt_line(label: &str) -> Result<String> {
|
|
print!("{label}: ");
|
|
std::io::stdout().flush()?;
|
|
let mut line = String::new();
|
|
std::io::stdin().read_line(&mut line)?;
|
|
Ok(line.trim().to_owned())
|
|
}
|
|
|
|
fn cmd_login(username: Option<String>) -> Result<()> {
|
|
let username = match username {
|
|
Some(u) => u,
|
|
None => prompt_line("Proton email")?,
|
|
};
|
|
let password = rpassword::prompt_password("Password: ").context("read password")?;
|
|
|
|
// The 2FA prompt fires lazily — only if the account actually requires it.
|
|
let get_totp = || -> pdfs_core::Result<String> {
|
|
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
|
};
|
|
|
|
let rt = tokio::runtime::Runtime::new()?;
|
|
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
|
// carrying. Point at the app that does rather than failing with the raw API
|
|
// message, which reads as "your login is broken".
|
|
rt.block_on(auth::login(&username, &password, get_totp))
|
|
.map_err(|e| match e {
|
|
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
|
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
|
Run `pdfs-app` and sign in there — it can show the verification page.\n\
|
|
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
|
|
your usual network often avoids it.)"
|
|
),
|
|
other => anyhow::Error::new(other).context("login failed"),
|
|
})?;
|
|
|
|
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
|
// between keyring checks; restart it so the mount appears now instead of
|
|
// whenever the next poll fires. Only when it is already active — `pdfs
|
|
// login` deliberately does not enable the unit the way the GUI does, so a
|
|
// headless user driving `pdfs daemon` by hand keeps that setup.
|
|
if service::is_active() {
|
|
service::restart();
|
|
}
|
|
|
|
println!("Logged in as {username}. Session stored in the system keyring.");
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_logout() -> Result<()> {
|
|
auth::logout()?;
|
|
println!("Stored session cleared.");
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_status() -> Result<()> {
|
|
// `status` is the one query that merges two sources — the local keyring and
|
|
// the daemon — so its JSON is composed here rather than being a daemon reply
|
|
// passed through. A script asking for status wants both halves in one
|
|
// object, including the "logged in but no daemon" case that has no reply at
|
|
// all.
|
|
if json_enabled() {
|
|
let session = match auth::load() {
|
|
Ok(s) => Some(s),
|
|
Err(pdfs_core::Error::NotLoggedIn) => None,
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
let mount = match control_request(CtlRequest::Status) {
|
|
Ok(r @ CtlResponse::Status { .. }) => response_payload(&r)?,
|
|
_ => serde_json::Value::Null,
|
|
};
|
|
let out = serde_json::json!({
|
|
"logged_in": session.is_some(),
|
|
"username": session.as_ref().map(|s| s.username.clone()),
|
|
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
|
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
|
"mount": mount,
|
|
});
|
|
println!("{}", serde_json::to_string_pretty(&out)?);
|
|
return Ok(());
|
|
}
|
|
|
|
match auth::load() {
|
|
Ok(s) => {
|
|
println!("Logged in as {} (user {})", s.username, s.user_id);
|
|
println!("Scopes: {}", s.scopes.join(", "));
|
|
}
|
|
Err(pdfs_core::Error::NotLoggedIn) => {
|
|
println!("Not logged in. Run `pdfs login`.");
|
|
return Ok(());
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
// If a mount daemon is running, report live status from its control socket.
|
|
match control_request(CtlRequest::Status) {
|
|
Ok(CtlResponse::Status {
|
|
mountpoint,
|
|
pinned,
|
|
online,
|
|
pending_uploads,
|
|
pending_changes,
|
|
parked_uploads,
|
|
failing_ops,
|
|
failing_error,
|
|
staged_bytes,
|
|
staged_oldest_secs,
|
|
paused,
|
|
paused_until,
|
|
..
|
|
}) => {
|
|
let state = match (online, paused) {
|
|
(_, true) => ", sync paused",
|
|
(false, false) => ", offline",
|
|
(true, false) => "",
|
|
};
|
|
let queued = match pending_summary(pending_uploads, pending_changes) {
|
|
Some(s) => format!(", {s}"),
|
|
None => String::new(),
|
|
};
|
|
println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})");
|
|
// Everything below is a queue that is not making progress on its
|
|
// own. Printed only when it exists, because in the ordinary case
|
|
// there is nothing to say.
|
|
if staged_bytes > 0 {
|
|
println!(
|
|
"Staged {} not yet uploaded (oldest {})",
|
|
human_bytes(staged_bytes),
|
|
human_age(staged_oldest_secs),
|
|
);
|
|
}
|
|
if let Some(until) = paused_until {
|
|
let left = until.saturating_sub(unix_now()).max(0) as u64;
|
|
println!("Paused sync resumes in {}", human_age(left.max(1)));
|
|
}
|
|
if parked_uploads > 0 {
|
|
println!("Parked {parked_uploads} upload(s) waiting for a final filename");
|
|
}
|
|
if failing_ops > 0 {
|
|
println!(
|
|
"Stuck {failing_ops} queued operation(s) keep failing: {}",
|
|
failing_error.as_deref().unwrap_or("no error recorded"),
|
|
);
|
|
}
|
|
}
|
|
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
|
Err(_) => println!("Mount: not running."),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_locations() -> Result<()> {
|
|
let response = control_request(CtlRequest::ListLocations)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::Locations { items } if items.is_empty() => {
|
|
println!("No Proton Drive locations.");
|
|
}
|
|
CtlResponse::Locations { items } => {
|
|
for location in items {
|
|
let kind = match &location.kind {
|
|
MountKind::MyFiles => "My Files".to_string(),
|
|
MountKind::Device { sync_folder_id } => {
|
|
format!("Device folder {sync_folder_id}")
|
|
}
|
|
MountKind::Shared { .. } => "Shared folder".to_string(),
|
|
};
|
|
let mode = match location.pending_mode {
|
|
Some(pending) => format!("{} -> {pending}", location.mode),
|
|
None => location.mode.to_string(),
|
|
};
|
|
let mounted = if location.mounted {
|
|
"mounted"
|
|
} else {
|
|
"not mounted"
|
|
};
|
|
println!(
|
|
"{kind}: {} ({mode}, {}, {}, {mounted})",
|
|
location.local_path, location.access, location.state
|
|
);
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
|
mount_once(mountpoint)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// How long to wait before the next login check, after `attempts` checks have
|
|
/// already come back [`pdfs_core::Error::NotLoggedIn`].
|
|
///
|
|
/// Doubles from 3s to a 15s ceiling. The interval is not just a sleep:
|
|
/// [`auth::load`] builds a fresh `keyring::Entry` on every call, so each check
|
|
/// opens a new D-Bus connection to the Secret Service and negotiates an
|
|
/// encrypted session over it. A flat 3s poll meant 28,800 of those a day from a
|
|
/// daemon that had nothing to do; the ceiling cuts that by 80%.
|
|
///
|
|
/// The ceiling stays low because the sleep is also the worst-case login →
|
|
/// mount latency for anything that does *not* poke systemd: front-ends restart
|
|
/// the unit on login (`service::enable_start`), which interrupts the sleep
|
|
/// immediately, but a hand-run `pdfs daemon` has no such wake-up path.
|
|
fn login_poll_delay(attempts: u32) -> std::time::Duration {
|
|
const BASE_SECS: u64 = 3;
|
|
const MAX_SECS: u64 = 15;
|
|
|
|
// `checked_shl` rather than `<<`: the daemon can sit unauthenticated for
|
|
// weeks, and a shift past the width of the type is a panic, not a big number.
|
|
let factor = 1u64.checked_shl(attempts).unwrap_or(u64::MAX);
|
|
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
|
}
|
|
|
|
/// Block until a session is stored, backing off between checks.
|
|
///
|
|
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
|
/// schedule can be tested without a keyring and without real time passing.
|
|
fn wait_for_session<L, S>(mut load: L, mut sleep: S) -> Result<()>
|
|
where
|
|
L: FnMut() -> std::result::Result<(), pdfs_core::Error>,
|
|
S: FnMut(std::time::Duration),
|
|
{
|
|
let mut attempts: u32 = 0;
|
|
loop {
|
|
match load() {
|
|
Ok(()) => return Ok(()),
|
|
Err(pdfs_core::Error::NotLoggedIn) => {
|
|
let delay = login_poll_delay(attempts);
|
|
// Say it once at INFO. Repeating it every interval buried the
|
|
// journal in tens of thousands of identical lines a day.
|
|
if attempts == 0 {
|
|
tracing::info!("not logged in; waiting for a session…");
|
|
} else {
|
|
tracing::debug!(delay_secs = delay.as_secs(), "still not logged in");
|
|
}
|
|
sleep(delay);
|
|
attempts = attempts.saturating_add(1);
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
|
|
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
|
|
/// an external unmount triggers a remount; errors back off and retry. This is
|
|
/// the entry point for the systemd user service.
|
|
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|
loop {
|
|
// Wait until a session is stored. The GUI enables this service on login,
|
|
// but the service may also start at boot before the user has logged in.
|
|
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
|
|
|
|
match mount_once(mountpoint.clone()) {
|
|
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
|
tracing::info!("daemon stopping");
|
|
return Ok(());
|
|
}
|
|
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
|
tracing::warn!("mount ended externally; remounting in 2s");
|
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
}
|
|
Err(e) => {
|
|
// `{:#}` 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));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One mount run: resume the session, mount, and block until the mount ends
|
|
/// (clean shutdown or external unmount). Returns the [`MountOutcome`] so the
|
|
/// daemon loop can decide whether to exit or remount.
|
|
fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|
let dirs = AppDirs::new()?;
|
|
dirs.ensure()?;
|
|
let config = dirs.load_config();
|
|
// An explicit `--mountpoint` wins; otherwise honor the Settings-page choice
|
|
// (config), falling back to the default location.
|
|
let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config));
|
|
std::fs::create_dir_all(&mountpoint)
|
|
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
|
|
|
let username = auth::load().map(|s| s.username).unwrap_or_default();
|
|
// The daemon owns the unified DB (single writer). Open it here so the content
|
|
// cache and the mount share one handle: the cache uses its `cache_entries`
|
|
// table as the LRU index, the mount uses it for nodes/search/event cursor.
|
|
let db = Arc::new(Db::open(&dirs.db_path()).context("open cache db")?);
|
|
let cache = ContentCache::open(
|
|
dirs.content_cache_dir(),
|
|
dirs.pins_path(),
|
|
config.resolved_cache_budget(),
|
|
db.clone(),
|
|
)
|
|
.context("open content cache")?;
|
|
let control_socket = dirs.control_socket();
|
|
|
|
// Multi-threaded runtime: its worker threads keep servicing async SDK calls
|
|
// while the main thread is parked inside the blocking FUSE session loop.
|
|
let rt = tokio::runtime::Runtime::new()?;
|
|
let (client, session) = rt
|
|
.block_on(auth::resume_client())
|
|
.context("resume session (run `pdfs login` first)")?;
|
|
|
|
// Token updates are persisted automatically in real-time. The SDK session has
|
|
// a token refresh callback registered during `resume_client` that writes updated
|
|
// credentials back to the OS keyring immediately upon rotation. This prevents
|
|
// the system keyring from holding stale refresh tokens even if the daemon crashes,
|
|
// the system is suspended, or the network changes.
|
|
let handle = rt.handle().clone();
|
|
let result = pdfs_fuse::mount(
|
|
client,
|
|
handle,
|
|
&mountpoint,
|
|
cache,
|
|
&control_socket,
|
|
db,
|
|
pdfs_fuse::MountOptions {
|
|
username,
|
|
sweep_mode: config.resolved_conflict_sweep(),
|
|
},
|
|
);
|
|
|
|
// Clean unmount check (as a best-effort final backup).
|
|
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
|
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
|
}
|
|
|
|
let outcome = result.context("mount failed")?;
|
|
Ok(outcome)
|
|
}
|
|
|
|
fn cmd_pin(path: PathBuf) -> Result<()> {
|
|
match control_request(CtlRequest::Pin {
|
|
path: path_arg(&path)?,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_unpin(path: PathBuf) -> Result<()> {
|
|
match control_request(CtlRequest::Unpin {
|
|
path: path_arg(&path)?,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_transfers() -> Result<()> {
|
|
use pdfs_core::control::TransferDirection;
|
|
let response = control_request(CtlRequest::GetQueueStatus)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => {
|
|
println!("Nothing in progress.")
|
|
}
|
|
CtlResponse::Transfers { items, jobs } => {
|
|
// Jobs first: they are the context for the transfers under them ("of
|
|
// 40 files", "still scanning") rather than a separate list.
|
|
for j in jobs {
|
|
let count = if j.total > 0 {
|
|
format!("{} of {}", j.done, j.total)
|
|
} else {
|
|
"—".to_string()
|
|
};
|
|
let detail = if j.detail.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" — {}", j.detail)
|
|
};
|
|
println!("… {:>9} {}{detail}", count, j.title);
|
|
}
|
|
for t in items {
|
|
let arrow = match t.direction {
|
|
TransferDirection::Download => "↓",
|
|
TransferDirection::Upload => "↑",
|
|
};
|
|
let pct = if t.bytes_total > 0 {
|
|
format!(
|
|
"{:.0}%",
|
|
100.0 * t.bytes_completed as f64 / t.bytes_total as f64
|
|
)
|
|
} else {
|
|
"—".to_string()
|
|
};
|
|
let speed = t.speed_bytes_sec as f64 / 1_048_576.0;
|
|
println!(
|
|
"{arrow} {:>4} {:>8} / {:>8} {speed:>6.2} MiB/s {}",
|
|
pct, t.bytes_completed, t.bytes_total, t.name
|
|
);
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_pins() -> Result<()> {
|
|
let response = control_request(CtlRequest::ListPins)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."),
|
|
CtlResponse::Pins { pins } => {
|
|
for p in pins {
|
|
println!("{}", p.path);
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_ls(path: Option<PathBuf>) -> Result<()> {
|
|
let path = match path {
|
|
Some(p) => path_arg(&p)?,
|
|
None => String::new(),
|
|
};
|
|
let response = control_request(CtlRequest::ListDir { path })?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::Entries { entries } if entries.is_empty() => println!("(empty)"),
|
|
CtlResponse::Entries { entries } => {
|
|
for e in entries {
|
|
let kind = if e.is_dir { "d" } else { "-" };
|
|
let pin = if e.pinned { "*" } else { " " };
|
|
println!("{kind}{pin} {:>12} {} [{}]", e.size, e.name, e.uid);
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_photos(limit: usize, offset: usize, favorites: bool) -> Result<()> {
|
|
match control_request(CtlRequest::PhotosTimeline {
|
|
offset,
|
|
limit,
|
|
kind: None,
|
|
range: None,
|
|
favorites,
|
|
})? {
|
|
CtlResponse::Photos {
|
|
available: false, ..
|
|
} => {
|
|
println!("This account has no photos volume.")
|
|
}
|
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
|
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Print one page of photos as `capture-time uid thumbnail-path`.
|
|
///
|
|
/// A timeline or album reply only carries thumbnails already in the cache, so
|
|
/// the rest of the page's are pulled in one batch rather than left blank.
|
|
fn cmd_favorite(uid: String, remove: bool) -> Result<()> {
|
|
ok_or_bail(control_request(CtlRequest::SetPhotoFavorite {
|
|
uid,
|
|
favorite: !remove,
|
|
})?)
|
|
}
|
|
|
|
fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
|
|
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
|
let thumbs: HashMap<String, Option<String>> =
|
|
match control_request(CtlRequest::PhotoThumbs { uids })? {
|
|
CtlResponse::Thumbs { items } => items.into_iter().map(|t| (t.uid, t.path)).collect(),
|
|
_ => HashMap::new(),
|
|
};
|
|
for p in items {
|
|
let thumb = thumbs
|
|
.get(&p.uid)
|
|
.and_then(|p| p.as_deref())
|
|
.or(p.thumb_path.as_deref())
|
|
.unwrap_or("(no thumbnail)");
|
|
let fav = if p.favorite { "*" } else { " " };
|
|
println!("{fav} {} {} {thumb}", p.capture_time, p.uid);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_albums() -> Result<()> {
|
|
match control_request(CtlRequest::PhotoAlbums)? {
|
|
CtlResponse::Albums {
|
|
available: false, ..
|
|
} => println!("This account has no photos volume."),
|
|
CtlResponse::Albums { items, .. } if items.is_empty() => println!("No albums."),
|
|
CtlResponse::Albums { items, .. } => {
|
|
for album in items {
|
|
let shared = if album.shared {
|
|
" (shared with me)"
|
|
} else {
|
|
""
|
|
};
|
|
println!(
|
|
"{:>6} {} {}{shared}",
|
|
album.photo_count, album.uid, album.name
|
|
);
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
|
// The daemon opens the archives itself off the shared filesystem, so it gets
|
|
// absolute paths — the same arrangement `pdfs upload` uses.
|
|
let mut absolute = Vec::with_capacity(archives.len());
|
|
for archive in &archives {
|
|
let path = std::fs::canonicalize(archive)
|
|
.with_context(|| format!("resolve {}", archive.display()))?;
|
|
absolute.push(
|
|
path.to_str()
|
|
.ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))?
|
|
.to_owned(),
|
|
);
|
|
}
|
|
|
|
match control_request(CtlRequest::ImportTakeout {
|
|
archives: absolute,
|
|
dry_run,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
if !wait {
|
|
println!("Run `pdfs import-status` to follow it.");
|
|
return Ok(());
|
|
}
|
|
|
|
// Poll rather than stream: the control protocol is request/response, and an
|
|
// import runs for hours — a held-open socket would be the only long-lived
|
|
// connection the daemon has.
|
|
loop {
|
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
match control_request(CtlRequest::ImportStatus)? {
|
|
CtlResponse::ImportStatus {
|
|
running: true,
|
|
summary: _,
|
|
} => continue,
|
|
CtlResponse::ImportStatus { summary, .. } => {
|
|
print_import_summary(summary.as_ref());
|
|
return Ok(());
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_redate_photos(
|
|
dry_run: bool,
|
|
limit: Option<usize>,
|
|
from: Option<String>,
|
|
to: Option<String>,
|
|
wait: bool,
|
|
) -> Result<()> {
|
|
let range = match (from.as_deref(), to.as_deref()) {
|
|
(None, None) => None,
|
|
(from, to) => Some((
|
|
from.map(parse_day).transpose()?.unwrap_or(i64::MIN),
|
|
to.map(parse_day).transpose()?.unwrap_or(i64::MAX),
|
|
)),
|
|
};
|
|
match control_request(CtlRequest::RedatePhotos {
|
|
dry_run,
|
|
limit,
|
|
range,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
if !wait {
|
|
println!("Run `pdfs redate-status` to follow it.");
|
|
return Ok(());
|
|
}
|
|
// Polled for the same reason the import is: request/response protocol, and a
|
|
// run over a whole library is a download plus an upload per photo.
|
|
loop {
|
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
match control_request(CtlRequest::RedateStatus)? {
|
|
CtlResponse::RedateStatus { running: true, .. } => continue,
|
|
CtlResponse::RedateStatus { summary, .. } => {
|
|
print_redate_summary(summary.as_ref());
|
|
return Ok(());
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout
|
|
/// scanner's date reader, which already accepts exactly this spelling.
|
|
fn parse_day(day: &str) -> Result<i64> {
|
|
pdfs_core::takeout::capture_time_from_name(day)
|
|
.ok_or_else(|| anyhow!("not a date (expected YYYY-MM-DD): {day}"))
|
|
}
|
|
|
|
fn cmd_redate_status() -> Result<()> {
|
|
match control_request(CtlRequest::RedateStatus)? {
|
|
CtlResponse::RedateStatus { running, summary } => {
|
|
if running {
|
|
println!("Re-date running. `pdfs transfers` shows its progress.");
|
|
}
|
|
print_redate_summary(summary.as_ref());
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_cancel_redate() -> Result<()> {
|
|
match control_request(CtlRequest::CancelRedate)? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) {
|
|
let Some(summary) = summary else {
|
|
println!("No re-date has run yet.");
|
|
return;
|
|
};
|
|
println!("Photos examined : {}", summary.examined);
|
|
println!("Mis-dated : {}", summary.candidates);
|
|
if summary.dry_run {
|
|
println!("(dry run — nothing was uploaded or trashed)");
|
|
} else {
|
|
println!("Re-dated : {}", summary.redated);
|
|
if summary.album_links > 0 {
|
|
println!("Albums refiled : {}", summary.album_links);
|
|
}
|
|
if summary.failed > 0 {
|
|
println!("Failed : {}", summary.failed);
|
|
}
|
|
if summary.bytes > 0 {
|
|
println!("Uploaded bytes : {}", summary.bytes);
|
|
}
|
|
}
|
|
for (name, stored, wanted) in &summary.samples {
|
|
// Epoch seconds, as `pdfs photos` prints capture times.
|
|
println!(" {name}: {stored} -> {wanted}");
|
|
}
|
|
if summary.candidates > summary.samples.len() {
|
|
println!(
|
|
" … and {} more",
|
|
summary.candidates - summary.samples.len()
|
|
);
|
|
}
|
|
if summary.cancelled {
|
|
println!("Run was cancelled before it finished.");
|
|
}
|
|
}
|
|
|
|
fn cmd_import_status() -> Result<()> {
|
|
match control_request(CtlRequest::ImportStatus)? {
|
|
CtlResponse::ImportStatus { running, summary } => {
|
|
if running {
|
|
println!("Import running. `pdfs transfers` shows its progress.");
|
|
}
|
|
print_import_summary(summary.as_ref());
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_cancel_import() -> Result<()> {
|
|
match control_request(CtlRequest::CancelImport)? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
|
|
let Some(summary) = summary else {
|
|
println!("No import has run yet.");
|
|
return;
|
|
};
|
|
println!("Photos in export : {}", summary.found);
|
|
println!("Uploaded : {}", summary.uploaded);
|
|
println!("Already on Proton: {}", summary.duplicates);
|
|
if summary.skipped_trashed > 0 {
|
|
println!("Skipped (trash) : {}", summary.skipped_trashed);
|
|
}
|
|
if summary.albums_created > 0 || summary.album_links > 0 {
|
|
println!(
|
|
"Albums : {} created, {} photos filed",
|
|
summary.albums_created, summary.album_links
|
|
);
|
|
}
|
|
if summary.failed > 0 {
|
|
println!("Failed : {}", summary.failed);
|
|
}
|
|
if summary.bytes > 0 {
|
|
println!("Uploaded bytes : {}", summary.bytes);
|
|
}
|
|
if summary.cancelled {
|
|
println!("Import was cancelled before it finished.");
|
|
}
|
|
}
|
|
|
|
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
|
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
|
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
|
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_open_photo(uid: String) -> Result<()> {
|
|
match control_request(CtlRequest::OpenPhoto { uid })? {
|
|
CtlResponse::FilePath { path } => println!("{path}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_search(query: String, limit: usize) -> Result<()> {
|
|
match control_request(CtlRequest::Search { query, limit })? {
|
|
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
|
|
CtlResponse::SearchResults { hits } => {
|
|
for h in hits {
|
|
let kind = if h.is_dir { "d" } else { "-" };
|
|
let pin = if h.pinned { "*" } else { " " };
|
|
println!("{kind}{pin} {:>12} {}", h.size, h.path);
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> {
|
|
match control_request(CtlRequest::Rename {
|
|
path: path_arg(&path)?,
|
|
new_name,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> {
|
|
match control_request(CtlRequest::Move {
|
|
path: path_arg(&path)?,
|
|
new_parent: path_arg(&new_parent)?,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_rm(path: PathBuf) -> Result<()> {
|
|
match control_request(CtlRequest::Delete {
|
|
path: path_arg(&path)?,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Seconds as something a human reads at a glance: the ages here run from
|
|
/// "still starting" to "wedged since yesterday".
|
|
fn human_secs(secs: u64) -> String {
|
|
match secs {
|
|
s if s < 60 => format!("{s}s"),
|
|
s if s < 3600 => format!("{}m{}s", s / 60, s % 60),
|
|
s if s < 86400 => format!("{}h{}m", s / 3600, (s % 3600) / 60),
|
|
s => format!("{}d{}h", s / 86400, (s % 86400) / 3600),
|
|
}
|
|
}
|
|
|
|
fn cmd_diagnostics() -> Result<()> {
|
|
let response = control_request(CtlRequest::Diagnostics)?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::Diagnostics { diagnostics: d } => {
|
|
println!(
|
|
"uptime {} rss {} pending ops {}",
|
|
human_secs(d.uptime_secs),
|
|
human_bytes(d.rss_bytes),
|
|
d.pending_ops,
|
|
);
|
|
println!(
|
|
"control handlers {}/{}",
|
|
d.control_handlers, d.control_handler_limit,
|
|
);
|
|
println!(
|
|
"queued: meta {} (done {}), transfer {} (done {})",
|
|
d.meta_queued, d.meta_completed, d.transfer_queued, d.transfer_completed,
|
|
);
|
|
println!("workers:");
|
|
for worker in &d.workers {
|
|
let what = if worker.busy {
|
|
worker.label.as_str()
|
|
} else {
|
|
"idle"
|
|
};
|
|
println!(
|
|
" {:<20} {:<12} {}",
|
|
worker.name,
|
|
what,
|
|
human_secs(worker.age_secs)
|
|
);
|
|
}
|
|
if d.inflight.is_empty() {
|
|
println!("in-flight requests: none");
|
|
} else {
|
|
println!("in-flight requests (oldest first):");
|
|
for request in &d.inflight {
|
|
println!(" {:<20} {}", request.kind, human_secs(request.age_secs));
|
|
}
|
|
}
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
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, kind } => bail!("{}", cli_error(kind, &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, kind } => bail!("{}", cli_error(kind, &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, kind } => bail!("{}", cli_error(kind, &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, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Drop a cached listing. `trash` and `photos` name those two listings; anything
|
|
/// else is read as a folder path, so a folder actually called "trash" is still
|
|
/// reachable as `./trash`.
|
|
fn cmd_refresh(target: Option<String>, full: bool) -> Result<()> {
|
|
let scope = match target.as_deref() {
|
|
Some("trash") => RefreshScope::Trash,
|
|
Some("photos") => RefreshScope::Photos { full },
|
|
path => RefreshScope::Dir {
|
|
path: path.unwrap_or("").to_string(),
|
|
},
|
|
};
|
|
let photos = matches!(scope, RefreshScope::Photos { .. });
|
|
match control_request(CtlRequest::Refresh { scope })? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
if photos {
|
|
wait_for_photos_refresh()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// How often [`wait_for_photos_refresh`] asks the daemon whether the timeline
|
|
/// refresh it started is done.
|
|
const PHOTOS_REFRESH_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
|
|
|
/// Block until the daemon's timeline refresh has finished.
|
|
///
|
|
/// The daemon answers `Refresh { Photos }` as soon as the refresh is running,
|
|
/// because re-reading a large library takes minutes and a control reply may not.
|
|
/// `pdfs refresh photos` means "the timeline is up to date when I return", so
|
|
/// the waiting happens here, where no socket read bound applies.
|
|
fn wait_for_photos_refresh() -> Result<()> {
|
|
loop {
|
|
std::thread::sleep(PHOTOS_REFRESH_POLL);
|
|
match control_request(CtlRequest::PhotosRefreshStatus)? {
|
|
CtlResponse::PhotosRefresh { running: true } => continue,
|
|
CtlResponse::PhotosRefresh { running: false } => {
|
|
println!("refreshed");
|
|
return Ok(());
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask for a typed `yes` before an irreversible destroy. Errors (aborting the
|
|
/// command) on anything else, including a non-interactive stdin: a piped or
|
|
/// scripted invocation must not silently wipe the trash.
|
|
fn confirm(prompt: &str) -> Result<()> {
|
|
print!("{prompt} [type \"yes\" to confirm] ");
|
|
std::io::stdout().flush()?;
|
|
let mut answer = String::new();
|
|
std::io::stdin().read_line(&mut answer)?;
|
|
if answer.trim() != "yes" {
|
|
bail!("aborted");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
|
|
match control_request(CtlRequest::CreateFolder {
|
|
parent: path_arg(&parent)?,
|
|
name,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn cmd_upload(sources: Vec<PathBuf>, parent: PathBuf) -> Result<()> {
|
|
// The daemon reads the sources off its own (local) filesystem, so send it
|
|
// absolute, canonicalized paths.
|
|
let mut abs = Vec::with_capacity(sources.len());
|
|
for src in &sources {
|
|
let p = std::fs::canonicalize(src).with_context(|| format!("resolve {}", src.display()))?;
|
|
abs.push(
|
|
p.to_str()
|
|
.ok_or_else(|| anyhow!("path is not valid UTF-8: {}", p.display()))?
|
|
.to_owned(),
|
|
);
|
|
}
|
|
match control_request(CtlRequest::UploadPaths {
|
|
parent: path_arg(&parent)?,
|
|
sources: abs,
|
|
})? {
|
|
CtlResponse::Ok { message } => println!("{message}"),
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Normalize a user-supplied path argument for sending to the daemon. Absolute
|
|
/// paths are canonicalized (so the daemon can strip its mountpoint prefix);
|
|
/// relative paths are passed through and resolved against the mount root.
|
|
fn path_arg(path: &Path) -> Result<String> {
|
|
let p = if path.is_absolute() {
|
|
std::fs::canonicalize(path).with_context(|| format!("resolve {}", path.display()))?
|
|
} else {
|
|
path.to_path_buf()
|
|
};
|
|
p.to_str()
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| anyhow!("path is not valid UTF-8"))
|
|
}
|
|
|
|
/// Format an age in seconds for a report line, coarsely — the point of the
|
|
/// number is whether it is minutes or weeks.
|
|
/// Parse a `sync pause --for` duration: a whole number followed by `m`, `h` or
|
|
/// `d`. Returns seconds.
|
|
fn parse_pause_duration(text: &str) -> Result<u64, String> {
|
|
let text = text.trim();
|
|
let split = text.char_indices().last().map_or(0, |(at, _)| at);
|
|
let (number, unit) = text.split_at(split);
|
|
let per_unit: u64 = match unit {
|
|
"m" => 60,
|
|
"h" => 3600,
|
|
"d" => 86_400,
|
|
_ => return Err("use a number followed by m, h or d, e.g. 30m".to_string()),
|
|
};
|
|
match number.parse::<u64>() {
|
|
Ok(n) if n > 0 => Ok(n.saturating_mul(per_unit)),
|
|
_ => Err("use a positive whole number, e.g. 30m".to_string()),
|
|
}
|
|
}
|
|
|
|
fn unix_now() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn human_age(secs: u64) -> String {
|
|
const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)];
|
|
for (unit, scale) in UNITS {
|
|
if secs >= scale {
|
|
return format!("{}{unit}", secs / scale);
|
|
}
|
|
}
|
|
"just now".to_string()
|
|
}
|
|
|
|
/// Format a byte count for a report line.
|
|
fn human_bytes(bytes: u64) -> String {
|
|
const UNITS: [(&str, f64); 4] = [
|
|
("GiB", 1_073_741_824.0),
|
|
("MiB", 1_048_576.0),
|
|
("KiB", 1024.0),
|
|
("B", 1.0),
|
|
];
|
|
let b = bytes as f64;
|
|
for (unit, scale) in UNITS {
|
|
if b >= scale {
|
|
return format!("{:.1} {unit}", b / scale);
|
|
}
|
|
}
|
|
"0 B".to_string()
|
|
}
|
|
|
|
fn cmd_cache(action: CacheCmd) -> Result<()> {
|
|
match action {
|
|
CacheCmd::Inspect { deep } => cmd_cache_inspect(deep),
|
|
CacheCmd::Vacuum => match control_request(CtlRequest::CacheVacuum)? {
|
|
CtlResponse::Ok { message } => {
|
|
println!("{message}");
|
|
Ok(())
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
},
|
|
CacheCmd::Clear => match control_request(CtlRequest::PurgeCache)? {
|
|
CtlResponse::Ok { message } => {
|
|
println!("{message}");
|
|
Ok(())
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Ask for a deep integrity check and wait for it to finish.
|
|
///
|
|
/// The daemon answers the request immediately and runs the check on a thread —
|
|
/// it holds the single database connection for as long as it takes, which would
|
|
/// otherwise stall the mount — so a caller that needs the verdict polls for it.
|
|
/// `pdfs doctor` is that caller: it is a diagnostic the user is watching, so
|
|
/// waiting is right, and the bound is generous enough for a large database
|
|
/// without hanging forever if the daemon never finishes.
|
|
fn await_integrity_check() -> Result<CtlResponse> {
|
|
const POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
|
const GIVE_UP_AFTER: std::time::Duration = std::time::Duration::from_secs(120);
|
|
|
|
let started = std::time::Instant::now();
|
|
loop {
|
|
let response = control_request(CtlRequest::CacheInspect { deep: true })?;
|
|
match &response {
|
|
CtlResponse::CacheReport {
|
|
integrity_running: true,
|
|
integrity_checked: false,
|
|
..
|
|
} if started.elapsed() < GIVE_UP_AFTER => std::thread::sleep(POLL),
|
|
_ => return Ok(response),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
|
let response = control_request(CtlRequest::CacheInspect { deep })?;
|
|
if emit_json(&response)? {
|
|
return Ok(());
|
|
}
|
|
match response {
|
|
CtlResponse::CacheReport {
|
|
schema_version,
|
|
db_bytes,
|
|
db_reclaimable_bytes,
|
|
tables,
|
|
cache_used,
|
|
cache_budget,
|
|
integrity_problems,
|
|
integrity_checked,
|
|
integrity_running,
|
|
} => {
|
|
println!(
|
|
"Database schema v{schema_version}, {}",
|
|
human_bytes(db_bytes)
|
|
);
|
|
if db_reclaimable_bytes > 0 {
|
|
println!(
|
|
" {} reclaimable — run `pdfs cache vacuum`",
|
|
human_bytes(db_reclaimable_bytes)
|
|
);
|
|
}
|
|
let budget = if cache_budget == 0 {
|
|
"unlimited".to_string()
|
|
} else {
|
|
human_bytes(cache_budget)
|
|
};
|
|
println!("Content {} cached of {budget}", human_bytes(cache_used));
|
|
|
|
println!("\nRows");
|
|
for (name, count) in tables {
|
|
println!(" {name:<16} {count:>10}");
|
|
}
|
|
|
|
if integrity_checked {
|
|
if integrity_problems.is_empty() {
|
|
println!("\nIntegrity ok");
|
|
} else {
|
|
println!("\nIntegrity {} problem(s):", integrity_problems.len());
|
|
for problem in integrity_problems {
|
|
println!(" {problem}");
|
|
}
|
|
}
|
|
} else if integrity_running {
|
|
// The daemon runs the check on a thread rather than holding its
|
|
// only database connection for the length of it, so the answer
|
|
// belongs to a later request.
|
|
println!("\nIntegrity checking in the background — run this again for the result");
|
|
} else {
|
|
println!("\nIntegrity not checked (pass --deep)");
|
|
}
|
|
Ok(())
|
|
}
|
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
|
other => bail!("unexpected response: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct DiagnoseReport {
|
|
warnings: usize,
|
|
failures: usize,
|
|
}
|
|
|
|
impl DiagnoseReport {
|
|
fn finding(&mut self, level: DiagnoseLevel, label: &str, detail: impl AsRef<str>) {
|
|
let mark = match level {
|
|
DiagnoseLevel::Ok => "ok ",
|
|
DiagnoseLevel::Warn => {
|
|
self.warnings += 1;
|
|
"WARN"
|
|
}
|
|
DiagnoseLevel::Fail => {
|
|
self.failures += 1;
|
|
"FAIL"
|
|
}
|
|
};
|
|
let detail = detail.as_ref();
|
|
if detail.is_empty() {
|
|
println!("[{mark}] {label}");
|
|
} else {
|
|
println!("[{mark}] {label}: {detail}");
|
|
}
|
|
}
|
|
|
|
fn check(&mut self, label: &str, ok: bool, detail: impl AsRef<str>) {
|
|
self.finding(
|
|
if ok {
|
|
DiagnoseLevel::Ok
|
|
} else {
|
|
DiagnoseLevel::Fail
|
|
},
|
|
label,
|
|
detail,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum DiagnoseLevel {
|
|
Ok,
|
|
Warn,
|
|
Fail,
|
|
}
|
|
|
|
fn executable_on_path(name: &str) -> bool {
|
|
std::env::var_os("PATH").is_some_and(|paths| {
|
|
std::env::split_paths(&paths)
|
|
.any(|dir| std::fs::metadata(dir.join(name)).is_ok_and(|m| m.is_file()))
|
|
})
|
|
}
|
|
|
|
fn mounted_fs_type(path: &Path) -> Option<String> {
|
|
let wanted = path.canonicalize().ok()?;
|
|
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
|
|
mounts
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let fields: Vec<_> = line.split_whitespace().collect();
|
|
let separator = fields.iter().position(|field| *field == "-")?;
|
|
let encoded = *fields.get(4)?;
|
|
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
|
|
let mountpoint = PathBuf::from(decoded);
|
|
if mountpoint.canonicalize().ok()? == wanted {
|
|
Some(fields.get(separator + 1)?.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.next()
|
|
}
|
|
|
|
/// Create, sync, and remove a new file without ever replacing an existing one.
|
|
fn writable_probe(dir: &Path) -> std::io::Result<()> {
|
|
let name = format!(".pdfs-diagnose-probe-{}", std::process::id());
|
|
let path = dir.join(name);
|
|
let mut file = OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&path)?;
|
|
let result = (|| {
|
|
file.write_all(b"probe")?;
|
|
file.sync_all()
|
|
})();
|
|
drop(file);
|
|
let remove = std::fs::remove_file(path);
|
|
result.and(remove)
|
|
}
|
|
|
|
/// Self-check the installation, reporting each finding and exiting non-zero if
|
|
/// any check failed.
|
|
///
|
|
/// Every check is written to work with no daemon running, because that is the
|
|
/// situation a user runs this in. A missing daemon is reported as a finding,
|
|
/// not an error that aborts the rest of the report.
|
|
fn cmd_diagnose() -> Result<()> {
|
|
let mut report = DiagnoseReport::default();
|
|
|
|
let dirs = AppDirs::new().context("resolve application directories")?;
|
|
let config = dirs.load_config();
|
|
|
|
println!("Paths");
|
|
let state = dirs.state_dir();
|
|
report.check(
|
|
" state dir",
|
|
state.is_dir(),
|
|
format!("{}", state.display()),
|
|
);
|
|
let cache_dir = dirs.cache_dir();
|
|
report.check(
|
|
" cache dir",
|
|
cache_dir.is_dir(),
|
|
format!("{}", cache_dir.display()),
|
|
);
|
|
// Writability is checked by actually writing: permission bits do not
|
|
// account for a full disk, a read-only remount, or an immutable flag.
|
|
match writable_probe(&state) {
|
|
Ok(()) => report.finding(
|
|
DiagnoseLevel::Ok,
|
|
" state dir writable",
|
|
"write and fsync succeeded",
|
|
),
|
|
Err(e) => report.finding(DiagnoseLevel::Fail, " state dir writable", e.to_string()),
|
|
}
|
|
|
|
let db_path = dirs.db_path();
|
|
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
|
report.check(
|
|
" database",
|
|
db_path.is_file(),
|
|
format!("{} ({})", db_path.display(), human_bytes(db_size)),
|
|
);
|
|
|
|
println!("\nFUSE");
|
|
let fuse = Path::new("/dev/fuse");
|
|
report.check(" /dev/fuse", fuse.exists(), fuse.display().to_string());
|
|
let has_fusermount = executable_on_path("fusermount3") || executable_on_path("fusermount");
|
|
report.check(
|
|
" unmount helper",
|
|
has_fusermount,
|
|
"fusermount3 or fusermount on PATH",
|
|
);
|
|
|
|
println!("\nAccount");
|
|
match pdfs_core::auth::load() {
|
|
Ok(session) => {
|
|
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
|
}
|
|
Err(e) => {
|
|
// A locked keyring and an absent login look the same from here, so
|
|
// the detail carries the distinction rather than the verdict.
|
|
report.finding(DiagnoseLevel::Fail, " keyring session", format!("{e}"));
|
|
}
|
|
}
|
|
|
|
println!("\nDaemon");
|
|
let socket = dirs.control_socket();
|
|
let socket_exists = socket.exists();
|
|
let socket_kind_ok =
|
|
std::fs::symlink_metadata(&socket).is_ok_and(|m| m.file_type().is_socket());
|
|
report.finding(
|
|
if socket_exists && !socket_kind_ok {
|
|
DiagnoseLevel::Fail
|
|
} else if socket_exists {
|
|
DiagnoseLevel::Ok
|
|
} else {
|
|
DiagnoseLevel::Warn
|
|
},
|
|
" control socket",
|
|
socket.display().to_string(),
|
|
);
|
|
// A stale socket file with no listener is the common failure, so the reply
|
|
// is what decides this rather than the file's existence.
|
|
match control_request(CtlRequest::Status) {
|
|
Ok(CtlResponse::Status {
|
|
mountpoint,
|
|
online,
|
|
pending_uploads,
|
|
pending_changes,
|
|
..
|
|
}) => {
|
|
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
|
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
|
let is_fuse = live_type
|
|
.as_deref()
|
|
.is_some_and(|kind| kind.starts_with("fuse"));
|
|
report.finding(
|
|
if is_fuse {
|
|
DiagnoseLevel::Ok
|
|
} else {
|
|
DiagnoseLevel::Fail
|
|
},
|
|
" active mount",
|
|
live_type.map_or_else(
|
|
|| format!("{mountpoint} is not mounted"),
|
|
|kind| format!("{mountpoint} ({kind})"),
|
|
),
|
|
);
|
|
report.finding(
|
|
if online {
|
|
DiagnoseLevel::Ok
|
|
} else {
|
|
DiagnoseLevel::Warn
|
|
},
|
|
" network",
|
|
if online {
|
|
"online"
|
|
} else {
|
|
"offline; cached data remains available"
|
|
},
|
|
);
|
|
let pending = pending_uploads + pending_changes;
|
|
report.finding(
|
|
if pending == 0 {
|
|
DiagnoseLevel::Ok
|
|
} else {
|
|
DiagnoseLevel::Warn
|
|
},
|
|
" queued writes",
|
|
if pending == 0 {
|
|
"none".to_string()
|
|
} else {
|
|
format!("{pending} waiting to upload")
|
|
},
|
|
);
|
|
match await_integrity_check() {
|
|
Ok(CtlResponse::CacheReport {
|
|
integrity_problems,
|
|
integrity_checked: true,
|
|
..
|
|
}) if integrity_problems.is_empty() => report.finding(
|
|
DiagnoseLevel::Ok,
|
|
" database integrity",
|
|
"SQLite integrity_check passed",
|
|
),
|
|
Ok(CtlResponse::CacheReport {
|
|
integrity_checked: false,
|
|
..
|
|
}) => report.finding(
|
|
DiagnoseLevel::Warn,
|
|
" database integrity",
|
|
"still checking — run `pdfs cache inspect --deep` for the result",
|
|
),
|
|
Ok(CtlResponse::CacheReport {
|
|
integrity_problems, ..
|
|
}) => report.finding(
|
|
DiagnoseLevel::Fail,
|
|
" database integrity",
|
|
format!(
|
|
"{} problem(s): {}",
|
|
integrity_problems.len(),
|
|
integrity_problems.join("; ")
|
|
),
|
|
),
|
|
Ok(other) => report.finding(
|
|
DiagnoseLevel::Fail,
|
|
" database integrity",
|
|
format!("unexpected: {other:?}"),
|
|
),
|
|
Err(e) => report.finding(
|
|
DiagnoseLevel::Fail,
|
|
" database integrity",
|
|
format!("check failed: {e:#}"),
|
|
),
|
|
}
|
|
}
|
|
Ok(other) => {
|
|
report.finding(
|
|
DiagnoseLevel::Fail,
|
|
" daemon responding",
|
|
format!("unexpected: {other:?}"),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
// Not a failure of the installation: a stopped daemon is a normal
|
|
// state, and the rest of the report is still worth printing.
|
|
report.finding(
|
|
DiagnoseLevel::Warn,
|
|
" daemon responding",
|
|
format!("stopped or unreachable: {e:#}"),
|
|
);
|
|
println!(" (start it with `systemctl --user start proton-drive.service`)");
|
|
}
|
|
}
|
|
|
|
let mountpoint = dirs.resolved_mountpoint(&config);
|
|
report.finding(
|
|
if mountpoint.is_dir() {
|
|
DiagnoseLevel::Ok
|
|
} else {
|
|
DiagnoseLevel::Warn
|
|
},
|
|
" configured mountpoint",
|
|
mountpoint.display().to_string(),
|
|
);
|
|
|
|
if report.failures == 0 {
|
|
if report.warnings == 0 {
|
|
println!("\nNo problems found.");
|
|
} else {
|
|
println!(
|
|
"\nNo critical problems found; {} warning(s) need attention.",
|
|
report.warnings
|
|
);
|
|
}
|
|
Ok(())
|
|
} else {
|
|
bail!(
|
|
"{} check(s) failed and {} warning(s) were reported; see above",
|
|
report.failures,
|
|
report.warnings
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Send one request to the running mount daemon's control socket and read its
|
|
/// reply. Errors if no daemon is listening.
|
|
fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
|
let socket = AppDirs::new()?.control_socket();
|
|
pdfs_core::control::send(&socket, &req)
|
|
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod pause_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn pause_durations_read_minutes_hours_and_days() {
|
|
assert_eq!(parse_pause_duration("30m"), Ok(1800));
|
|
assert_eq!(parse_pause_duration("1h"), Ok(3600));
|
|
assert_eq!(parse_pause_duration("2d"), Ok(172_800));
|
|
}
|
|
|
|
#[test]
|
|
fn pause_durations_reject_what_they_cannot_read() {
|
|
assert!(parse_pause_duration("").is_err());
|
|
assert!(parse_pause_duration("0h").is_err());
|
|
assert!(parse_pause_duration("1w").is_err());
|
|
assert!(parse_pause_duration("h").is_err());
|
|
assert!(parse_pause_duration("-1h").is_err());
|
|
assert!(parse_pause_duration("1ä").is_err());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod diagnose_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn finding_severity_is_counted_centrally() {
|
|
let mut report = DiagnoseReport::default();
|
|
report.finding(DiagnoseLevel::Ok, "ok", "");
|
|
report.finding(DiagnoseLevel::Warn, "warn", "");
|
|
report.finding(DiagnoseLevel::Fail, "fail", "");
|
|
assert_eq!(report.warnings, 1);
|
|
assert_eq!(report.failures, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn writable_probe_never_replaces_an_existing_file() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"pdfs-diagnose-test-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let collision = dir.join(format!(".pdfs-diagnose-probe-{}", std::process::id()));
|
|
std::fs::write(&collision, b"keep me").unwrap();
|
|
|
|
assert_eq!(
|
|
writable_probe(&dir).unwrap_err().kind(),
|
|
std::io::ErrorKind::AlreadyExists
|
|
);
|
|
assert_eq!(std::fs::read(&collision).unwrap(), b"keep me");
|
|
|
|
std::fs::remove_file(collision).unwrap();
|
|
std::fs::remove_dir(dir).unwrap();
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod login_wait_tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
#[test]
|
|
fn poll_delay_doubles_then_holds_at_the_ceiling() {
|
|
assert_eq!(login_poll_delay(0), Duration::from_secs(3));
|
|
assert_eq!(login_poll_delay(1), Duration::from_secs(6));
|
|
assert_eq!(login_poll_delay(2), Duration::from_secs(12));
|
|
assert_eq!(login_poll_delay(3), Duration::from_secs(15));
|
|
assert_eq!(login_poll_delay(4), Duration::from_secs(15));
|
|
// A daemon left unauthenticated for weeks reaches attempt counts well
|
|
// past the width of the shift; that must cap, not panic.
|
|
assert_eq!(login_poll_delay(64), Duration::from_secs(15));
|
|
assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15));
|
|
}
|
|
|
|
#[test]
|
|
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
|
let mut checks_left = 20;
|
|
let mut slept: Vec<Duration> = Vec::new();
|
|
|
|
wait_for_session(
|
|
|| {
|
|
if checks_left == 0 {
|
|
Ok(())
|
|
} else {
|
|
checks_left -= 1;
|
|
Err(pdfs_core::Error::NotLoggedIn)
|
|
}
|
|
},
|
|
|delay| slept.push(delay),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(slept.len(), 20, "one wait per failed check");
|
|
assert!(
|
|
slept.windows(2).all(|w| w[1] >= w[0]),
|
|
"the interval must never shrink: {slept:?}"
|
|
);
|
|
|
|
// The point of the change. Every check is a fresh D-Bus session against
|
|
// the Secret Service, so 20 of them must not fit inside a single minute
|
|
// the way a flat 3s poll did.
|
|
let total: Duration = slept.iter().sum();
|
|
assert!(
|
|
total >= Duration::from_secs(4 * 60),
|
|
"20 checks should span at least 4 minutes, spanned {total:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stored_session_is_not_waited_for_at_all() {
|
|
let mut slept = 0;
|
|
wait_for_session(|| Ok(()), |_| slept += 1).unwrap();
|
|
assert_eq!(slept, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn epoch_seconds_format_as_utc_calendar_dates() {
|
|
assert_eq!(format_epoch_utc(0), "1970-01-01");
|
|
assert_eq!(format_epoch_utc(951_782_400), "2000-02-29");
|
|
assert_eq!(format_epoch_utc(1_700_000_000), "2023-11-14");
|
|
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
|
|
}
|
|
|
|
#[test]
|
|
fn a_forged_looking_invitation_is_flagged_not_stated() {
|
|
let mut entry = pdfs_core::control::DirEntry {
|
|
name: "Budget.ods".into(),
|
|
is_dir: false,
|
|
size: 1,
|
|
modified: 0,
|
|
pinned: false,
|
|
cached: false,
|
|
uid: "vol~link".into(),
|
|
path: String::new(),
|
|
role: "viewer".into(),
|
|
shared_by: String::new(),
|
|
shared_at: 0,
|
|
shared_by_unverified: false,
|
|
};
|
|
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
|
entry.shared_by = "alice@proton.me".into();
|
|
entry.shared_at = 1_700_000_000;
|
|
assert_eq!(
|
|
shared_by_line(&entry).as_deref(),
|
|
Some("shared by alice@proton.me at 2023-11-14")
|
|
);
|
|
entry.shared_by_unverified = true;
|
|
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
|
|
}
|
|
}
|