mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
init
This commit is contained in:
1 parent
5ac94848f3
commit
862740c4ee
20 files changed
+3831
-150
No files matched your search
+214
-7
@@ -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()))
|
||||
}
|
||||
Reference in new issue
Block a user