Compare commits

...
2 Commits
Author SHA1 Message Date
nvrl c52d829a43 added trash 2026-07-14 14:55:54 +02:00
nvrl 30039c43d7 updated photos gallery 2026-07-14 12:40:33 +02:00
12 changed files with 3068 additions and 530 deletions

No files matched your search

Generated
+7 -6
View File
@@ -2898,7 +2898,7 @@ dependencies = [
[[package]]
name = "pdfs-cli"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"anyhow",
"clap 4.6.1",
@@ -2913,7 +2913,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"directories",
"ignore",
@@ -2931,7 +2931,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"fuser",
"libc",
@@ -2944,7 +2944,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "0.3.0"
version = "0.4.0"
dependencies = [
"anyhow",
"async-channel",
@@ -2953,6 +2953,7 @@ dependencies = [
"ksni",
"libadwaita",
"pdfs-core",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
@@ -3161,7 +3162,7 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.1.4"
source = "git+https://github.com/narrrl/proton-sdk-rs?branch=main#2e3c23803eecbdcb9f442c1e49229347f272596b"
source = "git+https://github.com/narrrl/proton-sdk-rs?branch=main#a24785d1a911b539d1d148d712691c5a89d561b5"
dependencies = [
"async-trait",
"base64",
@@ -3185,7 +3186,7 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.1.4"
source = "git+https://github.com/narrrl/proton-sdk-rs?branch=main#2e3c23803eecbdcb9f442c1e49229347f272596b"
source = "git+https://github.com/narrrl/proton-sdk-rs?branch=main#a24785d1a911b539d1d148d712691c5a89d561b5"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
+98 -1
View File
@@ -1,5 +1,6 @@
//! `pdfs` — command-line front-end for the Proton Drive Linux client.
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -121,6 +122,23 @@ enum Command {
},
/// Show the daemon's in-flight transfers (active uploads/downloads).
Transfers,
/// List what is in the account's trash, with the uids the restore and
/// delete-forever commands take.
Trash,
/// Restore trashed items, by uid, to the folders they were trashed from.
Restore {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
uids: Vec<String>,
},
/// Permanently delete trashed items by uid. This cannot be undone.
DeleteForever {
/// Node uids (`volume~link`), as printed by `pdfs trash`.
#[arg(required = true)]
uids: Vec<String>,
},
/// Permanently delete everything in the trash. This cannot be undone.
EmptyTrash,
}
fn main() -> Result<()> {
@@ -154,6 +172,10 @@ fn main() -> Result<()> {
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
Command::Upload { file, parent } => cmd_upload(file, parent),
Command::Transfers => cmd_transfers(),
Command::Trash => cmd_trash(),
Command::Restore { uids } => cmd_restore(uids),
Command::DeleteForever { uids } => cmd_delete_forever(uids),
Command::EmptyTrash => cmd_empty_trash(),
}
}
@@ -415,8 +437,22 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
}
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
CtlResponse::Photos { items, .. } => {
// The timeline reply only carries thumbnails already in the cache, so
// pull the rest of the page's in one batch to print a path per photo.
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
let thumbs: HashMap<String, Option<String>> =
match control_request(CtlRequest::PhotoThumbs { uids })? {
CtlResponse::Thumbs { items } => {
items.into_iter().map(|t| (t.uid, t.path)).collect()
}
_ => HashMap::new(),
};
for p in items {
let thumb = p.thumb_path.as_deref().unwrap_or("(no thumbnail)");
let thumb = thumbs
.get(&p.uid)
.and_then(|p| p.as_deref())
.or(p.thumb_path.as_deref())
.unwrap_or("(no thumbnail)");
println!("{} {} {thumb}", p.capture_time, p.uid);
}
}
@@ -486,6 +522,67 @@ fn cmd_rm(path: PathBuf) -> Result<()> {
Ok(())
}
fn cmd_trash() -> Result<()> {
match control_request(CtlRequest::ListTrash)? {
CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"),
CtlResponse::Entries { entries } => {
for e in entries {
let kind = if e.is_dir { "d" } else { "-" };
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_restore(uids: Vec<String>) -> Result<()> {
match control_request(CtlRequest::Restore { uids })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_delete_forever(uids: Vec<String>) -> Result<()> {
confirm(&format!(
"Permanently delete {} item(s)? This cannot be undone.",
uids.len()
))?;
match control_request(CtlRequest::DeleteForever { uids })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_empty_trash() -> Result<()> {
confirm("Permanently delete everything in the trash? This cannot be undone.")?;
match control_request(CtlRequest::EmptyTrash)? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// Ask for a typed `yes` before an irreversible destroy. Errors (aborting the
/// command) on anything else, including a non-interactive stdin: a piped or
/// scripted invocation must not silently wipe the trash.
fn confirm(prompt: &str) -> Result<()> {
print!("{prompt} [type \"yes\" to confirm] ");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if answer.trim() != "yes" {
bail!("aborted");
}
Ok(())
}
fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
match control_request(CtlRequest::CreateFolder {
parent: path_arg(&parent)?,
+67 -16
View File
@@ -7,7 +7,7 @@
//! path, so no fresh 2FA is required until the refresh token itself expires.
use keyring::Entry;
use proton_drive_rs::ProtonDriveClient;
use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
@@ -32,6 +32,20 @@ pub struct StoredSession {
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
pub mailbox_password: String,
/// The account's key salts, captured at login.
///
/// `core/v4/keys/salts` requires the `locked` scope, which only the access
/// token minted by a password login carries: once that token has been
/// rotated through `auth/v4/refresh` (as it is on every daemon start after
/// the first), the endpoint answers 403 and the key chain can no longer be
/// unlocked. Salts only change when the password changes, so store them
/// once and seed the Drive client with them on resume.
///
/// Empty for blobs written before this field existed; [`resume_client`]
/// then falls back to fetching (and, if the token is still scoped for it,
/// backfilling) them.
#[serde(default)]
pub key_salts: Vec<KeySalt>,
}
impl StoredSession {
@@ -84,14 +98,24 @@ pub async fn login(
session.apply_second_factor_code(code.trim()).await?;
}
register_refresh_handler(&session, password.to_owned());
// Grab the key salts while this access token still has the `locked` scope:
// after its first refresh it never will again, and without them no later
// resume can unlock the key chain. See `StoredSession::key_salts`.
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
let key_salts = client.account().key_salts().await?;
save(&session, password).await?;
register_refresh_handler(&session, password.to_owned(), key_salts.clone());
save(&session, password, key_salts).await?;
Ok(())
}
/// Persist the session's current tokens + mailbox password to the keyring.
pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<()> {
/// Persist the session's current tokens, mailbox password and key salts.
pub async fn save(
session: &ProtonApiSession,
mailbox_password: &str,
key_salts: Vec<KeySalt>,
) -> Result<()> {
let tokens = session.current_tokens().await;
let stored = StoredSession {
session_id: session.session_id().as_str().to_owned(),
@@ -105,6 +129,7 @@ pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<
PasswordMode::Dual => 2,
},
mailbox_password: mailbox_password.to_owned(),
key_salts,
};
let json = serde_json::to_string(&stored)?;
keyring_entry()?.set_password(&json)?;
@@ -139,10 +164,30 @@ pub fn logout() -> Result<()> {
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
let stored = load()?;
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
let password = stored.mailbox_password.clone();
register_refresh_handler(&session, stored.mailbox_password.clone());
// Blobs written before key salts were persisted have none: fetch them once
// more and write them back, which only works while the stored access token
// still carries the `locked` scope. If it doesn't, the account must be
// logged into again — no refresh can restore that scope.
let key_salts = if stored.key_salts.is_empty() {
register_refresh_handler(&session, password.clone(), Vec::new());
let probe = ProtonDriveClient::new(&session, password.clone().into_bytes());
let salts = probe.account().key_salts().await.map_err(|e| match &e {
proton_sdk::error::ProtonError::Api(api) if api.is_insufficient_scope() => {
Error::ReloginRequired
}
_ => e.into(),
})?;
save(&session, &password, salts.clone()).await?;
salts
} else {
stored.key_salts
};
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
register_refresh_handler(&session, password.clone(), key_salts.clone());
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
Ok((client, session))
}
@@ -153,11 +198,15 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
/// 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
let stored = load()?;
save(session, &stored.mailbox_password, stored.key_salts).await
}
fn register_refresh_handler(session: &ProtonApiSession, mailbox_password: String) {
fn register_refresh_handler(
session: &ProtonApiSession,
mailbox_password: String,
key_salts: Vec<KeySalt>,
) {
let session_id = session.session_id().as_str().to_owned();
let username = session.username().to_owned();
let user_id = session.user_id().as_str().to_owned();
@@ -177,14 +226,16 @@ fn register_refresh_handler(session: &ProtonApiSession, mailbox_password: String
scopes: scopes.clone(),
password_mode,
mailbox_password: mailbox_password.clone(),
key_salts: key_salts.clone(),
};
if let Ok(json) = serde_json::to_string(&stored)
&& let Ok(entry) = keyring_entry() {
if let Err(e) = entry.set_password(&json) {
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
} else {
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
}
&& let Ok(entry) = keyring_entry()
{
if let Err(e) = entry.set_password(&json) {
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
} else {
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
}
}
});
}
+65 -6
View File
@@ -44,9 +44,15 @@ pub enum Request {
/// 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.
/// Fetch a page of the photos timeline, newest first. Returns metadata only:
/// a thumbnail path comes back only for photos already in the cache, so the
/// reply never waits on the network. Front-ends ask for the thumbnails they
/// actually display with [`Request::PhotoThumbs`].
PhotosTimeline { offset: usize, limit: usize },
/// Fetch thumbnails for the given photo uids, downloading the ones not
/// already cached (one batched round-trip) and replying with their on-disk
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
PhotoThumbs { uids: Vec<String> },
/// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String },
/// Upload a photo with the given name, media type, and content bytes.
@@ -97,6 +103,20 @@ pub enum Request {
/// Replies with [`Response::Transfers`]. Cheap to poll: the daemon keeps the
/// registry in memory, so a front-end can render a live progress widget.
GetQueueStatus,
/// List what is in the account's trash. Replies with [`Response::Entries`];
/// a trashed node has no path inside the mount, so each entry carries only
/// its `uid` — the handle for [`Request::Restore`] and
/// [`Request::DeleteForever`] — and its `path` is empty.
ListTrash,
/// Restore trashed nodes, by uid, to the folders they were trashed from.
/// Replies with [`Response::Ok`].
Restore { uids: Vec<String> },
/// Permanently delete trashed nodes by uid. Irreversible: the content is
/// gone from Proton Drive, not moved. Replies with [`Response::Ok`].
DeleteForever { uids: Vec<String> },
/// Permanently delete everything in the trash. Irreversible.
/// Replies with [`Response::Ok`].
EmptyTrash,
}
/// Which way an active transfer is moving, in a [`TransferItem`].
@@ -144,8 +164,10 @@ pub struct DirEntry {
pub uid: String,
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
/// (the entry lives in the requested directory, so the caller derives the
/// path by joining its name); populated when an entry can live anywhere in
/// the tree, as for search hits rendered through the browser.
/// path by joining its name) and for a [`Request::ListTrash`] listing (a
/// trashed node has no path inside the mount at all); populated when an entry
/// can live anywhere in the tree, as for search hits rendered through the
/// browser.
#[serde(default)]
pub path: String,
}
@@ -187,10 +209,21 @@ pub struct PhotoItem {
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.
/// On-disk path to the cached thumbnail, when one is *already* cached. A
/// `None` here means "not fetched yet", not "has no thumbnail" — ask for it
/// with [`Request::PhotoThumbs`].
pub thumb_path: Option<String>,
}
/// One thumbnail in a [`Response::Thumbs`] batch.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoThumb {
pub uid: String,
/// On-disk path, or `None` when the photo genuinely has no thumbnail (or the
/// fetch failed) — a front-end can then stop asking for it.
pub path: Option<String>,
}
/// The daemon's reply to a [`Request`].
#[derive(Serialize, Deserialize, Debug)]
pub enum Response {
@@ -212,7 +245,8 @@ pub enum Response {
Ok { message: String },
/// The pin registry.
Pins { pins: Vec<Pin> },
/// A directory listing (reply to [`Request::ListDir`]).
/// A directory listing (reply to [`Request::ListDir`]) or a trash listing
/// (reply to [`Request::ListTrash`]).
Entries { entries: Vec<DirEntry> },
/// A page of the photos timeline. `available` is false when the account
/// has no photos volume.
@@ -220,6 +254,8 @@ pub enum Response {
available: bool,
items: Vec<PhotoItem>,
},
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]).
@@ -290,4 +326,27 @@ mod tests {
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
/// The trash requests carry uids rather than paths; they must survive the same
/// line-delimited round-trip, since a mangled uid would restore or destroy the
/// wrong node.
#[test]
fn trash_requests_roundtrip() {
let reqs = [
Request::ListTrash,
Request::Restore {
uids: vec!["vol~link".into(), "vol~other".into()],
},
Request::DeleteForever {
uids: vec!["vol~link".into()],
},
Request::EmptyTrash,
];
for req in reqs {
let line = serde_json::to_string(&req).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let back: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
}
+6
View File
@@ -23,6 +23,12 @@ pub enum Error {
#[error("not logged in (no saved session)")]
NotLoggedIn,
/// The stored session can no longer unlock the account key chain: reading
/// the key salts needs the `locked` scope, which a refreshed access token
/// does not have. Only a fresh password login restores it.
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired,
#[error("{0}")]
Other(String),
}
+1
View File
@@ -10,6 +10,7 @@ pub mod db;
pub mod error;
pub mod localindex;
pub mod service;
pub mod shell;
pub use error::{Error, Result};
+97
View File
@@ -0,0 +1,97 @@
//! File-manager integration: right-click actions inside the mounted Drive.
//!
//! Nautilus (and Nemo/Caja, which read the same directories) runs any executable
//! in its scripts folder with the selected paths in the environment, and shows it
//! under a "Scripts" submenu. That gives us pin/unpin from the file manager
//! without shipping a Python extension or a GNOME-version-specific plugin.
//!
//! Installation is idempotent and best-effort: the scripts are (re)written on
//! every app start so an upgraded `pdfs` never leaves a stale script behind, and
//! any failure is logged rather than surfaced — a missing file manager is not an
//! error the user needs to act on.
use std::io;
use std::path::PathBuf;
/// Scripts shipped into the file manager: `(file name, `pdfs` subcommand)`.
const SCRIPTS: [(&str, &str); 2] = [
("Keep offline (Proton Drive)", "pin"),
("Remove offline copy (Proton Drive)", "unpin"),
];
/// The script body. Nautilus passes the selection as newline-separated paths in
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
/// selection when the script runs on a local (non-URI) folder, so we prefer the
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
fn script_body(subcommand: &str) -> String {
format!(
"#!/bin/sh\n\
# Installed by proton-drive-linux. Regenerated on every app start.\n\
set -eu\n\
\n\
paths=\"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
if [ -z \"$paths\" ]; then\n\
\x20 paths=\"${{NEMO_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
fi\n\
if [ -z \"$paths\" ]; then\n\
\x20 for arg in \"$@\"; do\n\
\x20 paths=\"$paths$arg\\n\"\n\
\x20 done\n\
fi\n\
\n\
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
\x20 [ -n \"$path\" ] || continue\n\
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
done\n"
)
}
/// The scripts directories to populate, for whichever file managers are present.
fn script_dirs() -> Vec<PathBuf> {
let Some(data) = dirs_data_home() else {
return Vec::new();
};
["nautilus/scripts", "nemo/scripts"]
.iter()
.map(|rel| data.join(rel))
.collect()
}
/// `$XDG_DATA_HOME`, or `~/.local/share`.
fn dirs_data_home() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
}
/// Install (or refresh) the pin/unpin scripts. Best-effort: logs and moves on.
pub fn install_file_manager_scripts() {
for dir in script_dirs() {
// Only populate a file manager the user actually has: creating
// `~/.local/share/nemo` on a GNOME box would be litter.
if !dir.parent().is_some_and(|p| p.exists()) {
continue;
}
if let Err(e) = write_scripts(&dir) {
tracing::warn!("couldn't install file-manager scripts in {dir:?}: {e}");
}
}
}
/// Write both scripts into `dir`, creating it if needed, and make them
/// executable — a script without the exec bit is silently ignored by Nautilus.
fn write_scripts(dir: &PathBuf) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
for (name, subcommand) in SCRIPTS {
let path = dir.join(name);
std::fs::write(&path, script_body(subcommand))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
}
}
tracing::debug!("installed file-manager scripts in {dir:?}");
Ok(())
}
+238 -44
View File
@@ -52,8 +52,8 @@ use fuser::{
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
DirEntry, LocalHit, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
TransferDirection,
DirEntry, LocalHit, PhotoItem, PhotoThumb, Request as CtlRequest, Response as CtlResponse,
SearchHit, TransferDirection,
};
use pdfs_core::db::{Db, StoredNode};
use pdfs_core::localindex;
@@ -92,6 +92,11 @@ const LOCAL_INDEX_CHECK: Duration = Duration::from_secs(60);
/// The FUSE root inode is always 1.
const ROOT_INO: u64 = 1;
/// Parent inode for a persisted node whose parent row is missing from the DB.
/// No folder carries this inode, so such a node is listed by nobody and stays
/// inert until a live enumeration re-parents it.
const ORPHAN_INO: u64 = 0;
/// Extended attribute exposing the small server-side thumbnail of a file.
const XATTR_THUMBNAIL: &str = "user.proton.thumbnail";
/// Extended attribute exposing the larger server-side preview image of a file.
@@ -367,21 +372,26 @@ impl Core {
if ino == ROOT_INO {
continue;
}
// A node whose parent row never made it to disk must not be adopted
// by the root: it would surface as a phantom top-level entry.
let parent = node
.parent_uid
.as_ref()
.and_then(|p| st.by_uid.get(p).copied())
.unwrap_or(ROOT_INO);
.unwrap_or(ORPHAN_INO);
let uid = node.uid.clone();
st.entries.insert(ino, Entry { uid, parent, node });
}
// Pass 3: rebuild child listings for fully-enumerated folders.
// Pass 3: rebuild child listings for fully-enumerated folders. The root
// is its own parent (that is what `..` resolves to), so it would match
// its own filter; a directory listed inside itself makes the kernel fail
// the lookup with EIO, taking the whole listing down with it.
for dir_ino in listed_dirs {
let kids: Vec<u64> = st
.entries
.iter()
.filter(|(_, e)| e.parent == dir_ino && !e.node.trashed)
.filter(|&(&ino, e)| ino != dir_ino && e.parent == dir_ino && !e.node.trashed)
.map(|(&ino, _)| ino)
.collect();
st.children.insert(dir_ino, kids);
@@ -415,7 +425,7 @@ impl Core {
}
let mut child_inos = Vec::with_capacity(nodes.len());
for node in nodes {
if node.trashed {
if node.trashed || node.uid == folder_uid {
continue;
}
child_inos.push(st.intern(ino, node));
@@ -449,7 +459,9 @@ impl Core {
}
let mut child_inos = Vec::with_capacity(nodes.len());
for node in nodes {
if node.trashed {
// A folder listed among its own children would alias its inode into
// its own listing, which the kernel rejects with EIO.
if node.trashed || node.uid == folder_uid {
continue;
}
child_inos.push(st.intern(ino, node));
@@ -1035,9 +1047,12 @@ impl Core {
.collect())
}
/// A page of the photos timeline (newest first), with the page's thumbnails
/// fetched into the cache and their paths attached. `Ok(None)` when the
/// account has no photos volume.
/// A page of the photos timeline (newest first). Metadata only: a thumbnail
/// path is attached for photos whose thumbnail is *already* cached, and no
/// thumbnail is downloaded here — the page comes back as fast as the cached
/// timeline can be sliced, and the front-end pulls the thumbnails it actually
/// paints via [`Core::photo_thumbs`]. `Ok(None)` when the account has no
/// photos volume.
///
/// The SDK's `enumerate_timeline` returns the *whole* timeline, so the first
/// request (or the first after [`TIMELINE_TTL`] elapses) fetches and caches
@@ -1083,37 +1098,6 @@ impl Core {
}
};
let ttype = ThumbnailType::Thumbnail.as_i32();
// Batch-fetch only the thumbnails not already cached fresh.
let want: Vec<NodeUid> = page
.iter()
.filter(|it| {
self.cache
.cached_thumbnail_path(&it.uid, ttype, it.capture_time)
.is_none()
})
.map(|it| it.uid.clone())
.collect();
if !want.is_empty() {
let cap: HashMap<NodeUid, i64> = page
.iter()
.map(|it| (it.uid.clone(), it.capture_time))
.collect();
match self.rt.block_on(
self.photos()
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
) {
Ok(thumbs) => {
for ft in thumbs {
if let (Ok(bytes), Some(&tag)) = (ft.result, cap.get(&ft.file_uid)) {
let _ = self.cache.store_thumbnail(&ft.file_uid, ttype, tag, &bytes);
}
}
}
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
}
}
Ok(Some(
page.into_iter()
.map(|it| {
@@ -1131,6 +1115,64 @@ impl Core {
))
}
/// Thumbnails for `uids`, served from the cache and downloaded (in one
/// batched SDK call) for whatever is missing. Requested on demand as tiles
/// scroll into view, so a cold timeline paints immediately and only the
/// photos actually on screen cost a round-trip.
///
/// The cache tags a thumbnail with its photo's capture time, which is what
/// the timeline carries — a uid absent from the cached timeline therefore
/// can't be validated and is skipped rather than guessed at.
fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
let ttype = ThumbnailType::Thumbnail.as_i32();
let tags: HashMap<NodeUid, i64> = {
let guard = self.timeline.lock().unwrap();
match guard.as_ref() {
Some(c) => c
.items
.iter()
.map(|it| (it.uid.clone(), it.capture_time))
.collect(),
None => HashMap::new(),
}
};
let want: Vec<NodeUid> = uids
.iter()
.filter(|uid| {
tags.get(*uid)
.is_some_and(|&tag| self.cache.cached_thumbnail_path(uid, ttype, tag).is_none())
})
.cloned()
.collect();
if !want.is_empty() {
match self.rt.block_on(
self.photos()
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
) {
Ok(thumbs) => {
for ft in thumbs {
if let (Ok(bytes), Some(&tag)) = (ft.result, tags.get(&ft.file_uid)) {
let _ = self.cache.store_thumbnail(&ft.file_uid, ttype, tag, &bytes);
}
}
}
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
}
}
uids.iter()
.map(|uid| PhotoThumb {
uid: uid.to_string(),
path: tags.get(uid).and_then(|&tag| {
self.cache
.cached_thumbnail_path(uid, ttype, tag)
.map(|p| p.display().to_string())
}),
})
.collect()
}
/// Download a photo's full content into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists).
fn open_photo(&self, uid: &NodeUid) -> Result<PathBuf, String> {
@@ -1261,6 +1303,114 @@ impl Core {
Ok(name)
}
/// List the account's trash. Trashed nodes are outside the mounted tree — the
/// FUSE side forgot them when they were trashed — so this always goes to the
/// server rather than to `State` or the DB, and each entry is identified by
/// its uid (its only remaining handle) with an empty path.
fn list_trash(&self) -> Result<Vec<DirEntry>, String> {
let uids = self
.rt
.block_on(self.client.enumerate_trash_node_uids())
.map_err(|e| format!("enumerate trash: {e}"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| format!("enumerate nodes: {e}"))?;
Ok(nodes
.into_iter()
.map(|node| DirEntry {
name: node.name.clone(),
is_dir: node.is_folder(),
size: node_size(&node),
modified: node.modification_time,
// A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither.
pinned: false,
cached: false,
uid: node.uid.to_string(),
path: String::new(),
})
.collect())
}
/// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole
/// batch if any is malformed — a partial trash mutation is worse than none.
fn parse_uids(uids: &[String]) -> Result<Vec<NodeUid>, String> {
if uids.is_empty() {
return Err("no nodes given".to_string());
}
uids.iter()
.map(|u| parse_uid(u).ok_or_else(|| format!("invalid uid: {u}")))
.collect()
}
/// Restore trashed nodes to the folders they were trashed from. The parents
/// are read *before* the restore — a restored node reappears in a listing the
/// daemon may already have cached, so each destination folder is invalidated
/// and re-enumerated on next access.
fn restore(&self, uids: &[String]) -> Result<usize, String> {
let parsed = Self::parse_uids(uids)?;
let parents: Vec<NodeUid> = self
.rt
.block_on(self.client.enumerate_nodes(&parsed))
.map_err(|e| format!("enumerate nodes: {e}"))?
.into_iter()
.filter_map(|n| n.parent_uid)
.collect();
self.rt
.block_on(self.client.restore_nodes(&parsed))
.map_err(|e| format!("restore: {e}"))?;
let mut st = self.state.lock().unwrap();
for parent in parents {
if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
}
}
Ok(parsed.len())
}
/// Permanently delete trashed nodes. Irreversible on the server; locally it
/// drops any metadata and cached content the node still owns.
fn delete_forever(&self, uids: &[String]) -> Result<usize, String> {
let parsed = Self::parse_uids(uids)?;
self.rt
.block_on(self.client.delete_nodes(&parsed))
.map_err(|e| format!("delete: {e}"))?;
self.drop_local(&parsed);
Ok(parsed.len())
}
/// Permanently delete everything in the trash. The uids are listed first so
/// the blobs of items trashed by *another* client — which this daemon may
/// still hold in its cache — are reclaimed too, not just the ones it trashed.
fn empty_trash(&self) -> Result<usize, String> {
let uids = self
.rt
.block_on(self.client.enumerate_trash_node_uids())
.map_err(|e| format!("enumerate trash: {e}"))?;
self.rt
.block_on(self.client.empty_trash())
.map_err(|e| format!("empty trash: {e}"))?;
self.drop_local(&uids);
Ok(uids.len())
}
/// Forget every trace of nodes that no longer exist anywhere: their inode and
/// DB row, and their cached content.
fn drop_local(&self, uids: &[NodeUid]) {
let mut st = self.state.lock().unwrap();
for uid in uids {
st.forget(uid);
}
drop(st);
for uid in uids {
self.cache.evict(uid);
}
}
/// Create a folder named `name` under the mountpoint-relative `parent_rel`.
/// Interns the new node directly so it shows up without a re-enumeration.
fn create_folder(&self, parent_rel: &Path, name: &str) -> Result<String, String> {
@@ -2475,6 +2625,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::Error { message: e },
}
}
Ok(CtlRequest::PhotoThumbs { uids }) => {
let parsed: Vec<NodeUid> = uids.iter().filter_map(|u| parse_uid(u)).collect();
CtlResponse::Thumbs {
items: core.photo_thumbs(&parsed),
}
}
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
Some(u) => match core.open_photo(&u) {
Ok(p) => CtlResponse::FilePath {
@@ -2598,6 +2754,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::ListTrash) => match core.list_trash() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) {
Ok(n) => CtlResponse::Ok {
message: format!("restored {n} item(s)"),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) {
Ok(n) => CtlResponse::Ok {
message: format!("permanently deleted {n} item(s)"),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
Ok(n) => CtlResponse::Ok {
message: format!("emptied trash ({n} item(s))"),
},
Err(e) => CtlResponse::Error { message: e },
},
Ok(CtlRequest::PurgeCache) => {
let freed = core.cache.clear_unpinned();
CtlResponse::Ok {
@@ -2646,13 +2824,29 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
let _ = stream.write_all(&out);
}
/// Listen on the control socket, serving one CLI command per connection. Runs on
/// its own thread; returns only if the listener itself fails.
/// Listen on the control socket, serving one command per connection, each on its
/// own thread. Runs on its own thread; returns only if the listener itself fails.
///
/// Concurrent rather than serial because requests differ wildly in cost: an
/// `OpenPhoto` downloads a whole photo, and while it ran the accept loop used to
/// stall every other caller behind it — the GUI's 2s status poll, and the
/// thumbnail batches the gallery needs to paint. [`Core`] is a bundle of handles
/// (`Arc`/`Clone`), so each connection gets its own copy of it.
fn run_control_socket(core: Core, username: String, mountpoint: PathBuf, listener: UnixListener) {
info!("control socket listening");
for conn in listener.incoming() {
match conn {
Ok(stream) => handle_control_conn(&core, &username, &mountpoint, stream),
Ok(stream) => {
let core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.clone();
if let Err(e) = std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || handle_control_conn(&core, &username, &mountpoint, stream))
{
warn!(error = %e, "control: spawn handler failed");
}
}
Err(e) => {
warn!(error = %e, "control: accept failed");
}
+1
View File
@@ -24,6 +24,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
tokio.workspace = true
serde_json.workspace = true
ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
+2486 -455
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=0.2.5
pkgver=0.3.0
pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64')