updated photos gallery

This commit is contained in:
nvrl committed 2026-07-14 12:40:33 +02:00
1 parent 6c67226ab0
commit 30039c43d7
11 files changed
+2390 -521

No files matched your search

Generated
+3 -2
View File
@@ -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",
+16 -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;
@@ -415,8 +416,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);
}
}
+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");
}
}
});
}
+22 -3
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.
@@ -187,10 +193,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 {
@@ -220,6 +237,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`]).
+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(())
}
+108 -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> {
@@ -2475,6 +2517,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 {
@@ -2646,13 +2694,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"] }
+2068 -454
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')