mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
updated photos gallery
This commit is contained in:
1 parent
6c67226ab0
commit
30039c43d7
11 files changed
+2376
-507
No files matched your search
Generated
+3
-2
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +226,11 @@ 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() {
|
||||
&& 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 {
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
+108
-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> {
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
+2059
-445
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