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

+1
View File
@@ -20,3 +20,4 @@ tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
rpassword.workspace = true
serde_json.workspace = true
+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()))
}
+1
View File
@@ -16,4 +16,5 @@ serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
sha2.workspace = true
keyring.workspace = true
+18 -4
View File
@@ -35,7 +35,7 @@ pub struct StoredSession {
}
impl StoredSession {
fn into_params(&self) -> ResumeParameters {
fn to_params(&self) -> ResumeParameters {
ResumeParameters {
session_id: self.session_id.clone().into(),
username: self.username.clone(),
@@ -122,11 +122,25 @@ pub fn logout() -> Result<()> {
/// Resume a persisted session and build an authenticated Drive client.
///
/// Re-persists tokens afterward in case the refresh path rotated them. Returns
/// `Error::NotLoggedIn` when no session has been saved.
/// Returns `Error::NotLoggedIn` when no session has been saved. The caller is
/// responsible for persisting rotated tokens over the session's lifetime via
/// [`persist`] — Proton refresh tokens are single-use, so a refresh that is not
/// written back leaves the keyring holding a stale (now-invalid) refresh token,
/// and the next resume fails with `InvalidRefreshToken`.
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
let stored = load()?;
let session = ProtonApiSession::resume(client_config(), stored.into_params())?;
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
Ok((client, session))
}
/// Write the session's current tokens back to the keyring.
///
/// The session does not carry the mailbox password (it is only needed to rebuild
/// the key chain on resume), so it is re-read from the existing stored blob and
/// preserved. Call whenever the session may have rotated its tokens through the
/// 401-refresh path so a later [`resume_client`] presents a live refresh token.
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
let mailbox_password = load()?.mailbox_password;
save(session, &mailbox_password).await
}
+549
View File
@@ -0,0 +1,549 @@
//! On-disk content cache and pin registry.
//!
//! Files-On-Demand keeps file *content* off local disk by default: a `read`
//! hydrates the requested byte range straight from the remote. Two things make
//! that cache-worthy:
//!
//! * **Pinning** — the user marks a file "always keep on this device". Its full
//! plaintext is downloaded once and stored under [`AppDirs::cache_dir`]; every
//! later `read` is served from disk with no network.
//! * **Validation** — Proton does not hand us a stable revision id on the public
//! [`Node`], so a cached blob is validated against the node's
//! `(modification_time, plaintext_size)` pair. A new revision changes at least
//! the mtime, so a stale blob is detected and ignored (and the event poller
//! evicts it eagerly anyway).
//!
//! [`AppDirs::cache_dir`]: crate::config::AppDirs::cache_dir
//! [`Node`]: proton_drive_rs::Node
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::Result;
/// Validity tag stored alongside a cached blob. A blob is fresh only if both
/// fields still match the node's current metadata.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
struct Meta {
/// Node modification time, epoch seconds.
mtime: i64,
/// Plaintext size in bytes.
size: u64,
}
/// One pinned file, as persisted to the pin registry.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Pin {
/// Node uid in `volume~link` display form.
pub uid: String,
/// Last path the file was pinned under, for display in `status`. Advisory
/// only — the uid is the identity.
pub path: String,
}
/// The pin registry, persisted as JSON. Keyed by uid display string so a pin
/// survives renames/moves of the node.
#[derive(Serialize, Deserialize, Default)]
struct PinFile {
pins: BTreeMap<String, Pin>,
}
/// Content cache rooted at a directory, with a sibling pin-registry file.
pub struct ContentCache {
/// Directory holding `<key>` blobs and `<key>.meta` tags.
content_dir: PathBuf,
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files.
thumb_dir: PathBuf,
/// JSON pin registry path.
pins_path: PathBuf,
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
/// evicts least-recently-used *unpinned* blobs back under the cap. `0`
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
/// the cache over budget.
max_bytes: u64,
}
impl ContentCache {
/// Open (and create) a cache under `content_dir`, with the pin registry at
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
/// directories are created if missing.
pub fn open(content_dir: PathBuf, pins_path: PathBuf, max_bytes: u64) -> Result<Self> {
std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?;
if let Some(parent) = pins_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(Self { content_dir, thumb_dir, pins_path, max_bytes })
}
/// Filesystem-safe, fixed-length cache key for a uid display string: the
/// SHA-256 of `s`, hex-encoded (64 chars). A plain hex of the uid would
/// *double* its length, and Proton's `volume~link` ids are long enough
/// (each half can be 60+ chars) that the doubled key overflows `NAME_MAX`
/// (255) — `store` then fails with ENAMETOOLONG. Hashing bounds the key at
/// 64 chars; reversibility is not needed and collisions are negligible.
fn key_str(s: &str) -> String {
let digest = Sha256::digest(s.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
out.push(char::from_digit((b >> 4) as u32, 16).unwrap());
out.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
out
}
/// Filesystem-safe cache key for `uid`.
fn key(uid: &NodeUid) -> String {
Self::key_str(&uid.to_string())
}
fn blob_path(&self, uid: &NodeUid) -> PathBuf {
self.content_dir.join(Self::key(uid))
}
fn meta_path(&self, uid: &NodeUid) -> PathBuf {
self.content_dir.join(format!("{}.meta", Self::key(uid)))
}
/// The validated blob path for `uid`, or `None` if absent or stale against
/// `(mtime, size)`.
fn valid_blob(&self, uid: &NodeUid, mtime: i64, size: u64) -> Option<PathBuf> {
let want = Meta { mtime, size };
let meta: Meta = serde_json::from_slice(&std::fs::read(self.meta_path(uid)).ok()?).ok()?;
if meta != want {
return None;
}
let blob = self.blob_path(uid);
// Guard against a torn write: the blob must match the recorded size.
match std::fs::metadata(&blob) {
Ok(m) if m.len() == size => Some(blob),
_ => None,
}
}
/// Whether a fresh cached blob exists for `uid`.
pub fn is_cached(&self, uid: &NodeUid, mtime: i64, size: u64) -> bool {
self.valid_blob(uid, mtime, size).is_some()
}
/// Serve `len` bytes from `offset` out of the cached blob, or `None` on a
/// miss/stale entry. Reads only the requested window off disk.
pub fn read_range(
&self,
uid: &NodeUid,
mtime: i64,
size: u64,
offset: u64,
len: u64,
) -> Option<Vec<u8>> {
let blob = self.valid_blob(uid, mtime, size)?;
// Record the access for LRU: bump the blob's mtime to now. Best effort —
// a failed touch only makes eviction order slightly less accurate.
let mut f = std::fs::File::open(&blob).ok()?;
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
if offset >= size {
return Some(Vec::new());
}
let end = offset.saturating_add(len).min(size);
let want = (end - offset) as usize;
f.seek(SeekFrom::Start(offset)).ok()?;
let mut buf = vec![0u8; want];
f.read_exact(&mut buf).ok()?;
Some(buf)
}
/// Store `bytes` as the cached content for `uid`, tagged with `(mtime,
/// size)`. The blob is written to a temp file then renamed so a concurrent
/// reader never sees a partial blob; the meta tag is written last so a
/// crash mid-store fails validation rather than serving truncated data.
pub fn store(&self, uid: &NodeUid, mtime: i64, size: u64, bytes: &[u8]) -> Result<()> {
let blob = self.blob_path(uid);
let tmp = blob.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &blob)?;
let meta = serde_json::to_vec(&Meta { mtime, size })?;
std::fs::write(self.meta_path(uid), meta)?;
self.enforce_budget();
Ok(())
}
/// Remove any cached blob + meta for `uid`, including its thumbnails (best
/// effort; absence is fine). Thumbnails are tied to the revision, so a
/// content eviction must drop them too.
pub fn evict(&self, uid: &NodeUid) {
let _ = std::fs::remove_file(self.blob_path(uid));
let _ = std::fs::remove_file(self.meta_path(uid));
for ttype in [1, 2] {
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
}
}
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
self.thumb_dir.join(format!("{}.t{ttype}", Self::key(uid)))
}
fn thumb_meta(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
self.thumb_dir.join(format!("{}.t{ttype}.meta", Self::key(uid)))
}
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
/// Validated by node `mtime` alone: a new revision (which is what changes the
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
/// are small, so the whole blob is read at once.
pub fn read_thumbnail(&self, uid: &NodeUid, ttype: i32, mtime: i64) -> Option<Vec<u8>> {
let recorded: i64 =
serde_json::from_slice(&std::fs::read(self.thumb_meta(uid, ttype)).ok()?).ok()?;
if recorded != mtime {
return None;
}
std::fs::read(self.thumb_blob(uid, ttype)).ok()
}
/// On-disk path of the cached content blob for `uid`, validated against
/// `(mtime, size)`; `None` on miss/stale. Lets a front-end open the blob
/// directly instead of streaming its bytes back over the control socket.
pub fn cached_content_path(&self, uid: &NodeUid, mtime: i64, size: u64) -> Option<PathBuf> {
self.valid_blob(uid, mtime, size)
}
/// On-disk path where `uid`'s content blob lives once stored (the file may
/// not exist yet, or may be stale — pair with [`store`](Self::store)).
pub fn content_path(&self, uid: &NodeUid) -> PathBuf {
self.blob_path(uid)
}
/// On-disk path of the cached `ttype` thumbnail for `uid`, fresh against
/// `tag` (the validity tag last passed to [`store_thumbnail`](Self::store_thumbnail));
/// `None` on miss/stale. For Drive files the tag is the node mtime; for
/// photos it is the capture time.
pub fn cached_thumbnail_path(&self, uid: &NodeUid, ttype: i32, tag: i64) -> Option<PathBuf> {
let recorded: i64 =
serde_json::from_slice(&std::fs::read(self.thumb_meta(uid, ttype)).ok()?).ok()?;
if recorded != tag {
return None;
}
let blob = self.thumb_blob(uid, ttype);
blob.exists().then_some(blob)
}
/// On-disk path where `uid`'s `ttype` thumbnail lives once stored.
pub fn thumbnail_path(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
self.thumb_blob(uid, ttype)
}
/// Cache `bytes` as the `ttype` thumbnail for `uid`, tagged with `mtime`.
/// Blob written to a temp file then renamed; the meta tag is written last so
/// a crash mid-store fails validation rather than serving a torn thumbnail.
pub fn store_thumbnail(
&self,
uid: &NodeUid,
ttype: i32,
mtime: i64,
bytes: &[u8],
) -> Result<()> {
let blob = self.thumb_blob(uid, ttype);
let tmp = blob.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &blob)?;
std::fs::write(self.thumb_meta(uid, ttype), serde_json::to_vec(&mtime)?)?;
Ok(())
}
/// Evict least-recently-used *unpinned* blobs until total blob bytes fit the
/// configured `max_bytes` cap. No-op when the cap is disabled (`0`) or the
/// cache already fits. Pinned blobs are skipped, so a cache held entirely by
/// pins can legitimately stay over budget.
fn enforce_budget(&self) {
if self.max_bytes == 0 {
return;
}
// Pinned blobs (by cache key) are exempt from eviction.
let pinned: HashSet<String> =
self.load_pins().pins.keys().map(|uid| Self::key_str(uid)).collect();
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record
// size + last-access (mtime) for each.
let mut total: u64 = 0;
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.content_dir) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
total += meta.len();
if pinned.contains(name) {
continue; // counts toward total but is never a victim
}
let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
blobs.push((path, meta.len(), atime));
}
if total <= self.max_bytes {
return;
}
// Oldest access first.
blobs.sort_by_key(|(_, _, atime)| *atime);
for (path, len, _) in blobs {
if total <= self.max_bytes {
break;
}
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("meta"));
total = total.saturating_sub(len);
}
}
fn load_pins(&self) -> PinFile {
std::fs::read(&self.pins_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save_pins(&self, pins: &PinFile) -> Result<()> {
std::fs::write(&self.pins_path, serde_json::to_vec_pretty(pins)?)?;
Ok(())
}
/// Whether `uid` is pinned (independent of whether its blob is currently
/// cached).
pub fn is_pinned(&self, uid: &NodeUid) -> bool {
self.load_pins().pins.contains_key(&uid.to_string())
}
/// Record `uid` as pinned under `path`. The caller is responsible for having
/// already cached the content via [`store`](Self::store).
pub fn add_pin(&self, uid: &NodeUid, path: &Path) -> Result<()> {
let mut file = self.load_pins();
let key = uid.to_string();
file.pins.insert(
key.clone(),
Pin { uid: key, path: path.display().to_string() },
);
self.save_pins(&file)
}
/// Drop `uid` from the pin registry and evict its blob. No-op if not pinned.
pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> {
let mut file = self.load_pins();
if file.pins.remove(&uid.to_string()).is_some() {
self.save_pins(&file)?;
}
self.evict(uid);
Ok(())
}
/// All pinned files, ordered by uid.
pub fn list_pins(&self) -> Vec<Pin> {
self.load_pins().pins.into_values().collect()
}
/// Total bytes of cached content blobs (pinned + unpinned), matching what
/// [`enforce_budget`](Self::enforce_budget) weighs against the cap.
/// Thumbnails live in a sibling dir and are not counted. Cheap directory
/// scan, safe to call from a front-end for a usage read-out.
pub fn usage(&self) -> u64 {
let Ok(rd) = std::fs::read_dir(&self.content_dir) else {
return 0;
};
let mut total = 0u64;
for entry in rd.flatten() {
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
if let Ok(meta) = entry.metadata()
&& meta.is_file()
{
total += meta.len();
}
}
total
}
/// Configured soft byte cap (`0` = unlimited), for display alongside
/// [`usage`](Self::usage).
pub fn budget(&self) -> u64 {
self.max_bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
/// A unique temp directory removed on drop; avoids a dev-dependency.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-cache-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cache() -> (ContentCache, TempDir) {
cache_capped(0)
}
fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) {
let dir = TempDir::new();
let c = ContentCache::open(
dir.path().join("content"),
dir.path().join("pins.json"),
max_bytes,
)
.unwrap();
(c, dir)
}
#[test]
fn store_then_read_range() {
let (c, _d) = cache();
let u = uid("a");
let data = b"hello world";
c.store(&u, 100, data.len() as u64, data).unwrap();
assert!(c.is_cached(&u, 100, data.len() as u64));
assert_eq!(
c.read_range(&u, 100, data.len() as u64, 6, 5).unwrap(),
b"world"
);
// Offset past EOF yields an empty slice, not a miss.
assert_eq!(
c.read_range(&u, 100, data.len() as u64, 100, 5).unwrap(),
b""
);
}
#[test]
fn stale_metadata_is_a_miss() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 100, 3, b"abc").unwrap();
// A newer mtime (new revision) invalidates the cached blob.
assert!(!c.is_cached(&u, 101, 3));
assert!(c.read_range(&u, 101, 3, 0, 3).is_none());
// A size mismatch also invalidates it.
assert!(c.read_range(&u, 100, 4, 0, 3).is_none());
}
#[test]
fn pin_lifecycle_evicts_on_unpin() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 100, 3, b"abc").unwrap();
c.add_pin(&u, Path::new("docs/a.txt")).unwrap();
assert!(c.is_pinned(&u));
assert_eq!(c.list_pins().len(), 1);
assert_eq!(c.list_pins()[0].path, "docs/a.txt");
c.remove_pin(&u).unwrap();
assert!(!c.is_pinned(&u));
assert!(c.list_pins().is_empty());
// Unpin evicts the cached content.
assert!(!c.is_cached(&u, 100, 3));
}
#[test]
fn budget_evicts_lru_unpinned() {
// Cap fits two 4-byte blobs but not three.
let (c, _d) = cache_capped(8);
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap();
// Distinct mtimes so LRU order is deterministic (oldest = a).
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap();
// Touch `a` so `b` becomes the least-recently-used.
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(c.read_range(&a, 1, 4, 0, 4).is_some());
// Third store pushes over budget → evicts LRU unpinned (`b`).
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&d, 1, 4, b"dddd").unwrap();
assert!(c.is_cached(&a, 1, 4), "recently-read blob survives");
assert!(!c.is_cached(&b, 1, 4), "LRU blob evicted");
assert!(c.is_cached(&d, 1, 4), "newest blob survives");
}
#[test]
fn budget_never_evicts_pinned() {
let (c, _d) = cache_capped(8);
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap();
c.add_pin(&a, Path::new("a")).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap();
// Over budget: `a` is pinned (oldest) so `b` must be the victim.
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&d, 1, 4, b"dddd").unwrap();
assert!(c.is_cached(&a, 1, 4), "pinned blob never evicted");
assert!(!c.is_cached(&b, 1, 4), "unpinned LRU blob evicted");
assert!(c.is_cached(&d, 1, 4));
}
#[test]
fn thumbnail_store_read_and_evict() {
let (c, _d) = cache();
let u = uid("a");
c.store_thumbnail(&u, 1, 100, b"thumb1").unwrap();
c.store_thumbnail(&u, 2, 100, b"preview2").unwrap();
assert_eq!(c.read_thumbnail(&u, 1, 100).unwrap(), b"thumb1");
assert_eq!(c.read_thumbnail(&u, 2, 100).unwrap(), b"preview2");
// A newer revision (mtime bump) invalidates the cached thumbnail.
assert!(c.read_thumbnail(&u, 1, 101).is_none());
// Content eviction drops thumbnails too.
c.evict(&u);
assert!(c.read_thumbnail(&u, 1, 100).is_none());
assert!(c.read_thumbnail(&u, 2, 100).is_none());
}
#[test]
fn evict_removes_blob() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 1, 3, b"abc").unwrap();
c.evict(&u);
assert!(!c.is_cached(&u, 1, 3));
}
}
+19
View File
@@ -14,6 +14,10 @@ pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
/// Keyring service name; one entry per credential kind keyed by username.
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
/// Default soft cap on the on-disk content cache (5 GiB). LRU-evicts unpinned
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
/// Resolved XDG locations for state, cache, and the default mountpoint.
pub struct AppDirs {
dirs: ProjectDirs,
@@ -40,6 +44,21 @@ impl AppDirs {
self.dirs.cache_dir().to_path_buf()
}
/// Directory holding cached file-content blobs (pinned files).
pub fn content_cache_dir(&self) -> PathBuf {
self.cache_dir().join("content")
}
/// JSON pin registry, in persistent state (not the evictable cache).
pub fn pins_path(&self) -> PathBuf {
self.state_dir().join("pins.json")
}
/// Unix domain socket the mount daemon listens on for CLI control commands.
pub fn control_socket(&self) -> PathBuf {
self.state_dir().join("control.sock")
}
/// Default mountpoint when the user does not pass one explicitly.
pub fn default_mountpoint(&self) -> PathBuf {
directories::UserDirs::new()
+125
View File
@@ -0,0 +1,125 @@
//! IPC protocol between the CLI and a running mount daemon.
//!
//! The daemon listens on a Unix domain socket under the state dir; the CLI
//! (`pdfs pin` / `unpin` / `status`) connects, sends one [`Request`] as a single
//! JSON line, and reads one JSON-line [`Response`]. Keeping the wire format
//! line-delimited JSON makes the socket trivially scriptable.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::cache::Pin;
use crate::error::Result;
/// Cap on the *write* half of a round-trip. A crashed daemon can leave its
/// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect`
/// then succeeds but no one ever replies. A short write bound trips fast when
/// the daemon is wedged before it has read anything.
const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
/// Cap on the *read* half. Some requests make the daemon do real work before it
/// replies — `PhotosTimeline` downloads a page of thumbnails, `OpenFile`
/// downloads whole-file content — which easily takes more than a couple of
/// seconds. A 2s read bound timed those out and the GUI mistook the timeout for
/// "no daemon" (showing "Mount Proton Drive…" on a live mount). Give reads a
/// generous bound that still protects against a daemon that accepts but never
/// answers.
const READ_TIMEOUT: Duration = Duration::from_secs(120);
/// A command sent from the CLI to the daemon.
#[derive(Serialize, Deserialize, Debug)]
pub enum Request {
/// Report mount status (account, mountpoint, pin count).
Status,
/// Pin a file (path relative to the mountpoint, or absolute under it).
Pin { path: String },
/// Unpin a file, evicting its cached content.
Unpin { path: String },
/// List pinned files.
ListPins,
/// List a directory for the in-app file browser. `path` is
/// mountpoint-relative (empty or "." = the mount root).
ListDir { path: String },
/// Fetch a page of the photos timeline, newest first. Thumbnails for the
/// page are fetched into the cache and their on-disk paths returned.
PhotosTimeline { offset: usize, limit: usize },
/// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String },
/// Download a Drive file's full content into the cache; replies with the
/// on-disk path so the front-end can open it with the default app. `path`
/// is mountpoint-relative.
OpenFile { path: String },
}
/// One entry in a [`Request::ListDir`] listing.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DirEntry {
/// Decrypted node name.
pub name: String,
pub is_dir: bool,
/// Plaintext size in bytes (0 for folders).
pub size: u64,
/// Modification time, epoch seconds.
pub modified: i64,
/// Whether the file is pinned to this device.
pub pinned: bool,
/// Node uid in `volume~link` form, for follow-up requests.
pub uid: String,
}
/// One photo in a [`Request::PhotosTimeline`] page.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoItem {
/// Node uid in `volume~link` form.
pub uid: String,
/// Capture time, epoch seconds (the timeline is newest-first).
pub capture_time: i64,
/// On-disk path to the cached thumbnail, if one was available/fetched.
pub thumb_path: Option<String>,
}
/// The daemon's reply to a [`Request`].
#[derive(Serialize, Deserialize, Debug)]
pub enum Response {
/// Current mount status.
Status {
username: String,
mountpoint: String,
pinned: usize,
},
/// A human-readable success message.
Ok { message: String },
/// The pin registry.
Pins { pins: Vec<Pin> },
/// A directory listing (reply to [`Request::ListDir`]).
Entries { entries: Vec<DirEntry> },
/// A page of the photos timeline. `available` is false when the account
/// has no photos volume.
Photos { available: bool, items: Vec<PhotoItem> },
/// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String },
/// The request failed.
Error { message: String },
}
/// Send one [`Request`] to the daemon listening on `socket` and read its
/// [`Response`]. Errors (e.g. [`Error::Io`]) if no daemon is listening.
///
/// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(READ_TIMEOUT))?;
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
let mut line = serde_json::to_vec(req)?;
line.push(b'\n');
stream.write_all(&line)?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut resp = String::new();
reader.read_line(&mut resp)?;
Ok(serde_json::from_str(resp.trim())?)
}
+2
View File
@@ -3,7 +3,9 @@
//! front-end (CLI today, daemon + GUI later) shares.
pub mod auth;
pub mod cache;
pub mod config;
pub mod control;
pub mod error;
pub use error::{Error, Result};
+1
View File
@@ -14,3 +14,4 @@ tokio.workspace = true
tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "pdfs-gui"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
rust-version.workspace = true
[[bin]]
name = "pdfs-tray"
path = "src/main.rs"
[[bin]]
name = "pdfs-app"
path = "src/app.rs"
[dependencies]
pdfs-core.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
tokio.workspace = true
ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
async-channel = "2"
File diff suppressed because it is too large. Load diff
+205
View File
@@ -0,0 +1,205 @@
//! `pdfs-tray` — system-tray front-end for the Proton Drive Linux client.
//!
//! Slice 1 of the GUI phase: a StatusNotifierItem (SNI) tray icon with a menu
//! showing login/mount status and offering mount, unmount, open-folder and quit.
//! It talks to the running mount daemon over the same control socket the CLI
//! uses, so the tray is a thin presentation layer over [`pdfs_core::control`].
//!
//! GTK4 has no tray widget (GNOME dropped the systray), so the tray itself is an
//! SNI item via the `ksni` crate; the GTK4/libadwaita settings window arrives in
//! a later slice.
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result};
use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, send};
/// How often the tray re-polls the daemon to refresh its menu.
const POLL_INTERVAL: Duration = Duration::from_secs(3);
/// Snapshot of what the tray knows about the daemon, recomputed each poll.
#[derive(Clone)]
struct DriveState {
/// Human-readable first menu line ("Mounted at …", "Not running", …).
line: String,
/// Whether a mount daemon is currently serving the control socket.
mounted: bool,
/// Mountpoint to act on (from the daemon when mounted, else the default).
mountpoint: PathBuf,
}
struct DriveTray {
state: DriveState,
}
/// Ask the daemon for its status, falling back to the stored session so the
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(Response::Status { mountpoint, pinned, .. }) => DriveState {
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
mounted: true,
mountpoint: PathBuf::from(mountpoint),
},
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => DriveState {
line: "Mount: unexpected daemon response".into(),
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
},
// No daemon: describe login state instead so the menu is still useful.
Err(_) => {
let line = match auth::load() {
Ok(s) => format!("Logged in as {} — not mounted", s.username),
Err(pdfs_core::Error::NotLoggedIn) => "Not logged in".into(),
Err(e) => format!("Error: {e}"),
};
DriveState {
line,
mounted: false,
mountpoint: default_mountpoint.to_path_buf(),
}
}
}
}
/// Launch `pdfs mount` detached so the tray keeps running independently.
fn spawn_mount() {
match Command::new("pdfs").arg("mount").spawn() {
Ok(_) => tracing::info!("spawned `pdfs mount`"),
Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"),
}
}
/// Unmount the FUSE filesystem. `fusermount3 -u` is the FUSE-native teardown;
/// fall back to `fusermount` on older systems.
fn unmount(mountpoint: &Path) {
let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status();
let result = try_unmount("fusermount3").or_else(|_| try_unmount("fusermount"));
match result {
Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()),
Ok(s) => tracing::error!("unmount exited with {s}"),
Err(e) => tracing::error!("failed to run fusermount: {e}"),
}
}
fn open_folder(mountpoint: &Path) {
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
}
}
impl Tray for DriveTray {
fn id(&self) -> String {
"io.narl.proton-drive-linux".into()
}
fn title(&self) -> String {
"Proton Drive".into()
}
fn icon_name(&self) -> String {
"folder-remote".into()
}
fn menu(&self) -> Vec<MenuItem<Self>> {
// Values captured into the action closures (which only get `&mut Self`).
let mountpoint = self.state.mountpoint.clone();
let open_mp = mountpoint.clone();
let mut items: Vec<MenuItem<Self>> = vec![
StandardItem {
label: self.state.line.clone(),
enabled: false,
..Default::default()
}
.into(),
MenuItem::Separator,
];
if self.state.mounted {
items.push(
StandardItem {
label: "Open folder".into(),
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
..Default::default()
}
.into(),
);
items.push(
StandardItem {
label: "Unmount".into(),
activate: Box::new(move |this: &mut Self| {
unmount(&mountpoint);
this.state.mounted = false;
this.state.line = "Unmounting…".into();
}),
..Default::default()
}
.into(),
);
} else {
items.push(
StandardItem {
label: "Mount".into(),
activate: Box::new(|this: &mut Self| {
spawn_mount();
this.state.line = "Mounting…".into();
}),
..Default::default()
}
.into(),
);
}
items.push(MenuItem::Separator);
items.push(
StandardItem {
label: "Quit".into(),
activate: Box::new(|_: &mut Self| std::process::exit(0)),
..Default::default()
}
.into(),
);
items
}
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
let dirs = AppDirs::new().context("resolve app dirs")?;
let socket = dirs.control_socket();
let default_mountpoint = dirs.default_mountpoint();
let tray = DriveTray {
state: poll_state(&socket, &default_mountpoint),
};
let service = TrayService::new(tray);
let handle = service.handle();
std::thread::spawn(move || {
if let Err(e) = service.run() {
tracing::error!("tray service stopped: {e:?}");
}
});
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
loop {
std::thread::sleep(POLL_INTERVAL);
let st = poll_state(&socket, &default_mountpoint);
handle.update(move |t: &mut DriveTray| t.state = st);
}
}