mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-05 00:57:00 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52d829a43 | ||
|
|
30039c43d7 |
No files matched your search
Generated
+7
-6
@@ -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
@@ -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>"]
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod db;
|
||||
pub mod error;
|
||||
pub mod localindex;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user