implemented database for search

This commit is contained in:
nvrl committed 2026-06-30 17:35:18 +02:00
1 parent 5e151bbfc5
commit 03dd8d8496
10 files changed
+2342 -167

No files matched your search

+33
View File
@@ -2,6 +2,7 @@
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -9,6 +10,7 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache; use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse}; use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse};
use pdfs_core::db::Db;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@@ -76,6 +78,14 @@ enum Command {
/// Photo node uid in `volume~link` form (from `pdfs photos`). /// Photo node uid in `volume~link` form (from `pdfs photos`).
uid: String, uid: String,
}, },
/// Search file/folder names via the running daemon's local index.
Search {
/// Query string (substring match).
query: String,
/// Max hits to return.
#[arg(long, default_value_t = 50)]
limit: usize,
},
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -102,6 +112,7 @@ fn main() -> Result<()> {
Command::Ls { path } => cmd_ls(path), Command::Ls { path } => cmd_ls(path),
Command::Photos { limit, offset } => cmd_photos(limit, offset), Command::Photos { limit, offset } => cmd_photos(limit, offset),
Command::OpenPhoto { uid } => cmd_open_photo(uid), Command::OpenPhoto { uid } => cmd_open_photo(uid),
Command::Search { query, limit } => cmd_search(query, limit),
} }
} }
@@ -217,10 +228,15 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?; .with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
let username = auth::load().map(|s| s.username).unwrap_or_default(); let username = auth::load().map(|s| s.username).unwrap_or_default();
// The daemon owns the unified DB (single writer). Open it here so the content
// cache and the mount share one handle: the cache uses its `cache_entries`
// table as the LRU index, the mount uses it for nodes/search/event cursor.
let db = Arc::new(Db::open(&dirs.db_path()).context("open cache db")?);
let cache = ContentCache::open( let cache = ContentCache::open(
dirs.content_cache_dir(), dirs.content_cache_dir(),
dirs.pins_path(), dirs.pins_path(),
pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES, pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES,
db.clone(),
) )
.context("open content cache")?; .context("open content cache")?;
let control_socket = dirs.control_socket(); let control_socket = dirs.control_socket();
@@ -272,6 +288,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
&mountpoint, &mountpoint,
cache, cache,
&control_socket, &control_socket,
db,
username, username,
); );
@@ -371,6 +388,22 @@ fn cmd_open_photo(uid: String) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_search(query: String, limit: usize) -> Result<()> {
match control_request(CtlRequest::Search { query, limit })? {
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
CtlResponse::SearchResults { hits } => {
for h in hits {
let kind = if h.is_dir { "d" } else { "-" };
let pin = if h.pinned { "*" } else { " " };
println!("{kind}{pin} {:>12} {}", h.size, h.path);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
/// Normalize a user-supplied path argument for sending to the daemon. Absolute /// Normalize a user-supplied path argument for sending to the daemon. Absolute
/// paths are canonicalized (so the daemon can strip its mountpoint prefix); /// paths are canonicalized (so the daemon can strip its mountpoint prefix);
/// relative paths are passed through and resolved against the mount root. /// relative paths are passed through and resolved against the mount root.
+1
View File
@@ -17,4 +17,5 @@ thiserror.workspace = true
tracing.workspace = true tracing.workspace = true
directories.workspace = true directories.workspace = true
sha2.workspace = true sha2.workspace = true
rusqlite.workspace = true
keyring.workspace = true keyring.workspace = true
+249 -113
View File
@@ -19,14 +19,32 @@
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::SystemTime; use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::db::Db;
use crate::error::Result; use crate::error::Result;
/// `cache_entries.kind` tag for whole-file content blobs.
const KIND_BLOB: &str = "blob";
/// `cache_entries.kind` tag for on-demand block-cache chunks.
const KIND_BLOCK: &str = "block";
/// Current wall-clock time in unix *milliseconds*, for the LRU `last_accessed`
/// column. Milliseconds (not seconds) so two cache events in the same second
/// still order correctly — a coarse second-granularity clock would make LRU
/// eviction order indeterminate under bursty access.
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// Block size for the on-demand block cache. Matches the SDK content block size /// Block size for the on-demand block cache. Matches the SDK content block size
/// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one /// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one
/// `download_range` fetch with no straddling. /// `download_range` fetch with no straddling.
@@ -42,18 +60,22 @@ struct Meta {
size: u64, size: u64,
} }
/// One pinned file, as persisted to the pin registry. /// One pin, as carried over the control socket and listed in `status`.
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Pin { pub struct Pin {
/// Node uid in `volume~link` display form. /// Node uid in `volume~link` display form.
pub uid: String, pub uid: String,
/// Last path the file was pinned under, for display in `status`. Advisory /// Last path the node was pinned under, for display in `status`. Advisory
/// only — the uid is the identity. /// only — the uid is the identity.
pub path: String, pub path: String,
/// A folder pin whose whole subtree is kept on disk. `false` for a single
/// file. Defaulted so a legacy `pins.json` (file pins only) still parses.
#[serde(default)]
pub recursive: bool,
} }
/// The pin registry, persisted as JSON. Keyed by uid display string so a pin /// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
/// survives renames/moves of the node. /// the DB once on open. Live pins are owned by the `pins` table.
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
struct PinFile { struct PinFile {
pins: BTreeMap<String, Pin>, pins: BTreeMap<String, Pin>,
@@ -80,13 +102,24 @@ pub struct ContentCache {
/// disables the cap. Pinned blobs are never evicted, so pins alone may push /// disables the cap. Pinned blobs are never evicted, so pins alone may push
/// the cache over budget. /// the cache over budget.
max_bytes: u64, max_bytes: u64,
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
/// store/read/evict updates it, and the budget enforcers query it instead of
/// scanning the cache directories (plan.md P4).
db: Arc<Db>,
} }
impl ContentCache { impl ContentCache {
/// Open (and create) a cache under `content_dir`, with the pin registry at /// Open (and create) a cache under `content_dir`, with the pin registry at
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent /// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
/// directories are created if missing. /// directories are created if missing. `db` is the shared metadata database
pub fn open(content_dir: PathBuf, pins_path: PathBuf, max_bytes: u64) -> Result<Self> { /// whose `cache_entries` table backs LRU eviction; the on-disk cache is
/// reconciled into it on open.
pub fn open(
content_dir: PathBuf,
pins_path: PathBuf,
max_bytes: u64,
db: Arc<Db>,
) -> Result<Self> {
std::fs::create_dir_all(&content_dir)?; std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs"); let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?; std::fs::create_dir_all(&thumb_dir)?;
@@ -100,14 +133,71 @@ impl ContentCache {
if let Some(parent) = pins_path.parent() { if let Some(parent) = pins_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
Ok(Self { let cache = Self {
content_dir, content_dir,
thumb_dir, thumb_dir,
block_dir, block_dir,
scratch_dir, scratch_dir,
pins_path, pins_path,
max_bytes, max_bytes,
}) db,
};
cache.reconcile()?;
cache.import_legacy_pins()?;
Ok(cache)
}
/// One-time migration of a pre-P5 `pins.json` into the DB `pins` table, then
/// delete the file so it never re-imports. Legacy pins were all whole-file
/// (non-recursive). Absent file → nothing to do.
fn import_legacy_pins(&self) -> Result<()> {
let Ok(bytes) = std::fs::read(&self.pins_path) else {
return Ok(());
};
if let Ok(file) = serde_json::from_slice::<PinFile>(&bytes) {
for (uid, pin) in file.pins {
self.db.pin_add(&uid, &pin.path, false)?;
}
}
let _ = std::fs::remove_file(&self.pins_path);
Ok(())
}
/// Rebuild the `cache_entries` LRU index from the on-disk cache. Called once
/// on open: the index is cleared, then every blob (`content_dir`) and block
/// (`block_dir`) file is re-registered with its on-disk size and last-modified
/// time as the initial LRU key. This makes the DB authoritative even after a
/// crash or an external file deletion, and picks up caches written by builds
/// predating the index. In-run accesses then refine the ordering.
fn reconcile(&self) -> Result<()> {
self.db.cache_clear()?;
for (dir, kind) in [
(&self.content_dir, KIND_BLOB),
(&self.block_dir, KIND_BLOCK),
] {
let Ok(rd) = std::fs::read_dir(dir) else {
continue;
};
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
let at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
self.db.cache_touch(name, kind, meta.len(), at)?;
}
}
Ok(())
} }
/// Filesystem-safe, fixed-length cache key for a uid display string: the /// Filesystem-safe, fixed-length cache key for a uid display string: the
@@ -171,10 +261,10 @@ impl ContentCache {
len: u64, len: u64,
) -> Option<Vec<u8>> { ) -> Option<Vec<u8>> {
let blob = self.valid_blob(uid, mtime, size)?; let blob = self.valid_blob(uid, mtime, size)?;
// Record the access for LRU: bump the blob's mtime to now. Best effort — // Record the access for LRU in the cache index. Best effort — a failed
// a failed touch only makes eviction order slightly less accurate. // update only makes eviction order slightly less accurate.
let _ = self.db.cache_accessed(&Self::key(uid), now_millis());
let mut f = std::fs::File::open(&blob).ok()?; let mut f = std::fs::File::open(&blob).ok()?;
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
if offset >= size { if offset >= size {
return Some(Vec::new()); return Some(Vec::new());
} }
@@ -197,12 +287,22 @@ impl ContentCache {
std::fs::rename(&tmp, &blob)?; std::fs::rename(&tmp, &blob)?;
let meta = serde_json::to_vec(&Meta { mtime, size })?; let meta = serde_json::to_vec(&Meta { mtime, size })?;
std::fs::write(self.meta_path(uid), meta)?; std::fs::write(self.meta_path(uid), meta)?;
// Register the blob in the LRU index before enforcing the budget so the
// newest entry is weighed (and ordered most-recent) like any other.
self.db
.cache_touch(&Self::key(uid), KIND_BLOB, bytes.len() as u64, now_millis())?;
self.enforce_budget(); self.enforce_budget();
Ok(()) Ok(())
} }
/// Cache-index key for block `idx` of `uid` — the block file's name, which
/// is also its `cache_entries.cache_key`.
fn block_key(&self, uid: &NodeUid, idx: u64) -> String {
format!("{}.b{idx}", Self::key(uid))
}
fn block_blob(&self, uid: &NodeUid, idx: u64) -> PathBuf { fn block_blob(&self, uid: &NodeUid, idx: u64) -> PathBuf {
self.block_dir.join(format!("{}.b{idx}", Self::key(uid))) self.block_dir.join(self.block_key(uid, idx))
} }
fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf { fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf {
@@ -222,7 +322,10 @@ impl ContentCache {
return None; return None;
} }
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?; let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now())); // LRU touch in the cache index (best effort).
let _ = self
.db
.cache_accessed(&self.block_key(uid, idx), now_millis());
let mut buf = Vec::new(); let mut buf = Vec::new();
f.read_to_end(&mut buf).ok()?; f.read_to_end(&mut buf).ok()?;
Some(buf) Some(buf)
@@ -249,6 +352,12 @@ impl ContentCache {
self.block_meta(uid, idx), self.block_meta(uid, idx),
serde_json::to_vec(&Meta { mtime, size })?, serde_json::to_vec(&Meta { mtime, size })?,
)?; )?;
self.db.cache_touch(
&self.block_key(uid, idx),
KIND_BLOCK,
bytes.len() as u64,
now_millis(),
)?;
self.enforce_block_budget(); self.enforce_block_budget();
Ok(()) Ok(())
} }
@@ -260,42 +369,25 @@ impl ContentCache {
if self.max_bytes == 0 { if self.max_bytes == 0 {
return; return;
} }
let mut total: u64 = 0; // LRU-ordered (oldest first) block entries straight from the index — no
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new(); // directory scan. All blocks are evictable; pinned files are served from
let Ok(rd) = std::fs::read_dir(&self.block_dir) else { // whole-file blobs, never blocks.
let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOCK) else {
return; return;
}; };
for entry in rd.flatten() { let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
total += meta.len();
let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
blobs.push((path, meta.len(), atime));
}
if total <= self.max_bytes { if total <= self.max_bytes {
return; return;
} }
blobs.sort_by_key(|(_, _, atime)| *atime); for (key, size) in entries {
for (path, len, _) in blobs {
if total <= self.max_bytes { if total <= self.max_bytes {
break; break;
} }
let meta = path.with_file_name(format!( // `key` is the block file's name (and its `.meta` sibling's stem).
"{}.meta", let _ = std::fs::remove_file(self.block_dir.join(&key));
path.file_name().unwrap_or_default().to_string_lossy() let _ = std::fs::remove_file(self.block_dir.join(format!("{key}.meta")));
)); let _ = self.db.cache_remove(&key);
let _ = std::fs::remove_file(&path); total = total.saturating_sub(size);
let _ = std::fs::remove_file(meta);
total = total.saturating_sub(len);
} }
} }
@@ -328,7 +420,10 @@ impl ContentCache {
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype)); let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype)); let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
} }
// Drop every cached block (and its meta/tmp) for this uid. // Drop every cached block (and its meta/tmp) for this uid. The block
// dir is still scanned here because eviction targets one uid's files by
// name prefix, not by LRU order; the index rows are removed in one shot
// by `cache_remove_all` below.
let prefix = format!("{}.b", Self::key(uid)); let prefix = format!("{}.b", Self::key(uid));
if let Ok(rd) = std::fs::read_dir(&self.block_dir) { if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
for entry in rd.flatten() { for entry in rd.flatten() {
@@ -341,6 +436,8 @@ impl ContentCache {
} }
} }
} }
// Forget the blob and all of this uid's block rows in the LRU index.
let _ = self.db.cache_remove_all(&Self::key(uid));
} }
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf { fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
@@ -423,101 +520,80 @@ impl ContentCache {
if self.max_bytes == 0 { if self.max_bytes == 0 {
return; return;
} }
// Pinned blobs (by cache key) are exempt from eviction. // Pinned blobs (by cache key) are exempt from eviction. Resolves direct
// pins plus every descendant of a recursively-pinned folder, hashed into
// cache keys to match on-disk filenames.
let pinned: HashSet<String> = self let pinned: HashSet<String> = self
.load_pins() .db
.pins .pinned_uids()
.keys() .unwrap_or_default()
.iter()
.map(|uid| Self::key_str(uid)) .map(|uid| Self::key_str(uid))
.collect(); .collect();
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record // LRU-ordered (oldest first) blob entries from the index — no directory
// size + last-access (mtime) for each. // scan. Pinned blobs still count toward the total (so pins alone can hold
let mut total: u64 = 0; // the cache over budget) but are never chosen as victims.
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new(); let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) else {
let Ok(rd) = std::fs::read_dir(&self.content_dir) else {
return; return;
}; };
for entry in rd.flatten() { let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.ends_with(".meta") || name.ends_with(".tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
total += meta.len();
if pinned.contains(name) {
continue; // counts toward total but is never a victim
}
let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
blobs.push((path, meta.len(), atime));
}
if total <= self.max_bytes { if total <= self.max_bytes {
return; return;
} }
// Oldest access first. for (key, size) in entries {
blobs.sort_by_key(|(_, _, atime)| *atime);
for (path, len, _) in blobs {
if total <= self.max_bytes { if total <= self.max_bytes {
break; break;
} }
let _ = std::fs::remove_file(&path); if pinned.contains(&key) {
let _ = std::fs::remove_file(path.with_extension("meta")); continue; // counts toward total but is never a victim
total = total.saturating_sub(len); }
// `key` is the blob file's name (and its `.meta` sibling's stem).
let _ = std::fs::remove_file(self.content_dir.join(&key));
let _ = std::fs::remove_file(self.content_dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
total = total.saturating_sub(size);
} }
} }
fn load_pins(&self) -> PinFile { /// Whether `uid` is pinned — directly, or because an ancestor folder is
std::fs::read(&self.pins_path) /// pinned recursively (resolved in the DB against the node tree). Independent
.ok() /// of whether its blob is currently cached. Best effort: a DB error reads as
.and_then(|b| serde_json::from_slice(&b).ok()) /// "not pinned" rather than failing the calling FUSE op.
.unwrap_or_default()
}
fn save_pins(&self, pins: &PinFile) -> Result<()> {
std::fs::write(&self.pins_path, serde_json::to_vec_pretty(pins)?)?;
Ok(())
}
/// Whether `uid` is pinned (independent of whether its blob is currently
/// cached).
pub fn is_pinned(&self, uid: &NodeUid) -> bool { pub fn is_pinned(&self, uid: &NodeUid) -> bool {
self.load_pins().pins.contains_key(&uid.to_string()) self.db.is_pinned(&uid.to_string()).unwrap_or(false)
} }
/// Record `uid` as pinned under `path`. The caller is responsible for having /// Record `uid` as pinned under `path`. `recursive` marks a folder pin whose
/// already cached the content via [`store`](Self::store). /// whole subtree is kept. The caller is responsible for having cached the
pub fn add_pin(&self, uid: &NodeUid, path: &Path) -> Result<()> { /// content (a file via [`store`](Self::store); a folder's descendants by the
let mut file = self.load_pins(); /// daemon walking the subtree).
let key = uid.to_string(); pub fn add_pin(&self, uid: &NodeUid, path: &Path, recursive: bool) -> Result<()> {
file.pins.insert( self.db
key.clone(), .pin_add(&uid.to_string(), &path.display().to_string(), recursive)
Pin {
uid: key,
path: path.display().to_string(),
},
);
self.save_pins(&file)
} }
/// Drop `uid` from the pin registry and evict its blob. No-op if not pinned. /// Drop `uid` from the pin registry and evict its blob. No-op if not pinned.
/// A recursively-pinned folder's descendants are evicted by the daemon
/// (`Core::unpin`), which knows the node tree.
pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> { pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> {
let mut file = self.load_pins(); self.db.pin_remove(&uid.to_string())?;
if file.pins.remove(&uid.to_string()).is_some() {
self.save_pins(&file)?;
}
self.evict(uid); self.evict(uid);
Ok(()) Ok(())
} }
/// All pinned files, ordered by uid. /// All pins (files and recursive folders), ordered by uid.
pub fn list_pins(&self) -> Vec<Pin> { pub fn list_pins(&self) -> Vec<Pin> {
self.load_pins().pins.into_values().collect() self.db
.pin_list()
.unwrap_or_default()
.into_iter()
.map(|(uid, path, recursive)| Pin {
uid,
path,
recursive,
})
.collect()
} }
/// Total bytes of cached content blobs (pinned + unpinned), matching what /// Total bytes of cached content blobs (pinned + unpinned), matching what
@@ -591,10 +667,12 @@ mod tests {
fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) { fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) {
let dir = TempDir::new(); let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let c = ContentCache::open( let c = ContentCache::open(
dir.path().join("content"), dir.path().join("content"),
dir.path().join("pins.json"), dir.path().join("pins.json"),
max_bytes, max_bytes,
db,
) )
.unwrap(); .unwrap();
(c, dir) (c, dir)
@@ -618,6 +696,40 @@ mod tests {
); );
} }
#[test]
fn reconcile_rebuilds_index_from_disk() {
let dir = TempDir::new();
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
// First session: unlimited cap, store three blobs onto disk.
let db1 = Arc::new(Db::open_in_memory().unwrap());
{
let c = ContentCache::open(content.clone(), pins.clone(), 0, db1).unwrap();
c.store(&uid("a"), 1, 4, b"aaaa").unwrap();
c.store(&uid("b"), 1, 4, b"bbbb").unwrap();
c.store(&uid("c"), 1, 4, b"cccc").unwrap();
}
// Second session with a *fresh* index (as if predating P4, or after a
// crash) but the same on-disk cache: reconcile repopulates from disk.
let db2 = Arc::new(Db::open_in_memory().unwrap());
assert!(db2.cache_entries_by_kind("blob").unwrap().is_empty());
let c = ContentCache::open(content, pins, 8, db2.clone()).unwrap();
assert_eq!(db2.cache_entries_by_kind("blob").unwrap().len(), 3);
// With the index rebuilt, a further store enforces the budget against the
// reconciled entries instead of leaking them.
c.store(&uid("d"), 1, 4, b"dddd").unwrap();
let total: u64 = db2
.cache_entries_by_kind("blob")
.unwrap()
.iter()
.map(|(_, s)| s)
.sum();
assert!(total <= 8, "budget enforced after reconcile, total={total}");
}
#[test] #[test]
fn stale_metadata_is_a_miss() { fn stale_metadata_is_a_miss() {
let (c, _d) = cache(); let (c, _d) = cache();
@@ -630,12 +742,36 @@ mod tests {
assert!(c.read_range(&u, 100, 4, 0, 3).is_none()); assert!(c.read_range(&u, 100, 4, 0, 3).is_none());
} }
#[test]
fn legacy_pins_json_imported_then_removed() {
let dir = TempDir::new();
let pins = dir.path().join("pins.json");
// Hand-write a pre-P5 pins.json (file pins only, no `recursive` field).
std::fs::write(
&pins,
r#"{"pins":{"vol~a":{"uid":"vol~a","path":"docs/a.txt"}}}"#,
)
.unwrap();
let db = Arc::new(Db::open_in_memory().unwrap());
let c = ContentCache::open(dir.path().join("content"), pins.clone(), 0, db).unwrap();
// Imported into the DB as a non-recursive pin...
assert!(c.is_pinned(&uid("a")));
let listed = c.list_pins();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].path, "docs/a.txt");
assert!(!listed[0].recursive);
// ...and the legacy file is gone so it never re-imports.
assert!(!pins.exists());
}
#[test] #[test]
fn pin_lifecycle_evicts_on_unpin() { fn pin_lifecycle_evicts_on_unpin() {
let (c, _d) = cache(); let (c, _d) = cache();
let u = uid("a"); let u = uid("a");
c.store(&u, 100, 3, b"abc").unwrap(); c.store(&u, 100, 3, b"abc").unwrap();
c.add_pin(&u, Path::new("docs/a.txt")).unwrap(); c.add_pin(&u, Path::new("docs/a.txt"), false).unwrap();
assert!(c.is_pinned(&u)); assert!(c.is_pinned(&u));
assert_eq!(c.list_pins().len(), 1); assert_eq!(c.list_pins().len(), 1);
assert_eq!(c.list_pins()[0].path, "docs/a.txt"); assert_eq!(c.list_pins()[0].path, "docs/a.txt");
@@ -675,7 +811,7 @@ mod tests {
let (a, b, d) = (uid("a"), uid("b"), uid("d")); let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap(); c.store(&a, 1, 4, b"aaaa").unwrap();
c.add_pin(&a, Path::new("a")).unwrap(); c.add_pin(&a, Path::new("a"), false).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap(); c.store(&b, 1, 4, b"bbbb").unwrap();
// Over budget: `a` is pinned (oldest) so `b` must be the victim. // Over budget: `a` is pinned (oldest) so `b` must be the victim.
+6
View File
@@ -94,6 +94,12 @@ impl AppDirs {
self.state_dir().join("pins.json") self.state_dir().join("pins.json")
} }
/// Unified SQLite metadata cache (inodes, FTS, cache LRU, pins). Lives in
/// persistent state next to `control.sock`; only the daemon writes it.
pub fn db_path(&self) -> PathBuf {
self.state_dir().join("cache.db")
}
/// Unix domain socket the mount daemon listens on for CLI control commands. /// Unix domain socket the mount daemon listens on for CLI control commands.
pub fn control_socket(&self) -> PathBuf { pub fn control_socket(&self) -> PathBuf {
self.state_dir().join("control.sock") self.state_dir().join("control.sock")
+34
View File
@@ -49,10 +49,20 @@ pub enum Request {
PhotosTimeline { offset: usize, limit: usize }, PhotosTimeline { offset: usize, limit: usize },
/// Download a photo's full content into the cache; replies with its path. /// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String }, OpenPhoto { uid: String },
/// Upload a photo with the given name, media type, and content bytes.
UploadPhoto {
name: String,
media_type: String,
bytes: Vec<u8>,
capture_time: Option<i64>,
},
/// Download a Drive file's full content into the cache; replies with the /// Download a Drive file's full content into the cache; replies with the
/// on-disk path so the front-end can open it with the default app. `path` /// on-disk path so the front-end can open it with the default app. `path`
/// is mountpoint-relative. /// is mountpoint-relative.
OpenFile { path: String }, OpenFile { path: String },
/// Full-text search node names against the daemon's local metadata index.
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
Search { query: String, limit: usize },
} }
/// One entry in a [`Request::ListDir`] listing. /// One entry in a [`Request::ListDir`] listing.
@@ -69,6 +79,28 @@ pub struct DirEntry {
pub pinned: bool, pub pinned: bool,
/// Node uid in `volume~link` form, for follow-up requests. /// Node uid in `volume~link` form, for follow-up requests.
pub uid: String, 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.
#[serde(default)]
pub path: String,
}
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
/// full mountpoint-relative `path` (a hit can live anywhere in the tree), so the
/// front-end can navigate to or open it directly.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SearchHit {
pub name: String,
/// Mountpoint-relative path (`/`-joined, no leading slash).
pub path: String,
pub is_dir: bool,
pub size: u64,
pub modified: i64,
pub pinned: bool,
/// Node uid in `volume~link` form.
pub uid: String,
} }
/// One photo in a [`Request::PhotosTimeline`] page. /// One photo in a [`Request::PhotosTimeline`] page.
@@ -113,6 +145,8 @@ pub enum Response {
}, },
/// An on-disk path the front-end can open (e.g. a downloaded photo). /// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String }, FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]).
SearchResults { hits: Vec<SearchHit> },
/// The request failed. /// The request failed.
Error { message: String }, Error { message: String },
} }
File diff suppressed because it is too large. Load diff
+3
View File
@@ -16,6 +16,9 @@ pub enum Error {
#[error("io: {0}")] #[error("io: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("database: {0}")]
Db(#[from] rusqlite::Error),
/// No persisted session found — caller must run an interactive login. /// No persisted session found — caller must run an interactive login.
#[error("not logged in (no saved session)")] #[error("not logged in (no saved session)")]
NotLoggedIn, NotLoggedIn,
+1
View File
@@ -6,6 +6,7 @@ pub mod auth;
pub mod cache; pub mod cache;
pub mod config; pub mod config;
pub mod control; pub mod control;
pub mod db;
pub mod error; pub mod error;
pub mod service; pub mod service;
+305 -26
View File
@@ -49,7 +49,10 @@ use fuser::{
ReplyWrite, Request, Session, TimeOrNow, WriteFlags, ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
}; };
use pdfs_core::cache::{BLOCK_SIZE, ContentCache}; use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse}; use pdfs_core::control::{
DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
};
use pdfs_core::db::{Db, StoredNode};
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient, DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
@@ -186,11 +189,19 @@ struct State {
/// have no entry here. /// have no entry here.
handles: HashMap<u64, WriteHandle>, handles: HashMap<u64, WriteHandle>,
next_fh: u64, next_fh: u64,
/// Unified SQLite metadata cache. Every map mutation below writes through to
/// it inside the `State` lock so the DB stays the authoritative copy across
/// restarts (see plan.md P1).
db: Arc<Db>,
} }
impl State { impl State {
/// Allocate (or reuse) a stable inode for a node and store its metadata. /// Allocate (or reuse) a stable inode for a node and store its metadata,
/// writing the node through to the DB.
fn intern(&mut self, parent: u64, node: Node) -> u64 { fn intern(&mut self, parent: u64, node: Node) -> u64 {
if let Err(e) = self.db.upsert_node(&node) {
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
}
if let Some(&ino) = self.by_uid.get(&node.uid) { if let Some(&ino) = self.by_uid.get(&node.uid) {
if let Some(e) = self.entries.get_mut(&ino) { if let Some(e) = self.entries.get_mut(&ino) {
e.node = node; e.node = node;
@@ -213,10 +224,14 @@ impl State {
} }
/// Forget a node entirely: drop its inode, its uid mapping, its own cached /// Forget a node entirely: drop its inode, its uid mapping, its own cached
/// listing, and its slot in its parent's listing. Returns `(parent_ino, /// listing, its slot in its parent's listing, and its DB row. Returns
/// name)` when the node was known, so the caller can notify the kernel. /// `(parent_ino, name)` when the node was known, so the caller can notify
/// the kernel.
fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> { fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
let ino = self.by_uid.remove(uid)?; let ino = self.by_uid.remove(uid)?;
if let Err(e) = self.db.delete_node(uid) {
warn!(%uid, error = %e, "db delete_node failed");
}
let entry = self.entries.remove(&ino)?; let entry = self.entries.remove(&ino)?;
self.children.remove(&ino); self.children.remove(&ino);
if let Some(kids) = self.children.get_mut(&entry.parent) { if let Some(kids) = self.children.get_mut(&entry.parent) {
@@ -225,6 +240,20 @@ impl State {
Some((entry.parent, entry.node.name)) Some((entry.parent, entry.node.name))
} }
/// Drop a directory's cached child listing and mark it unlisted in the DB,
/// so the next access re-enumerates instead of trusting a stale listing.
fn invalidate_listing(&mut self, ino: u64) {
if self.children.remove(&ino).is_none() {
return;
}
if let Some(e) = self.entries.get(&ino) {
let uid = e.uid.clone();
if let Err(err) = self.db.set_listed(&uid, false) {
warn!(%uid, error = %err, "db set_listed(false) failed");
}
}
}
/// Update a file entry's recorded plaintext size so `getattr` reflects an /// Update a file entry's recorded plaintext size so `getattr` reflects an
/// in-progress write before the new revision is sealed. /// in-progress write before the new revision is sealed.
fn set_size(&mut self, ino: u64, size: u64) { fn set_size(&mut self, ino: u64, size: u64) {
@@ -255,6 +284,10 @@ struct Core {
rt: tokio::runtime::Handle, rt: tokio::runtime::Handle,
state: Arc<Mutex<State>>, state: Arc<Mutex<State>>,
cache: Arc<ContentCache>, cache: Arc<ContentCache>,
/// Unified SQLite metadata cache: the persistence layer behind the in-memory
/// `State` maps. Every mutation writes through here, and the maps rehydrate
/// from it on mount (plan.md P1).
db: Arc<Db>,
/// Cached full photos timeline (newest first), so paged `PhotosTimeline` /// Cached full photos timeline (newest first), so paged `PhotosTimeline`
/// requests slice memory instead of re-fetching the whole timeline per page. /// requests slice memory instead of re-fetching the whole timeline per page.
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`]. /// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
@@ -268,6 +301,71 @@ struct TimelineCache {
} }
impl Core { impl Core {
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
/// start serves previously-seen metadata (stable inodes, instant listings)
/// without re-hitting the API. The root inode is already installed by
/// [`ProtonFs::new`]; this fills in every other persisted node and rebuilds
/// the child listings of folders the DB records as fully enumerated.
fn hydrate(&self) {
let stored = match self.db.load_all() {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "db load_all failed; mounting cold");
return;
}
};
if stored.is_empty() {
return;
}
let mut st = self.state.lock().unwrap();
// Pass 1: assign a stable inode to every uid (root is already mapped).
for sn in &stored {
if st.by_uid.contains_key(&sn.node.uid) {
continue;
}
let ino = st.next_ino;
st.next_ino += 1;
st.by_uid.insert(sn.node.uid.clone(), ino);
}
// Pass 2: materialize entries, resolving each parent's inode by uid.
// Track folders flagged complete so their listings rebuild in pass 3.
let mut listed_dirs: Vec<u64> = Vec::new();
for sn in stored {
let StoredNode { node, listed } = sn;
let Some(&ino) = st.by_uid.get(&node.uid) else {
continue;
};
if listed && node.is_folder() {
listed_dirs.push(ino);
}
// The root entry is owned by `ProtonFs::new`; don't overwrite it.
if ino == ROOT_INO {
continue;
}
let parent = node
.parent_uid
.as_ref()
.and_then(|p| st.by_uid.get(p).copied())
.unwrap_or(ROOT_INO);
let uid = node.uid.clone();
st.entries.insert(ino, Entry { uid, parent, node });
}
// Pass 3: rebuild child listings for fully-enumerated folders.
for dir_ino in listed_dirs {
let kids: Vec<u64> = st
.entries
.iter()
.filter(|(_, e)| e.parent == dir_ino && !e.node.trashed)
.map(|(&ino, _)| ino)
.collect();
st.children.insert(dir_ino, kids);
}
info!(nodes = st.entries.len(), "hydrated metadata cache from db");
}
/// Enumerate `ino`'s children from the remote and cache them. No-op if the /// Enumerate `ino`'s children from the remote and cache them. No-op if the
/// directory has already been listed. Network I/O happens without the lock /// directory has already been listed. Network I/O happens without the lock
/// held so concurrent metadata reads aren't blocked behind a fetch. /// held so concurrent metadata reads aren't blocked behind a fetch.
@@ -283,6 +381,29 @@ impl Core {
} }
}; };
// Offline fast path: a folder the DB still records as fully enumerated
// can be rebuilt from disk without hitting the API, even if its listing
// was trimmed from the hot cache mid-run.
match self.db.children_if_listed(&folder_uid) {
Ok(Some(nodes)) => {
let mut st = self.state.lock().unwrap();
if st.children.contains_key(&ino) {
return Ok(());
}
let mut child_inos = Vec::with_capacity(nodes.len());
for node in nodes {
if node.trashed {
continue;
}
child_inos.push(st.intern(ino, node));
}
st.children.insert(ino, child_inos);
return Ok(());
}
Ok(None) => {}
Err(e) => warn!(%folder_uid, error = %e, "db children_if_listed failed"),
}
let uids = self let uids = self
.rt .rt
.block_on(self.client.enumerate_folder_children_node_uids(&folder_uid)) .block_on(self.client.enumerate_folder_children_node_uids(&folder_uid))
@@ -311,6 +432,11 @@ impl Core {
child_inos.push(st.intern(ino, node)); child_inos.push(st.intern(ino, node));
} }
st.children.insert(ino, child_inos); st.children.insert(ino, child_inos);
// Record the listing as complete so a later restart (or a trimmed hot
// cache) can rebuild it from the DB without the API.
if let Err(e) = self.db.set_listed(&folder_uid, true) {
warn!(%folder_uid, error = %e, "db set_listed(true) failed");
}
Ok(()) Ok(())
} }
@@ -553,25 +679,32 @@ impl Core {
Ok(()) Ok(())
} }
/// Pin the file at mountpoint-relative `rel`: download its full plaintext /// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// into the content cache and record it in the pin registry. Returns the /// plaintext into the content cache; a folder records a recursive pin and
/// resolved node name on success. /// downloads every descendant file (selective sync). Returns a human message.
fn pin(&self, rel: &Path) -> Result<String, String> { fn pin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self let (ino, uid) = self
.resolve_path(rel) .resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?; .map_err(|e| format!("resolve path: {e:?}"))?;
let (name, mtime, size) = { let (name, is_folder, mtime, size) = {
let st = self.state.lock().unwrap(); let st = self.state.lock().unwrap();
let e = st.entries.get(&ino).ok_or("node vanished")?; let e = st.entries.get(&ino).ok_or("node vanished")?;
if !e.node.is_file() {
return Err("not a regular file".into());
}
( (
e.node.name.clone(), e.node.name.clone(),
e.node.is_folder(),
e.node.modification_time, e.node.modification_time,
node_size(&e.node), node_size(&e.node),
) )
}; };
if is_folder {
// Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree.
self.cache
.add_pin(&uid, rel, true)
.map_err(|e| format!("pin: {e}"))?;
let n = self.pin_subtree(ino)?;
return Ok(format!("{name} ({n} files)"));
}
let bytes = self let bytes = self
.rt .rt
.block_on(self.client.download_file(&uid)) .block_on(self.client.download_file(&uid))
@@ -580,11 +713,56 @@ impl Core {
.store(&uid, mtime, size, &bytes) .store(&uid, mtime, size, &bytes)
.map_err(|e| format!("cache store: {e}"))?; .map_err(|e| format!("cache store: {e}"))?;
self.cache self.cache
.add_pin(&uid, rel) .add_pin(&uid, rel, false)
.map_err(|e| format!("pin: {e}"))?; .map_err(|e| format!("pin: {e}"))?;
Ok(name) Ok(name)
} }
/// Download and cache every file in the subtree rooted at folder `ino`,
/// returning the count cached (already-fresh blobs counted, not re-fetched).
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
/// fully discovered; the lock is dropped before each network download.
fn pin_subtree(&self, ino: u64) -> Result<usize, String> {
let mut files: Vec<(NodeUid, i64, u64)> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
self.ensure_children(dir)
.map_err(|e| format!("enumerate: {e:?}"))?;
let st = self.state.lock().unwrap();
if let Some(kids) = st.children.get(&dir) {
for &k in kids {
if let Some(e) = st.entries.get(&k) {
if e.node.is_folder() {
stack.push(k);
} else {
files.push((
e.uid.clone(),
e.node.modification_time,
node_size(&e.node),
));
}
}
}
}
}
let mut count = 0;
for (uid, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.rt.block_on(self.client.download_file(&uid)) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
}
}
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
}
}
Ok(count)
}
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache /// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
/// when fresh and otherwise downloaded from the remote and cached. Returns /// when fresh and otherwise downloaded from the remote and cached. Returns
/// `Ok(None)` when the node is not a file or has no thumbnail of that type. /// `Ok(None)` when the node is not a file or has no thumbnail of that type.
@@ -615,21 +793,32 @@ impl Core {
Ok(bytes) Ok(bytes)
} }
/// Unpin the file at `rel`, evicting its cached content. /// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept).
fn unpin(&self, rel: &Path) -> Result<String, String> { fn unpin(&self, rel: &Path) -> Result<String, String> {
let (ino, uid) = self let (ino, uid) = self
.resolve_path(rel) .resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?; .map_err(|e| format!("resolve path: {e:?}"))?;
let name = { let (name, is_folder) = {
let st = self.state.lock().unwrap(); let st = self.state.lock().unwrap();
st.entries st.entries
.get(&ino) .get(&ino)
.map(|e| e.node.name.clone()) .map(|e| (e.node.name.clone(), e.node.is_folder()))
.unwrap_or_default() .unwrap_or_default()
}; };
self.cache self.cache
.remove_pin(&uid) .remove_pin(&uid)
.map_err(|e| format!("unpin: {e}"))?; .map_err(|e| format!("unpin: {e}"))?;
// A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree.
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
for s in uids {
if let Some(u) = parse_uid(&s) {
self.cache.evict(&u);
}
}
}
Ok(name) Ok(name)
} }
@@ -681,6 +870,31 @@ impl Core {
modified, modified,
pinned: self.cache.is_pinned(&uid), pinned: self.cache.is_pinned(&uid),
uid: uid.to_string(), uid: uid.to_string(),
// Listing entries live in the requested dir; the caller derives
// the path from its name. Left empty.
path: String::new(),
})
.collect())
}
/// Full-text search node names against the local SQLite index, mapping each
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
/// which the DB doesn't track). Pure local lookup — never hits the network.
fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>, String> {
let hits = self
.db
.search(query, limit)
.map_err(|e| format!("search: {e}"))?;
Ok(hits
.into_iter()
.map(|h| SearchHit {
name: h.node.name.clone(),
path: h.path,
is_dir: h.node.is_folder(),
size: node_size(&h.node),
modified: h.node.modification_time,
pinned: self.cache.is_pinned(&h.node.uid),
uid: h.node.uid.to_string(),
}) })
.collect()) .collect())
} }
@@ -855,6 +1069,9 @@ impl ProtonFs {
fn new(core: Core, root: Node) -> Self { fn new(core: Core, root: Node) -> Self {
{ {
let mut st = core.state.lock().unwrap(); let mut st = core.state.lock().unwrap();
if let Err(e) = st.db.upsert_node(&root) {
warn!(uid = %root.uid, error = %e, "db upsert root failed");
}
st.by_uid.insert(root.uid.clone(), ROOT_INO); st.by_uid.insert(root.uid.clone(), ROOT_INO);
st.entries.insert( st.entries.insert(
ROOT_INO, ROOT_INO,
@@ -1710,7 +1927,7 @@ fn apply_event(
// Known node changed: drop its cached attrs/data (and listing if // Known node changed: drop its cached attrs/data (and listing if
// it is a directory) so the next access re-fetches. Its content // it is a directory) so the next access re-fetches. Its content
// blob may now be stale, so evict it too. // blob may now be stale, so evict it too.
st.children.remove(&ino); st.invalidate_listing(ino);
content.evict(node_uid); content.evict(node_uid);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0); let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
} }
@@ -1718,8 +1935,8 @@ fn apply_event(
// drop it so the new child is picked up on the next readdir. // drop it so the new child is picked up on the next readdir.
if let Some(parent_uid) = parent_node_uid if let Some(parent_uid) = parent_node_uid
&& let Some(&parent) = st.by_uid.get(parent_uid) && let Some(&parent) = st.by_uid.get(parent_uid)
&& st.children.remove(&parent).is_some()
{ {
st.invalidate_listing(parent);
let _ = notifier.inval_inode(INodeNo(parent), 0, 0); let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
} }
} }
@@ -1746,8 +1963,8 @@ fn apply_event(
warn!("event continuity lost; dropping all cached listings, resyncing lazily"); warn!("event continuity lost; dropping all cached listings, resyncing lazily");
let mut st = state.lock().unwrap(); let mut st = state.lock().unwrap();
let dirs: Vec<u64> = st.children.keys().copied().collect(); let dirs: Vec<u64> = st.children.keys().copied().collect();
st.children.clear(); for &ino in &dirs {
for ino in dirs { st.invalidate_listing(ino);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0); let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
} }
} }
@@ -1758,20 +1975,41 @@ fn apply_event(
} }
/// Poll the remote event cursor forever, applying each batch to the shared /// Poll the remote event cursor forever, applying each batch to the shared
/// state. Seeds the cursor from the current server head so we only react to /// state. Resumes from the cursor persisted in the DB so changes made while
/// changes made after mount. Runs as a Tokio task; returns only on fatal error. /// unmounted are applied; only a first-ever mount seeds from the server head.
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
/// only on fatal error.
async fn run_event_sync( async fn run_event_sync(
client: ProtonDriveClient, client: ProtonDriveClient,
scope: DriveEventScopeId, scope: DriveEventScopeId,
state: Arc<Mutex<State>>, state: Arc<Mutex<State>>,
content: Arc<ContentCache>, content: Arc<ContentCache>,
db: Arc<Db>,
notifier: Notifier, notifier: Notifier,
) { ) {
// Seed: a `None` cursor yields a single `CursorAdvanced` at the server head. let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
let mut cursor: Option<DriveEventId> = match client.enumerate_events(&scope, None).await { // Resume: pick up exactly where the last run left off.
Ok(events) => events.last().map(|e| e.id().clone()), Ok(Some(saved)) => Some(DriveEventId::from(saved)),
// First mount: a `None` cursor yields a single `CursorAdvanced` at the
// server head; persist it so the next restart resumes instead of
// reseeding (which would skip everything that changed offline).
Ok(None) => match client.enumerate_events(&scope, None).await {
Ok(events) => {
let head = events.last().map(|e| e.id().clone());
if let Some(c) = &head
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist seed cursor failed");
}
head
}
Err(e) => {
error!(error = %e, "failed to seed event cursor; live sync disabled");
return;
}
},
Err(e) => { Err(e) => {
error!(error = %e, "failed to seed event cursor; live sync disabled"); error!(error = %e, "read persisted cursor failed; live sync disabled");
return; return;
} }
}; };
@@ -1794,6 +2032,11 @@ async fn run_event_sync(
apply_event(&state, &content, &notifier, event); apply_event(&state, &content, &notifier, event);
} }
cursor = events.last().map(|e| e.id().clone()); cursor = events.last().map(|e| e.id().clone());
if let Some(c) = &cursor
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist event cursor failed");
}
} }
} }
@@ -1889,6 +2132,32 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("bad uid: {uid}"), message: format!("bad uid: {uid}"),
}, },
}, },
Ok(CtlRequest::UploadPhoto {
name,
media_type,
bytes,
capture_time,
}) => {
let photos = core.photos();
let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time,
..Default::default()
};
match core
.rt
.block_on(photos.upload_photo(&name, &media_type, &bytes, metadata))
{
Ok(uid) => {
*core.timeline.lock().unwrap() = None;
CtlResponse::Ok {
message: format!("uploaded photo with uid {uid}"),
}
}
Err(e) => CtlResponse::Error {
message: format!("upload photo failed: {e}"),
},
}
}
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) { Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.open_file(&rel) { Ok(rel) => match core.open_file(&rel) {
Ok(p) => CtlResponse::FilePath { Ok(p) => CtlResponse::FilePath {
@@ -1898,6 +2167,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::Error { message: e }, Err(e) => CtlResponse::Error { message: e },
}, },
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
Ok(hits) => CtlResponse::SearchResults { hits },
Err(e) => CtlResponse::Error { message: e },
},
Err(e) => CtlResponse::Error { Err(e) => CtlResponse::Error {
message: format!("bad request: {e}"), message: format!("bad request: {e}"),
}, },
@@ -1981,6 +2254,7 @@ pub fn mount(
mountpoint: &Path, mountpoint: &Path,
cache: ContentCache, cache: ContentCache,
control_socket: &Path, control_socket: &Path,
db: Arc<Db>,
username: String, username: String,
) -> std::io::Result<MountOutcome> { ) -> std::io::Result<MountOutcome> {
let root = rt let root = rt
@@ -1998,8 +2272,10 @@ pub fn mount(
next_ino: 2, next_ino: 2,
handles: HashMap::new(), handles: HashMap::new(),
next_fh: 1, next_fh: 1,
db: db.clone(),
})), })),
cache: Arc::new(cache), cache: Arc::new(cache),
db,
timeline: Arc::new(Mutex::new(None)), timeline: Arc::new(Mutex::new(None)),
}; };
@@ -2029,6 +2305,9 @@ pub fn mount(
} }
let fs = ProtonFs::new(core.clone(), root); let fs = ProtonFs::new(core.clone(), root);
// Warm the in-memory maps from the DB so a cold start serves previously
// seen metadata without re-hitting the API (plan.md P1).
core.hydrate();
// Build the session explicitly (not `mount2`) so we can grab a `Notifier` // Build the session explicitly (not `mount2`) so we can grab a `Notifier`
// for the event task. `spawn` runs the session loop on its own background // for the event task. `spawn` runs the session loop on its own background
@@ -2036,7 +2315,7 @@ pub fn mount(
let bg = Session::new(fs, mountpoint, &config)?.spawn()?; let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
let notifier = bg.notifier(); let notifier = bg.notifier();
rt.spawn(run_event_sync( rt.spawn(run_event_sync(
client, scope, core.state, core.cache, notifier, client, scope, core.state, core.cache, core.db, notifier,
)); ));
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C) // Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
+662 -28
View File
@@ -32,7 +32,7 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send}; use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, SearchHit, send};
use pdfs_core::service; use pdfs_core::service;
/// How many photos to pull per [`Request::PhotosTimeline`] page. /// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -47,6 +47,11 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
/// Backoff between auto-retries of a Files/Photos load while the mount service /// Backoff between auto-retries of a Files/Photos load while the mount service
/// is still coming up (see [`load_browser`] / [`load_gallery`]). /// is still coming up (see [`load_browser`] / [`load_gallery`]).
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
/// Idle pause after the last keystroke before a search query is sent, so typing
/// doesn't fire a request per character.
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
/// Cap on search hits requested from the daemon.
const SEARCH_LIMIT: usize = 200;
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's /// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding. /// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
@@ -112,11 +117,17 @@ struct Ui {
browser_retry: gtk4::Button, browser_retry: gtk4::Button,
/// Mountpoint-relative path the browser is showing (empty = root). /// Mountpoint-relative path the browser is showing (empty = root).
browser_path: RefCell<String>, browser_path: RefCell<String>,
/// Debounced full-text search box in the browser header.
browser_search: gtk4::SearchEntry,
/// Pending debounce timer for the search box; replaced on every keystroke so
/// only the last pause actually fires a [`Request::Search`].
search_source: RefCell<Option<glib::SourceId>>,
// Photos (gallery) page. // Photos (gallery) page.
gallery_model: gio::ListStore, gallery_model: gio::ListStore,
gallery_status: gtk4::Label, gallery_status: gtk4::Label,
gallery_retry: gtk4::Button, gallery_retry: gtk4::Button,
gallery_more: gtk4::Button, gallery_more: gtk4::Button,
gallery_upload: gtk4::Button,
} }
impl Ui { impl Ui {
@@ -173,7 +184,17 @@ fn load_proton_theme() {
.brand-icon {{ color: {PROTON_PURPLE}; }}\n\ .brand-icon {{ color: {PROTON_PURPLE}; }}\n\
.file-grid {{ padding: 6px; }}\n\ .file-grid {{ padding: 6px; }}\n\
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n" .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
.photo-viewer-window {{ background-color: #000000; }}\n\
.viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 12px 24px; color: white; }}\n\
.viewer-title {{ font-weight: bold; font-size: 1.1rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
.viewer-action-btn {{ color: white; background-color: rgba(255, 255, 255, 0.15); border-radius: 50%; padding: 8px; margin-left: 4px; }}\n\
.viewer-action-btn:hover {{ background-color: rgba(255, 255, 255, 0.3); color: white; }}\n\
.viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.5); color: white; margin: 24px; padding: 16px; border-radius: 50%; }}\n\
.viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.8); color: white; }}\n\
.viewer-status {{ color: #ff5555; font-size: 1.1rem; background-color: rgba(0, 0, 0, 0.8); padding: 12px 24px; border-radius: 8px; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }}\n\
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n"
); );
let provider = gtk4::CssProvider::new(); let provider = gtk4::CssProvider::new();
provider.load_from_string(&css); provider.load_from_string(&css);
@@ -252,15 +273,19 @@ fn build_window(app: &adw::Application) {
browser_status: browser_widgets.3, browser_status: browser_widgets.3,
browser_retry: browser_widgets.6, browser_retry: browser_widgets.6,
browser_path: RefCell::new(String::new()), browser_path: RefCell::new(String::new()),
browser_search: browser_widgets.7,
search_source: RefCell::new(None),
gallery_model: gallery_widgets.0, gallery_model: gallery_widgets.0,
gallery_status: gallery_widgets.1, gallery_status: gallery_widgets.1,
gallery_retry: gallery_widgets.4, gallery_retry: gallery_widgets.4,
gallery_more: gallery_widgets.2, gallery_more: gallery_widgets.2,
gallery_upload: gallery_widgets.5,
}); });
wire_login(&ui); wire_login(&ui);
wire_logout(&ui, &main_widgets.5); wire_logout(&ui, &main_widgets.5);
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5); wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
wire_search(&ui);
wire_gallery(&ui, &gallery_widgets.3); wire_gallery(&ui, &gallery_widgets.3);
wire_retry(&ui); wire_retry(&ui);
@@ -831,6 +856,7 @@ fn build_browser_page() -> (
gtk4::GridView, gtk4::GridView,
gtk4::ColumnView, gtk4::ColumnView,
gtk4::Button, gtk4::Button,
gtk4::SearchEntry,
), ),
) { ) {
let model = gio::ListStore::new::<BoxedAnyObject>(); let model = gio::ListStore::new::<BoxedAnyObject>();
@@ -866,9 +892,16 @@ fn build_browser_page() -> (
toggles.append(&grid_toggle); toggles.append(&grid_toggle);
toggles.append(&list_toggle); toggles.append(&list_toggle);
let search = gtk4::SearchEntry::builder()
.placeholder_text("Search Drive")
.valign(gtk4::Align::Center)
.build();
search.set_width_chars(18);
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&back); header.append(&back);
header.append(&crumb); header.append(&crumb);
header.append(&search);
header.append(&toggles); header.append(&toggles);
let status = gtk4::Label::builder().wrap(true).build(); let status = gtk4::Label::builder().wrap(true).build();
@@ -938,7 +971,7 @@ fn build_browser_page() -> (
( (
inner.upcast(), inner.upcast(),
(model, back, crumb, status, grid, column_view, retry), (model, back, crumb, status, grid, column_view, retry, search),
) )
} }
@@ -1165,6 +1198,11 @@ fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Option<DirEnt
fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) { fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
if entry.is_dir { if entry.is_dir {
// Descending into a search hit: clear the query so the folder listing
// isn't immediately re-masked by a stale search.
if !entry.path.is_empty() {
ui.browser_search.set_text("");
}
*ui.browser_path.borrow_mut() = rel; *ui.browser_path.borrow_mut() = rel;
load_browser(ui); load_browser(ui);
} else { } else {
@@ -1217,6 +1255,11 @@ fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
/// Join the entry name onto the current browser directory to get its /// Join the entry name onto the current browser directory to get its
/// mountpoint-relative path. /// mountpoint-relative path.
fn entry_rel(ui: &Rc<Ui>, entry: &DirEntry) -> String { fn entry_rel(ui: &Rc<Ui>, entry: &DirEntry) -> String {
// Search hits carry an absolute (mountpoint-relative) path since they can
// live anywhere; plain listing entries derive it from the current folder.
if !entry.path.is_empty() {
return entry.path.clone();
}
let base = ui.browser_path.borrow(); let base = ui.browser_path.borrow();
if base.is_empty() { if base.is_empty() {
entry.name.clone() entry.name.clone()
@@ -1357,6 +1400,97 @@ fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
} }
} }
/// Wire the browser header's search box: debounce keystrokes, then either run a
/// search or — when cleared — restore the current directory listing.
fn wire_search(ui: &Rc<Ui>) {
let ui_s = ui.clone();
ui.browser_search.connect_search_changed(move |_| {
// Replace any pending debounce so only the last keystroke's pause fires.
if let Some(src) = ui_s.search_source.borrow_mut().take() {
src.remove();
}
let ui_t = ui_s.clone();
let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || {
ui_t.search_source.borrow_mut().take();
let query = ui_t.browser_search.text().trim().to_string();
if query.is_empty() {
load_browser(&ui_t);
} else {
run_search(&ui_t, &query);
}
});
*ui_s.search_source.borrow_mut() = Some(src);
});
}
/// Send a [`Request::Search`] to the daemon and render the hits in the browser
/// views, reusing the same row model so click-to-open and pin work unchanged
/// (each hit carries its full path; see [`entry_rel`]).
fn run_search(ui: &Rc<Ui>, query: &str) {
ui.browser_model.remove_all();
ui.browser_retry.set_visible(false);
ui.browser_status.set_label("Searching…");
ui.browser_status.set_visible(true);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::Search {
query: query.to_string(),
limit: SEARCH_LIMIT,
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
// The box may have been cleared (or changed) while the reply was in
// flight; if so a fresh load/search already owns the model — drop this.
if ui.browser_search.text().trim().is_empty() {
return;
}
match result {
Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits),
Ok(Ok(Response::Error { message })) => browser_message(&ui, &message),
Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."),
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
}
});
}
/// Repopulate the model with search hits — folders first, then by name — mapping
/// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and
/// handlers already understand.
fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
ui.browser_model.remove_all();
if hits.is_empty() {
browser_message(ui, "No matches.");
return;
}
ui.browser_status.set_visible(false);
let mut entries: Vec<DirEntry> = hits
.iter()
.map(|h| DirEntry {
name: h.name.clone(),
is_dir: h.is_dir,
size: h.size,
modified: h.modified,
pinned: h.pinned,
uid: h.uid.clone(),
path: h.path.clone(),
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
for entry in entries {
ui.browser_model.append(&BoxedAnyObject::new(entry));
}
}
/// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a /// The Photos page: a [`gtk4::GridView`] of thumbnails backed by a
/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a /// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`PhotoItem`]s, plus a
/// "Load more" button and a status label. /// "Load more" button and a status label.
@@ -1368,6 +1502,7 @@ fn build_gallery_page() -> (
gtk4::Button, gtk4::Button,
gtk4::GridView, gtk4::GridView,
gtk4::Button, gtk4::Button,
gtk4::Button, // Upload Photo
), ),
) { ) {
let model = gio::ListStore::new::<BoxedAnyObject>(); let model = gio::ListStore::new::<BoxedAnyObject>();
@@ -1429,21 +1564,41 @@ fn build_gallery_page() -> (
.child(&grid) .child(&grid)
.build(); .build();
// Modern header: "Photos" title + "Upload Photo" action button
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let title_label = gtk4::Label::builder()
.label("Photos")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title_label.add_css_class("heading");
let upload = gtk4::Button::builder()
.label("Upload Photo")
.icon_name("list-add-symbolic")
.valign(gtk4::Align::Center)
.build();
upload.add_css_class("pill");
upload.add_css_class("suggested-action");
header_box.append(&title_label);
header_box.append(&upload);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12); inner.set_margin_top(12);
inner.set_margin_bottom(12); inner.set_margin_bottom(12);
inner.set_margin_start(12); inner.set_margin_start(12);
inner.set_margin_end(12); inner.set_margin_end(12);
inner.append(&header_box);
inner.append(&status); inner.append(&status);
inner.append(&retry); inner.append(&retry);
inner.append(&scroll); inner.append(&scroll);
inner.append(&more); inner.append(&more);
(inner.upcast(), (model, status, more, grid, retry)) (inner.upcast(), (model, status, more, grid, retry, upload))
} }
/// Wire the gallery: activating a tile downloads the photo and opens it; the /// Wire the gallery: activating a tile downloads the photo and opens it in our
/// "Load more" button appends the next page. /// in-app lightbox viewer; the "Load more" button appends the next page.
fn wire_gallery(ui: &Rc<Ui>, grid: &gtk4::GridView) { fn wire_gallery(ui: &Rc<Ui>, grid: &gtk4::GridView) {
let ui_open = ui.clone(); let ui_open = ui.clone();
grid.connect_activate(move |grid, pos| { grid.connect_activate(move |grid, pos| {
@@ -1454,34 +1609,513 @@ fn wire_gallery(ui: &Rc<Ui>, grid: &gtk4::GridView) {
.downcast_ref::<BoxedAnyObject>() .downcast_ref::<BoxedAnyObject>()
.map(|o| o.borrow::<PhotoItem>().uid.clone()); .map(|o| o.borrow::<PhotoItem>().uid.clone());
let Some(uid) = uid else { return }; let Some(uid) = uid else { return };
// Ignore a repeat activation of a photo already downloading. open_photo_viewer(&ui_open, uid);
if !ui_open.opening.borrow_mut().insert(uid.clone()) {
return;
}
ui_open.busy_begin();
let rx = spawn_request(
ui_open.dirs.control_socket(),
Request::OpenPhoto { uid: uid.clone() },
);
let ui = ui_open.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.opening.borrow_mut().remove(&uid);
match result {
Ok(Ok(Response::FilePath { path })) => open_path(&path),
Ok(Ok(Response::Error { message })) => {
tracing::error!("open photo failed: {message}")
}
_ => tracing::error!("open photo request failed"),
}
});
}); });
let ui_more = ui.clone(); let ui_more = ui.clone();
ui.gallery_more.clone().connect_clicked(move |_| { ui.gallery_more.clone().connect_clicked(move |_| {
load_gallery(&ui_more, true); load_gallery(&ui_more, true);
}); });
let ui_upload = ui.clone();
ui.gallery_upload.connect_clicked(move |_| {
let dialog = gtk4::FileDialog::builder()
.title("Select Photo to Upload")
.build();
let filter = gtk4::FileFilter::new();
filter.set_name(Some("Images"));
filter.add_mime_type("image/*");
let filters = gio::ListStore::new::<gtk4::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
let ui = ui_upload.clone();
let parent_win = ui.stack.root().and_downcast::<gtk4::Window>();
dialog.open(parent_win.as_ref(), gio::Cancellable::NONE, move |res| {
if let Ok(file) = res {
if let Some(path) = file.path() {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("photo.jpg")
.to_string();
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
tracing::error!("Failed to read photo: {e}");
return;
}
};
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("jpg")
.to_lowercase();
let media_type = match ext.as_str() {
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
_ => "image/jpeg",
};
let capture_time = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::UploadPhoto {
name,
media_type: media_type.to_string(),
bytes,
capture_time,
},
);
let ui_clone = ui.clone();
glib::spawn_future_local(async move {
let res = rx.recv().await;
ui_clone.busy_end();
match res {
Ok(Ok(Response::Ok { message })) => {
tracing::info!("Photo uploaded: {message}");
load_gallery(&ui_clone, false);
}
Ok(Ok(Response::Error { message })) => {
tracing::error!("Upload failed: {message}");
show_error_dialog(&ui_clone, &format!("Upload failed: {message}"));
}
_ => {
tracing::error!("Upload request failed");
show_error_dialog(
&ui_clone,
"Upload request failed (daemon unreachable).",
);
}
}
});
}
}
});
});
}
fn show_error_dialog(ui: &Rc<Ui>, message: &str) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>();
let dialog = adw::AlertDialog::builder()
.heading("Photo Operation")
.body(message)
.build();
dialog.add_response("ok", "OK");
dialog.present(parent.as_ref());
}
fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option<u32> {
for i in 0..model.n_items() {
if let Some(obj) = model.item(i) {
if let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() {
if boxed.borrow::<PhotoItem>().uid == uid {
return Some(i);
}
}
}
}
None
}
fn format_capture_time(secs: i64) -> String {
let date = glib::DateTime::from_unix_local(secs);
match date {
Ok(d) => match d.format("%Y-%m-%d %H:%M:%S") {
Ok(s) => s.to_string(),
Err(_) => "Unknown Date".to_string(),
},
Err(_) => "Unknown Date".to_string(),
}
}
fn load_photo(
ui: &Rc<Ui>,
uid: String,
picture: &gtk4::Picture,
spinner: &gtk4::Spinner,
status_label: &gtk4::Label,
current_path: Rc<RefCell<Option<String>>>,
) {
spinner.set_visible(true);
spinner.start();
picture.set_paintable(gtk4::gdk::Paintable::NONE);
status_label.set_visible(false);
*current_path.borrow_mut() = None;
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenPhoto { uid: uid.clone() },
);
let picture_clone = picture.clone();
let spinner_clone = spinner.clone();
let status_clone = status_label.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
spinner_clone.stop();
spinner_clone.set_visible(false);
match result {
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => {
picture_clone.set_paintable(Some(&texture));
*current_path.borrow_mut() = Some(path);
}
Err(e) => {
status_clone.set_label(&format!("Failed to load image: {e}"));
status_clone.set_visible(true);
}
},
Ok(Ok(Response::Error { message })) => {
status_clone.set_label(&format!("Error downloading photo: {message}"));
status_clone.set_visible(true);
}
_ => {
status_clone.set_label("Failed to connect to daemon");
status_clone.set_visible(true);
}
}
});
}
fn save_photo_to_disk(window: &gtk4::Window, source_path: &str, original_name: &str) {
let dialog = gtk4::FileDialog::builder()
.title("Save Photo")
.initial_name(original_name)
.build();
let source_path_str = source_path.to_string();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
if let Ok(file) = res {
if let Some(dest_path) = file.path() {
if let Err(e) = std::fs::copy(&source_path_str, &dest_path) {
tracing::error!("Failed to copy file to {:?}: {}", dest_path, e);
} else {
tracing::info!("Saved photo to {:?}", dest_path);
}
}
}
});
}
fn navigate_photo(
ui: &Rc<Ui>,
current_uid: &Rc<RefCell<String>>,
delta: i32,
picture: &gtk4::Picture,
spinner: &gtk4::Spinner,
status_label: &gtk4::Label,
title_label: &gtk4::Label,
prev_btn: &gtk4::Button,
next_btn: &gtk4::Button,
current_path: Rc<RefCell<Option<String>>>,
) {
let model = &ui.gallery_model;
let n = model.n_items();
if n == 0 {
return;
}
let uid_val = current_uid.borrow().clone();
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
let next_idx = (current_idx as i32 + delta).clamp(0, n as i32 - 1) as u32;
let Some(obj) = model.item(next_idx) else {
return;
};
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
return;
};
let photo = boxed.borrow::<PhotoItem>().clone();
*current_uid.borrow_mut() = photo.uid.clone();
prev_btn.set_sensitive(next_idx > 0);
next_btn.set_sensitive(next_idx < n - 1);
let date_str = format_capture_time(photo.capture_time);
title_label.set_label(&format!("Photo {} of {} • {}", next_idx + 1, n, date_str));
load_photo(
ui,
photo.uid.clone(),
picture,
spinner,
status_label,
current_path,
);
}
fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder()
.title("Photo Viewer")
.modal(true)
.transient_for(&parent)
.default_width(800)
.default_height(600)
.build();
window.add_css_class("photo-viewer-window");
let overlay = gtk4::Overlay::new();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Contain)
.hexpand(true)
.vexpand(true)
.build();
overlay.set_child(Some(&picture));
let prev_btn = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Previous Photo")
.halign(gtk4::Align::Start)
.valign(gtk4::Align::Center)
.build();
prev_btn.add_css_class("circular");
prev_btn.add_css_class("flat");
prev_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&prev_btn);
let next_btn = gtk4::Button::builder()
.icon_name("go-next-symbolic")
.tooltip_text("Next Photo")
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
next_btn.add_css_class("circular");
next_btn.add_css_class("flat");
next_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&next_btn);
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
top_bar.add_css_class("viewer-top-bar");
top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
let title_label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
title_label.add_css_class("viewer-title");
top_bar.append(&title_label);
let download_btn = gtk4::Button::builder()
.icon_name("document-save-symbolic")
.tooltip_text("Download to Disk")
.build();
download_btn.add_css_class("flat");
download_btn.add_css_class("viewer-action-btn");
top_bar.append(&download_btn);
let open_ext_btn = gtk4::Button::builder()
.icon_name("document-open-symbolic")
.tooltip_text("Open in External App")
.build();
open_ext_btn.add_css_class("flat");
open_ext_btn.add_css_class("viewer-action-btn");
top_bar.append(&open_ext_btn);
let close_btn = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Close")
.build();
close_btn.add_css_class("flat");
close_btn.add_css_class("viewer-action-btn");
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
let spinner = gtk4::Spinner::builder()
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.width_request(48)
.height_request(48)
.build();
overlay.add_overlay(&spinner);
let status_label = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
status_label.add_css_class("viewer-status");
overlay.add_overlay(&status_label);
window.set_child(Some(&overlay));
let current_uid = Rc::new(RefCell::new(initial_uid.clone()));
let current_path = Rc::new(RefCell::new(None::<String>));
let n = ui.gallery_model.n_items();
let initial_idx = find_photo_index(&ui.gallery_model, &initial_uid).unwrap_or(0);
prev_btn.set_sensitive(initial_idx > 0);
next_btn.set_sensitive(initial_idx < n - 1);
let mut initial_capture_time = 0;
if let Some(obj) = ui.gallery_model.item(initial_idx) {
if let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() {
initial_capture_time = boxed.borrow::<PhotoItem>().capture_time;
}
}
let date_str = format_capture_time(initial_capture_time);
title_label.set_label(&format!(
"Photo {} of {} • {}",
initial_idx + 1,
n,
date_str
));
load_photo(
ui,
initial_uid,
&picture,
&spinner,
&status_label,
current_path.clone(),
);
let w_close = window.clone();
close_btn.connect_clicked(move |_| {
w_close.close();
});
let w_download = window.clone();
let path_download = current_path.clone();
let uid_download = current_uid.clone();
download_btn.connect_clicked(move |_| {
if let Some(path) = path_download.borrow().as_deref() {
let uid_val = uid_download.borrow();
let name = format!("{}.jpg", uid_val);
save_photo_to_disk(&w_download, path, &name);
}
});
let path_ext = current_path.clone();
open_ext_btn.connect_clicked(move |_| {
if let Some(path) = path_ext.borrow().as_deref() {
open_path(path);
}
});
let ui_prev = ui.clone();
let uid_prev = current_uid.clone();
let pic_prev = picture.clone();
let spin_prev = spinner.clone();
let status_prev = status_label.clone();
let title_prev = title_label.clone();
let p_btn = prev_btn.clone();
let n_btn = next_btn.clone();
let path_prev = current_path.clone();
prev_btn.connect_clicked(move |_| {
navigate_photo(
&ui_prev,
&uid_prev,
-1,
&pic_prev,
&spin_prev,
&status_prev,
&title_prev,
&p_btn,
&n_btn,
path_prev.clone(),
);
});
let ui_next = ui.clone();
let uid_next = current_uid.clone();
let pic_next = picture.clone();
let spin_next = spinner.clone();
let status_next = status_label.clone();
let title_next = title_label.clone();
let p_btn = prev_btn.clone();
let n_btn = next_btn.clone();
let path_next = current_path.clone();
next_btn.connect_clicked(move |_| {
navigate_photo(
&ui_next,
&uid_next,
1,
&pic_next,
&spin_next,
&status_next,
&title_next,
&p_btn,
&n_btn,
path_next.clone(),
);
});
let key_controller = gtk4::EventControllerKey::new();
let ui_key = ui.clone();
let uid_key = current_uid.clone();
let pic_key = picture.clone();
let spin_key = spinner.clone();
let status_key = status_label.clone();
let title_key = title_label.clone();
let p_btn = prev_btn.clone();
let n_btn = next_btn.clone();
let path_key = current_path.clone();
let w_key = window.clone();
key_controller.connect_key_pressed(move |_, key, _keycode, _state| {
match key.name().as_deref() {
Some("Left") => {
let current_idx =
find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0);
if current_idx > 0 {
navigate_photo(
&ui_key,
&uid_key,
-1,
&pic_key,
&spin_key,
&status_key,
&title_key,
&p_btn,
&n_btn,
path_key.clone(),
);
}
glib::Propagation::Stop
}
Some("Right") => {
let n = ui_key.gallery_model.n_items();
let current_idx =
find_photo_index(&ui_key.gallery_model, &uid_key.borrow()).unwrap_or(0);
if current_idx < n - 1 {
navigate_photo(
&ui_key,
&uid_key,
1,
&pic_key,
&spin_key,
&status_key,
&title_key,
&p_btn,
&n_btn,
path_key.clone(),
);
}
glib::Propagation::Stop
}
Some("Escape") => {
w_key.close();
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
window.add_controller(key_controller);
window.present();
} }
/// Fetch a timeline page from the daemon. When `append` is false the model is /// Fetch a timeline page from the daemon. When `append` is false the model is