mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e71148fec | ||
|
|
2812e0c893 | ||
|
|
03dd8d8496 | ||
|
|
5e151bbfc5 |
No files matched your search
Generated
+79
-5
@@ -53,6 +53,18 @@ dependencies = [
|
|||||||
"aes",
|
"aes",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1278,6 +1290,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -1793,12 +1817,30 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2117,7 +2159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2345,6 +2387,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2772,7 +2825,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.1",
|
"clap 4.6.1",
|
||||||
@@ -2787,12 +2840,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"keyring",
|
"keyring",
|
||||||
"proton-drive-rs",
|
"proton-drive-rs",
|
||||||
"proton-sdk",
|
"proton-sdk",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
@@ -2803,7 +2857,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2816,7 +2870,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3406,6 +3460,20 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -4347,6 +4415,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vec_map"
|
name = "vec_map"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -27,6 +27,7 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "5"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
# Proton Drive client for Linux (unofficial)
|
# Proton Drive client for Linux (unofficial)
|
||||||
|
|
||||||
A fast, unofficial Proton Drive client for Linux. This client provides a FUSE files-on-demand virtual mount, a command-line interface (CLI), and a GTK4 desktop application with system tray integration.
|
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files-on-demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened.
|
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
||||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||||
- **GTK4 Desktop App**: Log in, browse files, and configure sync options through a modern, native Linux GUI.
|
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, and configure options through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) for safe token storage.
|
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||||
|
|
||||||
|
## Performance & Caching
|
||||||
|
|
||||||
|
The client includes several optimizations designed for high efficiency, a low memory footprint, and a responsive user experience:
|
||||||
|
|
||||||
|
- **On-Demand Block Cache**: Files are read in fixed 4 MiB blocks. For unpinned files, the client fetches and caches only the blocks containing the requested byte ranges. This enables fast sequential and sparse reads (e.g. media streaming or metadata scanning) without downloading entire files.
|
||||||
|
- **Disk-Backed Writes**: Large file writes are staged on disk in temporary scratch files (rather than fully buffered in RAM) and track modified byte intervals. Only the unedited remote segments are fetched at commit time, keeping memory usage minimal.
|
||||||
|
- **Non-Blocking GTK4 Loop**: To prevent UI freezes, all synchronous D-Bus credential checks, control socket requests, and cache usage calculations are offloaded to background worker threads or fetched asynchronously.
|
||||||
|
- **Flicker-Free UI Rendering**: The GTK4 application performs differential rendering of the pins list, only modifying list rows when the list content changes, preserving the user's scroll position and widget focus.
|
||||||
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
### GUI Application
|
### GUI Application
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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, ¬ifier, event);
|
apply_event(&state, &content, ¬ifier, 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)
|
||||||
|
|||||||
+657
-28
@@ -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: >k4::GridView) {
|
fn wire_gallery(ui: &Rc<Ui>, grid: >k4::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,508 @@ fn wire_gallery(ui: &Rc<Ui>, grid: >k4::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
|
||||||
|
&& 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)
|
||||||
|
&& let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>()
|
||||||
|
&& 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: >k4::Picture,
|
||||||
|
spinner: >k4::Spinner,
|
||||||
|
status_label: >k4::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: >k4::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
|
||||||
|
&& 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: >k4::Picture,
|
||||||
|
spinner: >k4::Spinner,
|
||||||
|
status_label: >k4::Label,
|
||||||
|
title_label: >k4::Label,
|
||||||
|
prev_btn: >k4::Button,
|
||||||
|
next_btn: >k4::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)
|
||||||
|
&& 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
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=0.1.2
|
pkgver=0.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
Reference in new issue
Block a user