This commit is contained in:
nvrl committed 2026-06-30 03:27:13 +02:00
1 parent 5ac94848f3
commit 862740c4ee
20 files changed
+3831 -150

No files matched your search

+214 -7
View File
@@ -1,12 +1,14 @@
//! `pdfs` — command-line front-end for the Proton Drive Linux client.
use std::io::Write;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
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::{Request as CtlRequest, Response as CtlResponse};
#[derive(Parser)]
#[command(name = "pdfs", version, about = "Proton Drive for Linux (Files On-Demand)")]
@@ -25,20 +27,54 @@ enum Command {
},
/// Forget the stored session.
Logout,
/// Show the currently logged-in account.
/// Show account login state, and mount status if a daemon is running.
Status,
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
Mount {
/// 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,
},
/// 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,
},
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
// `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();
@@ -48,6 +84,12 @@ fn main() -> Result<()> {
Command::Logout => cmd_logout(),
Command::Status => cmd_status(),
Command::Mount { mountpoint } => cmd_mount(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 } => cmd_photos(limit, offset),
Command::OpenPhoto { uid } => cmd_open_photo(uid),
}
}
@@ -93,9 +135,20 @@ fn cmd_status() -> Result<()> {
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`."),
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, .. }) => {
println!("Mounted at {mountpoint} ({pinned} pinned)");
}
Ok(other) => println!("Mount: unexpected response {other:?}"),
Err(_) => println!("Mount: not running."),
}
Ok(())
}
@@ -106,14 +159,168 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
std::fs::create_dir_all(&mountpoint)
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
let username = auth::load().map(|s| s.username).unwrap_or_default();
let cache = ContentCache::open(
dirs.content_cache_dir(),
dirs.pins_path(),
pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES,
)
.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
let (client, session) = rt
.block_on(auth::resume_client())
.context("resume session (run `pdfs login` first)")?;
// Persist rotated tokens for the daemon's lifetime. Proton refresh tokens are
// single-use: the 401-refresh path swaps in a new access+refresh pair, and if
// that pair is never written back the keyring keeps a now-dead refresh token,
// so the next `pdfs mount` fails with `InvalidRefreshToken`. The session
// shares its token store with the Drive client (one `Arc`-backed token store),
// so both the poll below and the shutdown flush see every rotation.
//
// `ProtonApiSession` is `Clone` over that shared store, so the polling task
// gets its own handle and we keep `session` here for a final flush. The poll
// is a backstop for long-lived mounts; the flush after `mount` returns is what
// makes the common case correct — a refresh shortly before a clean unmount
// would otherwise be lost in the poll gap, leaving a dead token behind.
let poll_session = session.clone();
rt.spawn(async move {
let mut last = poll_session.current_tokens().await;
loop {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let current = poll_session.current_tokens().await;
if current.access_token == last.access_token
&& current.refresh_token == last.refresh_token
{
continue;
}
match auth::persist(&poll_session).await {
Ok(()) => {
tracing::info!("persisted refreshed session tokens");
last = current;
}
Err(e) => tracing::warn!(error = %e, "failed to persist refreshed tokens"),
}
}
});
let handle = rt.handle().clone();
pdfs_fuse::mount(client, handle, &mountpoint).context("mount failed")?;
let result =
pdfs_fuse::mount(client, handle, &mountpoint, cache, &control_socket, username);
// Clean unmount: flush whatever tokens the store holds now so the keyring
// always ends a session with the live refresh token, not a stale one. Runs
// even if `mount` errored — a rotation may still have happened first.
if let Err(e) = rt.block_on(auth::persist(&session)) {
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
}
result.context("mount failed")?;
Ok(())
}
fn cmd_pin(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::Pin { path: path_arg(&path)? })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{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 } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_pins() -> Result<()> {
match control_request(CtlRequest::ListPins)? {
CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."),
CtlResponse::Pins { pins } => {
for p in pins {
println!("{}", p.path);
}
}
CtlResponse::Error { message } => bail!("{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(),
};
match control_request(CtlRequest::ListDir { path })? {
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 } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
match control_request(CtlRequest::PhotosTimeline { offset, limit })? {
CtlResponse::Photos { available: false, .. } => {
println!("This account has no photos volume.")
}
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
CtlResponse::Photos { items, .. } => {
for p in items {
let thumb = p.thumb_path.as_deref().unwrap_or("(no thumbnail)");
println!("{} {} {thumb}", p.capture_time, p.uid);
}
}
CtlResponse::Error { message } => bail!("{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 } => bail!("{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"))
}
/// 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()))
}