Compare commits

...
Author SHA1 Message Date
nvrl bb9804498c added new folder icon, fixed db size (max log size), progress bars 2026-07-16 18:13:48 +02:00
nvrl a38d0371ec implemented shared files, folder uploads, caching 2026-07-16 01:18:03 +02:00
nvrl cc744d56c7 added photos thumbnail caching + bigger photos 2026-07-15 01:00:43 +02:00
nvrl ae0d374ec0 updated readme screenshots 2026-07-14 19:36:11 +02:00
nvrl c52d829a43 added trash 2026-07-14 14:55:54 +02:00
nvrl 30039c43d7 updated photos gallery 2026-07-14 12:40:33 +02:00
nvrl 6c67226ab0 version bump 2026-07-13 02:44:59 +02:00
nvrl 6f7269b9b6 updated to use github repo for sdk 2026-07-13 02:44:20 +02:00
nvrl dacacf51bb added local file search to prompt 2026-07-13 02:33:18 +02:00
nvrl 8f28f5fd22 improvements to the photos gallery 2026-07-13 02:15:08 +02:00
nvrl 924e07e85f updated prompt design 2026-07-10 16:03:31 +02:00
nvrl e77ab0ffa9 updated readme + workflows 2026-07-01 00:50:20 +02:00
nvrl d131d3dca8 implemented proton drive prompt 2026-07-01 00:21:42 +02:00
nvrl 5766bc4ed1 transfer ui 2026-07-01 00:02:35 +02:00
nvrl 35bc957cd5 transfer progress api 2026-06-30 23:52:41 +02:00
nvrl f9000f8540 version bump 2026-06-30 21:42:09 +02:00
nvrl d77d7ed817 version bump for sdk 2026-06-30 21:11:21 +02:00
nvrl 56f2b35aca version bump to 0.2.2 2026-06-30 18:39:11 +02:00
nvrl 56abe3fe14 added settings + non blocking ui actions 2026-06-30 18:38:38 +02:00
nvrl 5e71148fec version bump 0.2.1 2026-06-30 17:42:38 +02:00
nvrl 2812e0c893 clippy + pkgbuild 2026-06-30 17:41:52 +02:00
nvrl 03dd8d8496 implemented database for search 2026-06-30 17:35:18 +02:00
nvrl 5e151bbfc5 updated readme 2026-06-30 06:31:51 +02:00
nvrl 172ec4b0b7 version 0.2.0 2026-06-30 06:23:36 +02:00
nvrl ec4f5ea32f fmt 2026-06-30 06:23:00 +02:00
nvrl 81a0ecce54 added block cache 2026-06-30 06:22:40 +02:00
nvrl cc2f370670 lazy 2fa 2026-06-30 06:01:36 +02:00
nvrl b3e8e8ab96 performance fixes 2026-06-30 05:56:21 +02:00
nvrl 8e8d42ad3b updated ux 2026-06-30 05:50:20 +02:00
nvrl 99e80a7206 implemented robust fuse mount 2026-06-30 05:43:14 +02:00
nvrl ca30dceae7 version bump to 0.1.2 2026-06-30 05:00:51 +02:00
nvrl 13d45ce6f5 clippy 2026-06-30 04:59:15 +02:00
nvrl f31633cb3a fmt 2026-06-30 04:58:40 +02:00
nvrl 86fa8aef03 updated app name + fixed missing icon in pkgbuild 2026-06-30 04:58:03 +02:00
nvrl aa70077ad0 added images 2026-06-30 04:07:19 +02:00
36 changed files with 20158 additions and 904 deletions

No files matched your search

+3 -1
View File
@@ -62,6 +62,7 @@ jobs:
cp target/release/pdfs dist/binaries/
cp target/release/pdfs-tray dist/binaries/
cp target/release/pdfs-app dist/binaries/
cp target/release/pdfs-prompt dist/binaries/
# Create tarball
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
@@ -78,6 +79,7 @@ jobs:
cp target/release/pdfs deb/usr/bin/
cp target/release/pdfs-tray deb/usr/bin/
cp target/release/pdfs-app deb/usr/bin/
cp target/release/pdfs-prompt deb/usr/bin/
# Copy desktop integration files
cp packaging/io.narl.proton-drive-linux.desktop deb/usr/share/applications/
@@ -101,7 +103,7 @@ jobs:
Proton Drive client for Linux featuring:
- FUSE files-on-demand mount
- Command-line interface (CLI)
- GTK4 system tray and desktop application
- GTK4 system tray, desktop application, and search launcher
EOF
# Build Debian package
Generated
+985 -33
View File
File diff suppressed because it is too large. Load diff
+15 -4
View File
@@ -3,16 +3,15 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "0.1.0"
version = "0.4.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96"
[workspace.dependencies]
# Local Proton SDK crates (path deps until published; switch to versioned later).
proton-sdk = "0.1"
proton-drive-rs = "0.1"
proton-sdk = "0.1.6"
proton-drive-rs = "0.1.6"
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -26,9 +25,21 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
ignore = "0.4"
sha2 = "0.10"
# Thumbnail generation for photos the server has none for. Decoders only for the
# formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
rusqlite = { version = "0.32", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
clap = { version = "4", features = ["derive"] }
rpassword = "7"
fuser = "0.17"
libc = "0.2"
# Development override: build against the local SDK checkout until 0.1.6 is
# published to crates.io. Remove this block to consume the released crate.
[patch.crates-io]
proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" }
proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" }
+36 -5
View File
@@ -1,14 +1,44 @@
# 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
- **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.
- **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.
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) for safe token storage.
- **Fuzzy Search Launcher Prompt (HUD)**: A Google Drive-style search launcher (`pdfs-prompt`) for finding and opening files or folders instantly, ideal for binding to a system-wide hotkey.
- **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).
## 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
### GUI Application & Launcher
<table>
<tr>
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
</tr>
<tr>
<td align="center" colspan="2"><img src="images/settings.png" alt="Settings" width="50%"><br><sub><b>Settings</b></sub></td>
</tr>
</table>
---
@@ -53,6 +83,7 @@ The compiled binaries will be available under `target/release/`:
- `pdfs`: The CLI utility.
- `pdfs-app`: The GTK4 application.
- `pdfs-tray`: The tray status notifier.
- `pdfs-prompt`: The launcher prompt for quick HUD search.
---
@@ -85,7 +116,7 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
- Sets up the Rust compiler and caches build targets to speed up runs.
- Compiles the workspace members in release mode.
3. **Artifact Packaging**:
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`).
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
- Packs them into a Debian package (`.deb`).
4. **Publishing**:
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
File diff suppressed because it is too large. Load diff
+2
View File
@@ -16,5 +16,7 @@ serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
ignore.workspace = true
sha2.workspace = true
rusqlite.workspace = true
keyring.workspace = true
+104 -9
View File
@@ -7,12 +7,12 @@
//! path, so no fresh 2FA is required until the refresh token itself expires.
use keyring::Entry;
use proton_drive_rs::ProtonDriveClient;
use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
use crate::config::{APP_VERSION, KEYRING_SERVICE, USER_AGENT};
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
use crate::error::{Error, Result};
/// Fixed keyring account name for the single stored session blob.
@@ -32,6 +32,20 @@ pub struct StoredSession {
/// Mailbox (data) password — required by `ProtonDriveClient::new` to derive
/// the key chain. Lives only in the OS keyring, never on disk in cleartext.
pub mailbox_password: String,
/// The account's key salts, captured at login.
///
/// `core/v4/keys/salts` requires the `locked` scope, which only the access
/// token minted by a password login carries: once that token has been
/// rotated through `auth/v4/refresh` (as it is on every daemon start after
/// the first), the endpoint answers 403 and the key chain can no longer be
/// unlocked. Salts only change when the password changes, so store them
/// once and seed the Drive client with them on resume.
///
/// Empty for blobs written before this field existed; [`resume_client`]
/// then falls back to fetching (and, if the token is still scoped for it,
/// backfilling) them.
#[serde(default)]
pub key_salts: Vec<KeySalt>,
}
impl StoredSession {
@@ -53,7 +67,14 @@ impl StoredSession {
}
fn client_config() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
let (app_version, user_agent) = match AppDirs::new() {
Ok(dirs) => {
let config = dirs.load_config();
(config.app_version, config.user_agent)
}
Err(_) => (APP_VERSION.to_string(), USER_AGENT.to_string()),
};
ProtonClientConfiguration::new(app_version).with_user_agent(user_agent)
}
fn keyring_entry() -> Result<Entry> {
@@ -77,12 +98,24 @@ pub async fn login(
session.apply_second_factor_code(code.trim()).await?;
}
save(&session, password).await?;
// Grab the key salts while this access token still has the `locked` scope:
// after its first refresh it never will again, and without them no later
// resume can unlock the key chain. See `StoredSession::key_salts`.
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
let key_salts = client.account().key_salts().await?;
register_refresh_handler(&session, password.to_owned(), key_salts.clone());
save(&session, password, key_salts).await?;
Ok(())
}
/// Persist the session's current tokens + mailbox password to the keyring.
pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<()> {
/// Persist the session's current tokens, mailbox password and key salts.
pub async fn save(
session: &ProtonApiSession,
mailbox_password: &str,
key_salts: Vec<KeySalt>,
) -> Result<()> {
let tokens = session.current_tokens().await;
let stored = StoredSession {
session_id: session.session_id().as_str().to_owned(),
@@ -96,6 +129,7 @@ pub async fn save(session: &ProtonApiSession, mailbox_password: &str) -> Result<
PasswordMode::Dual => 2,
},
mailbox_password: mailbox_password.to_owned(),
key_salts,
};
let json = serde_json::to_string(&stored)?;
keyring_entry()?.set_password(&json)?;
@@ -130,7 +164,30 @@ pub fn logout() -> Result<()> {
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
let stored = load()?;
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
let client = ProtonDriveClient::new(&session, stored.mailbox_password.clone().into_bytes());
let password = stored.mailbox_password.clone();
// Blobs written before key salts were persisted have none: fetch them once
// more and write them back, which only works while the stored access token
// still carries the `locked` scope. If it doesn't, the account must be
// logged into again — no refresh can restore that scope.
let key_salts = if stored.key_salts.is_empty() {
register_refresh_handler(&session, password.clone(), Vec::new());
let probe = ProtonDriveClient::new(&session, password.clone().into_bytes());
let salts = probe.account().key_salts().await.map_err(|e| match &e {
proton_sdk::error::ProtonError::Api(api) if api.is_insufficient_scope() => {
Error::ReloginRequired
}
_ => e.into(),
})?;
save(&session, &password, salts.clone()).await?;
salts
} else {
stored.key_salts
};
register_refresh_handler(&session, password.clone(), key_salts.clone());
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
Ok((client, session))
}
@@ -141,6 +198,44 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
/// preserved. Call whenever the session may have rotated its tokens through the
/// 401-refresh path so a later [`resume_client`] presents a live refresh token.
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
let mailbox_password = load()?.mailbox_password;
save(session, &mailbox_password).await
let stored = load()?;
save(session, &stored.mailbox_password, stored.key_salts).await
}
fn register_refresh_handler(
session: &ProtonApiSession,
mailbox_password: String,
key_salts: Vec<KeySalt>,
) {
let session_id = session.session_id().as_str().to_owned();
let username = session.username().to_owned();
let user_id = session.user_id().as_str().to_owned();
let scopes = session.scopes().to_vec();
let password_mode = match session.password_mode() {
PasswordMode::Single => 1,
PasswordMode::Dual => 2,
};
session.http().set_on_tokens_refreshed(move |tokens| {
let stored = StoredSession {
session_id: session_id.clone(),
username: username.clone(),
user_id: user_id.clone(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scopes: scopes.clone(),
password_mode,
mailbox_password: mailbox_password.clone(),
key_salts: key_salts.clone(),
};
if let Ok(json) = serde_json::to_string(&stored)
&& let Ok(entry) = keyring_entry()
{
if let Err(e) = entry.set_password(&json) {
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring");
} else {
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
}
}
});
}
+539 -84
View File
@@ -19,14 +19,38 @@
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::db::Db;
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
/// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one
/// `download_range` fetch with no straddling.
pub const BLOCK_SIZE: u64 = 1 << 22;
/// Validity tag stored alongside a cached blob. A blob is fresh only if both
/// fields still match the node's current metadata.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
@@ -37,18 +61,22 @@ struct Meta {
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)]
pub struct Pin {
/// Node uid in `volume~link` display form.
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.
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
/// survives renames/moves of the node.
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
/// the DB once on open. Live pins are owned by the `pins` table.
#[derive(Serialize, Deserialize, Default)]
struct PinFile {
pins: BTreeMap<String, Pin>,
@@ -61,27 +89,118 @@ pub struct ContentCache {
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files.
thumb_dir: PathBuf,
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
/// sees them; blocks carry their own LRU budget.
block_dir: PathBuf,
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
/// Emptied on open so a crashed run leaves no orphans.
scratch_dir: PathBuf,
/// JSON pin registry path.
pins_path: PathBuf,
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
/// evicts least-recently-used *unpinned* blobs back under the cap. `0`
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
/// the cache over budget.
max_bytes: u64,
/// the cache over budget. Atomic so the daemon can retune it at runtime
/// (a Settings-page change) via [`set_budget`](Self::set_budget) without
/// taking a lock on every cache read.
max_bytes: AtomicU64,
/// 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 {
/// 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
/// directories are created if missing.
pub fn open(content_dir: PathBuf, pins_path: PathBuf, max_bytes: u64) -> Result<Self> {
/// directories are created if missing. `db` is the shared metadata database
/// 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)?;
let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?;
let block_dir = content_dir.join("blocks");
std::fs::create_dir_all(&block_dir)?;
// Scratch holds disk-backed write buffers; a previous run's leftovers are
// worthless, so start clean.
let scratch_dir = content_dir.join("scratch");
let _ = std::fs::remove_dir_all(&scratch_dir);
std::fs::create_dir_all(&scratch_dir)?;
if let Some(parent) = pins_path.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(Self { content_dir, thumb_dir, pins_path, max_bytes })
let cache = Self {
content_dir,
thumb_dir,
block_dir,
scratch_dir,
pins_path,
max_bytes: AtomicU64::new(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
@@ -145,10 +264,10 @@ impl ContentCache {
len: u64,
) -> Option<Vec<u8>> {
let blob = self.valid_blob(uid, mtime, size)?;
// Record the access for LRU: bump the blob's mtime to now. Best effort —
// a failed touch only makes eviction order slightly less accurate.
// Record the access for LRU in the cache index. Best effort — a failed
// 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 _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
if offset >= size {
return Some(Vec::new());
}
@@ -171,10 +290,130 @@ impl ContentCache {
std::fs::rename(&tmp, &blob)?;
let meta = serde_json::to_vec(&Meta { mtime, size })?;
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();
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 {
self.block_dir.join(self.block_key(uid, idx))
}
fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf {
self.block_dir
.join(format!("{}.b{idx}.meta", Self::key(uid)))
}
/// Serve cached block `idx` (a [`BLOCK_SIZE`]-aligned chunk) of `uid`, or
/// `None` on miss/stale. Validated against `(mtime, size)` like a whole-file
/// blob, so a new revision (which bumps the mtime) is detected. Bumps the
/// block's mtime for LRU, best effort.
pub fn cached_block(&self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Vec<u8>> {
let want = Meta { mtime, size };
let meta: Meta =
serde_json::from_slice(&std::fs::read(self.block_meta(uid, idx)).ok()?).ok()?;
if meta != want {
return None;
}
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
// 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();
f.read_to_end(&mut buf).ok()?;
Some(buf)
}
/// Store `bytes` as cached block `idx` of `uid`, tagged `(mtime, size)`.
/// Temp-file-then-rename like [`store`](Self::store); meta written last so a
/// crash mid-store fails validation. Enforces the block-cache LRU budget.
pub fn store_block(
&self,
uid: &NodeUid,
mtime: i64,
size: u64,
idx: u64,
bytes: &[u8],
) -> Result<()> {
let blob = self.block_blob(uid, idx);
let tmp = self
.block_dir
.join(format!("{}.b{idx}.tmp", Self::key(uid)));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &blob)?;
std::fs::write(
self.block_meta(uid, idx),
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();
Ok(())
}
/// Evict least-recently-used block-cache files until the block dir fits
/// `max_bytes`. No-op when the cap is disabled (`0`). All blocks are
/// evictable — pinned files are served from whole-file blobs, never blocks.
fn enforce_block_budget(&self) {
let cap = self.cap();
if cap == 0 {
return;
}
// LRU-ordered (oldest first) block entries straight from the index — no
// directory scan. All blocks are evictable; pinned files are served from
// whole-file blobs, never blocks.
let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOCK) else {
return;
};
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
if total <= cap {
return;
}
for (key, size) in entries {
if total <= cap {
break;
}
// `key` is the block file's name (and its `.meta` sibling's stem).
let _ = std::fs::remove_file(self.block_dir.join(&key));
let _ = std::fs::remove_file(self.block_dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
total = total.saturating_sub(size);
}
}
/// Create a fresh, empty read-write scratch file for a disk-backed write
/// handle. Returns the open file and its path (for cleanup on release).
pub fn create_scratch(&self) -> Result<(std::fs::File, PathBuf)> {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let path = self.scratch_dir.join(format!(
"w-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.read(true)
.write(true)
.open(&path)?;
Ok((file, path))
}
/// Remove any cached blob + meta for `uid`, including its thumbnails (best
/// effort; absence is fine). Thumbnails are tied to the revision, so a
/// content eviction must drop them too.
@@ -185,6 +424,24 @@ impl ContentCache {
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
}
// 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));
if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
for entry in rd.flatten() {
if entry
.file_name()
.to_str()
.is_some_and(|n| n.starts_with(&prefix))
{
let _ = std::fs::remove_file(entry.path());
}
}
}
// 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 {
@@ -192,7 +449,8 @@ impl ContentCache {
}
fn thumb_meta(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
self.thumb_dir.join(format!("{}.t{ttype}.meta", Self::key(uid)))
self.thumb_dir
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
}
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
@@ -263,97 +521,84 @@ impl ContentCache {
/// cache already fits. Pinned blobs are skipped, so a cache held entirely by
/// pins can legitimately stay over budget.
fn enforce_budget(&self) {
if self.max_bytes == 0 {
let cap = self.cap();
if cap == 0 {
return;
}
// Pinned blobs (by cache key) are exempt from eviction.
let pinned: HashSet<String> =
self.load_pins().pins.keys().map(|uid| Self::key_str(uid)).collect();
// 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
.db
.pinned_uids()
.unwrap_or_default()
.iter()
.map(|uid| Self::key_str(uid))
.collect();
// Scan blobs (the `<key>` files; skip `.meta`/`.tmp` siblings), record
// size + last-access (mtime) for each.
let mut total: u64 = 0;
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
let Ok(rd) = std::fs::read_dir(&self.content_dir) else {
// LRU-ordered (oldest first) blob entries from the index — no directory
// scan. Pinned blobs still count toward the total (so pins alone can hold
// the cache over budget) but are never chosen as victims.
let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) else {
return;
};
for entry in rd.flatten() {
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 {
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
if total <= cap {
return;
}
// Oldest access first.
blobs.sort_by_key(|(_, _, atime)| *atime);
for (path, len, _) in blobs {
if total <= self.max_bytes {
for (key, size) in entries {
if total <= cap {
break;
}
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(path.with_extension("meta"));
total = total.saturating_sub(len);
if pinned.contains(&key) {
continue; // counts toward total but is never a victim
}
// `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 {
std::fs::read(&self.pins_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.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).
/// Whether `uid` is pinned — directly, or because an ancestor folder is
/// pinned recursively (resolved in the DB against the node tree). Independent
/// of whether its blob is currently cached. Best effort: a DB error reads as
/// "not pinned" rather than failing the calling FUSE op.
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
/// already cached the content via [`store`](Self::store).
pub fn add_pin(&self, uid: &NodeUid, path: &Path) -> Result<()> {
let mut file = self.load_pins();
let key = uid.to_string();
file.pins.insert(
key.clone(),
Pin { uid: key, path: path.display().to_string() },
);
self.save_pins(&file)
/// Record `uid` as pinned under `path`. `recursive` marks a folder pin whose
/// whole subtree is kept. The caller is responsible for having cached the
/// content (a file via [`store`](Self::store); a folder's descendants by the
/// daemon walking the subtree).
pub fn add_pin(&self, uid: &NodeUid, path: &Path, recursive: bool) -> Result<()> {
self.db
.pin_add(&uid.to_string(), &path.display().to_string(), recursive)
}
/// 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<()> {
let mut file = self.load_pins();
if file.pins.remove(&uid.to_string()).is_some() {
self.save_pins(&file)?;
}
self.db.pin_remove(&uid.to_string())?;
self.evict(uid);
Ok(())
}
/// All pinned files, ordered by uid.
/// All pins (files and recursive folders), ordered by uid.
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
@@ -384,7 +629,61 @@ impl ContentCache {
/// Configured soft byte cap (`0` = unlimited), for display alongside
/// [`usage`](Self::usage).
pub fn budget(&self) -> u64 {
self.max_bytes
self.cap()
}
/// The current soft byte cap. A plain atomic load; the value can change
/// under [`set_budget`](Self::set_budget) while the daemon runs.
fn cap(&self) -> u64 {
self.max_bytes.load(Ordering::Relaxed)
}
/// Retune the soft byte cap (`0` = unlimited) at runtime and immediately
/// enforce it: blobs and blocks are LRU-evicted back under the new cap (a
/// lower cap frees space now; a higher one is a no-op until the next store).
/// Called by the daemon when the Settings page changes the cache budget.
pub fn set_budget(&self, bytes: u64) {
self.max_bytes.store(bytes, Ordering::Relaxed);
self.enforce_budget();
self.enforce_block_budget();
}
/// Delete every *unpinned* cached blob plus all on-demand block chunks,
/// keeping pinned files intact. Pinned files keep their whole-file blobs
/// (and are never served from blocks), so dropping all blocks is safe.
/// Returns the number of bytes freed, for a user-facing confirmation.
pub fn clear_unpinned(&self) -> u64 {
// Pinned blobs are exempt; resolve them to cache keys to match filenames.
let pinned: HashSet<String> = self
.db
.pinned_uids()
.unwrap_or_default()
.iter()
.map(|uid| Self::key_str(uid))
.collect();
let mut freed = 0u64;
// Unpinned whole-file blobs.
if let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) {
for (key, size) in entries {
if pinned.contains(&key) {
continue;
}
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);
freed = freed.saturating_add(size);
}
}
// Every on-demand block (transient partial reads, re-fetched on demand).
if let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOCK) {
for (key, size) in entries {
let _ = std::fs::remove_file(self.block_dir.join(&key));
let _ = std::fs::remove_file(self.block_dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
freed = freed.saturating_add(size);
}
}
freed
}
}
@@ -427,10 +726,12 @@ mod tests {
fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) {
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let c = ContentCache::open(
dir.path().join("content"),
dir.path().join("pins.json"),
max_bytes,
db,
)
.unwrap();
(c, dir)
@@ -454,6 +755,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]
fn stale_metadata_is_a_miss() {
let (c, _d) = cache();
@@ -466,12 +801,36 @@ mod tests {
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]
fn pin_lifecycle_evicts_on_unpin() {
let (c, _d) = cache();
let u = uid("a");
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_eq!(c.list_pins().len(), 1);
assert_eq!(c.list_pins()[0].path, "docs/a.txt");
@@ -511,7 +870,7 @@ mod tests {
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
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));
c.store(&b, 1, 4, b"bbbb").unwrap();
// Over budget: `a` is pinned (oldest) so `b` must be the victim.
@@ -522,6 +881,43 @@ mod tests {
assert!(c.is_cached(&d, 1, 4));
}
#[test]
fn clear_unpinned_keeps_pinned() {
let (c, _d) = cache();
let (a, b) = (uid("a"), uid("b"));
c.store(&a, 1, 4, b"aaaa").unwrap();
c.add_pin(&a, Path::new("a"), false).unwrap();
c.store(&b, 1, 4, b"bbbb").unwrap();
// A stray on-demand block of the unpinned file is purged too.
c.store_block(&b, 1, 8, 0, b"bbbb").unwrap();
let freed = c.clear_unpinned();
assert_eq!(freed, 8, "one 4-byte blob + one 4-byte block freed");
assert!(c.is_cached(&a, 1, 4), "pinned blob survives purge");
assert!(!c.is_cached(&b, 1, 4), "unpinned blob purged");
assert!(c.cached_block(&b, 1, 8, 0).is_none(), "block purged");
}
#[test]
fn set_budget_evicts_immediately() {
// Start unlimited so three blobs all fit, then tighten the cap.
let (c, _d) = cache();
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&d, 1, 4, b"dddd").unwrap();
// Tightening to 8 bytes evicts the LRU unpinned blob(s) now, not on the
// next store.
c.set_budget(8);
assert!(!c.is_cached(&a, 1, 4), "oldest blob evicted on tighten");
assert!(c.is_cached(&b, 1, 4));
assert!(c.is_cached(&d, 1, 4));
assert_eq!(c.budget(), 8);
}
#[test]
fn thumbnail_store_read_and_evict() {
let (c, _d) = cache();
@@ -546,4 +942,63 @@ mod tests {
c.evict(&u);
assert!(!c.is_cached(&u, 1, 3));
}
#[test]
fn block_store_then_read() {
let (c, _d) = cache();
let u = uid("a");
c.store_block(&u, 100, 4096, 0, b"block-zero").unwrap();
assert_eq!(c.cached_block(&u, 100, 4096, 0).unwrap(), b"block-zero");
// A different index is a separate cache entry, absent here.
assert!(c.cached_block(&u, 100, 4096, 1).is_none());
// A new revision (mtime/size bump) invalidates the block.
assert!(c.cached_block(&u, 101, 4096, 0).is_none());
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
}
#[test]
fn evict_drops_blocks() {
let (c, _d) = cache();
let u = uid("a");
c.store_block(&u, 1, 8, 0, b"aaaa").unwrap();
c.store_block(&u, 1, 8, 1, b"bbbb").unwrap();
c.evict(&u);
assert!(c.cached_block(&u, 1, 8, 0).is_none());
assert!(c.cached_block(&u, 1, 8, 1).is_none());
}
#[test]
fn block_budget_evicts_lru() {
// Cap fits two 4-byte blocks but not three.
let (c, _d) = cache_capped(8);
let u = uid("a");
c.store_block(&u, 1, 12, 0, b"aaaa").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_block(&u, 1, 12, 1, b"bbbb").unwrap();
// Touch block 0 so block 1 is the least-recently-used.
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(c.cached_block(&u, 1, 12, 0).is_some());
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_block(&u, 1, 12, 2, b"cccc").unwrap();
assert!(
c.cached_block(&u, 1, 12, 0).is_some(),
"recently-read survives"
);
assert!(c.cached_block(&u, 1, 12, 1).is_none(), "LRU block evicted");
assert!(c.cached_block(&u, 1, 12, 2).is_some(), "newest survives");
}
#[test]
fn scratch_file_is_writable_and_isolated() {
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
let (c, _d) = cache();
let (mut f1, p1) = c.create_scratch().unwrap();
let (_f2, p2) = c.create_scratch().unwrap();
assert_ne!(p1, p2, "each scratch file is unique");
f1.write_all(b"scratch").unwrap();
f1.seek(SeekFrom::Start(0)).unwrap();
let mut s = String::new();
f1.read_to_string(&mut s).unwrap();
assert_eq!(s, "scratch");
}
}
+126
View File
@@ -3,6 +3,7 @@
use std::path::PathBuf;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
@@ -18,6 +19,42 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
/// Configuration structure allowing the user to customize client identification headers.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AppConfig {
pub app_version: String,
pub user_agent: String,
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
/// writes an explicit value here. Defaulted for configs predating the field.
#[serde(default)]
pub cache_budget: Option<u64>,
/// Mountpoint the daemon mounts at. `None` means
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
/// path here. Defaulted for configs predating the field.
#[serde(default)]
pub mountpoint: Option<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
app_version: APP_VERSION.to_string(),
user_agent: USER_AGENT.to_string(),
cache_budget: None,
mountpoint: None,
}
}
}
impl AppConfig {
/// The effective cache budget in bytes: the user's explicit choice, or
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
pub fn resolved_cache_budget(&self) -> u64 {
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
}
}
/// Resolved XDG locations for state, cache, and the default mountpoint.
pub struct AppDirs {
dirs: ProjectDirs,
@@ -30,6 +67,41 @@ impl AppDirs {
Ok(Self { dirs })
}
/// Configuration file path (e.g. ~/.config/proton-drive-linux/config.json).
pub fn config_path(&self) -> PathBuf {
self.dirs.config_dir().join("config.json")
}
/// Load config from disk, creating default if missing.
pub fn load_config(&self) -> AppConfig {
let path = self.config_path();
if path.exists()
&& let Ok(content) = std::fs::read_to_string(&path)
&& let Ok(config) = serde_json::from_str::<AppConfig>(&content)
{
return config;
}
let default_config = AppConfig::default();
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
let _ = std::fs::create_dir_all(self.dirs.config_dir());
let _ = std::fs::write(&path, content);
}
default_config
}
/// Persist `config` to [`config_path`](Self::config_path), creating the
/// config dir if missing. The Settings page calls this after editing the
/// cache budget or mountpoint; the daemon re-reads the file on its next mount.
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
let path = self.config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serde_json::to_string_pretty(config)?)?;
Ok(())
}
/// Persistent state: inode map / cache index DB lives here.
pub fn state_dir(&self) -> PathBuf {
// `state_dir` is Linux-only in `directories`; fall back to data dir.
@@ -54,11 +126,28 @@ impl AppDirs {
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.
pub fn control_socket(&self) -> PathBuf {
self.state_dir().join("control.sock")
}
/// Unix domain socket the tray uses to ensure single instance.
pub fn tray_socket(&self) -> PathBuf {
self.state_dir().join("tray.sock")
}
/// The user's home directory — the root the daemon indexes for local
/// (non-Drive) file search. `None` if it cannot be resolved.
pub fn home_dir(&self) -> Option<PathBuf> {
directories::UserDirs::new().map(|u| u.home_dir().to_path_buf())
}
/// Default mountpoint when the user does not pass one explicitly.
pub fn default_mountpoint(&self) -> PathBuf {
directories::UserDirs::new()
@@ -66,10 +155,47 @@ impl AppDirs {
.unwrap_or_else(|| PathBuf::from("/tmp/ProtonDrive"))
}
/// The effective mountpoint: the config's explicit path, or
/// [`default_mountpoint`](Self::default_mountpoint) when unset.
pub fn resolved_mountpoint(&self, config: &AppConfig) -> PathBuf {
config
.mountpoint
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| self.default_mountpoint())
}
/// Create state + cache dirs if missing.
pub fn ensure(&self) -> Result<()> {
std::fs::create_dir_all(self.state_dir())?;
std::fs::create_dir_all(self.cache_dir())?;
let _ = std::fs::create_dir_all(self.dirs.config_dir());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_config_serialization() {
let config = AppConfig {
app_version: "external-drive-test-client@1.0.0".to_string(),
user_agent: "test-agent/1.0".to_string(),
cache_budget: Some(1234),
mountpoint: Some("/tmp/x".to_string()),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
assert_eq!(decoded.user_agent, "test-agent/1.0");
}
#[test]
fn test_default_app_config() {
let default_config = AppConfig::default();
assert_eq!(default_config.app_version, APP_VERSION);
assert_eq!(default_config.user_agent, USER_AGENT);
}
}
+701 -6
View File
@@ -44,15 +44,439 @@ pub enum Request {
/// List a directory for the in-app file browser. `path` is
/// mountpoint-relative (empty or "." = the mount root).
ListDir { path: String },
/// Fetch a page of the photos timeline, newest first. Thumbnails for the
/// page are fetched into the cache and their on-disk paths returned.
/// Fetch a page of the photos timeline, newest first. Returns metadata only:
/// a thumbnail path comes back only for photos already in the cache, so the
/// reply never waits on the network. Front-ends ask for the thumbnails they
/// actually display with [`Request::PhotoThumbs`].
PhotosTimeline { offset: usize, limit: usize },
/// Fetch thumbnails for the given photo uids, downloading the ones not
/// already cached (one batched round-trip) and replying with their on-disk
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
PhotoThumbs { uids: Vec<String> },
/// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String },
/// Upload a photo with the given name, media type, and content bytes.
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
/// on-disk path so the front-end can open it with the default app. `path`
/// is mountpoint-relative.
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 },
/// Search the daemon's index of *local* (non-Drive) files on this machine.
/// Independent of [`Request::Search`] so a front-end can fire both at once and
/// render whichever lands first. Replies with [`Response::LocalResults`].
SearchLocal { query: String, limit: usize },
/// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a
/// single path component (no separators). Replies with [`Response::Ok`].
Rename { path: String, new_name: String },
/// Move a file or folder into a new parent folder. Both `path` and
/// `new_parent` are mountpoint-relative. Replies with [`Response::Ok`].
Move { path: String, new_parent: String },
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
/// [`Response::Ok`].
Delete { path: String },
/// Create a new folder named `name` under the mountpoint-relative `parent`.
/// Replies with [`Response::Ok`].
CreateFolder { parent: String, name: String },
/// Upload a file named `name` with content `bytes` into the
/// mountpoint-relative `parent` folder. Replies with [`Response::Ok`].
UploadFile {
parent: String,
name: String,
bytes: Vec<u8>,
},
/// Bulk-upload local files and/or directory trees into the mountpoint-relative
/// `parent` folder. `sources` are absolute paths on the daemon's own
/// filesystem (the daemon is local): each file is uploaded, each directory is
/// recreated remotely and walked recursively. The daemon acks immediately with
/// [`Response::Ok`] and does the work in the background — a big tree far
/// outlasts the socket read timeout — so progress is observed through
/// [`Request::GetQueueStatus`] and completion through the transfer count
/// falling to zero.
UploadPaths {
parent: String,
sources: Vec<String>,
},
/// Delete all unpinned cached blobs and on-demand blocks, keeping pinned
/// files intact. Replies with [`Response::Ok`] reporting the bytes freed.
PurgeCache,
/// Retune the on-disk cache's soft byte cap at runtime (`0` = unlimited) and
/// persist it to config so the next mount keeps it. Replies with
/// [`Response::Ok`].
SetCacheBudget { bytes: u64 },
/// Snapshot what the daemon is working on: in-flight transfers (active
/// uploads/downloads) and the longer jobs around them (scans, folder
/// skeletons, the local index, sync passes). Replies with
/// [`Response::Transfers`]. Cheap to poll: the daemon keeps the registry in
/// memory, so a front-end can render a live progress widget.
GetQueueStatus,
/// List what is in the account's trash. Replies with [`Response::Entries`];
/// a trashed node has no path inside the mount, so each entry carries only
/// its `uid` — the handle for [`Request::Restore`] and
/// [`Request::DeleteForever`] — and its `path` is empty.
ListTrash,
/// Restore trashed nodes, by uid, to the folders they were trashed from.
/// Replies with [`Response::Ok`].
Restore { uids: Vec<String> },
/// Permanently delete trashed nodes by uid. Irreversible: the content is
/// gone from Proton Drive, not moved. Replies with [`Response::Ok`].
DeleteForever { uids: Vec<String> },
/// Permanently delete everything in the trash. Irreversible.
/// Replies with [`Response::Ok`].
EmptyTrash,
// ---- devices ----------------------------------------------------------
/// List the account's registered devices. Replies with [`Response::Devices`].
ListDevices,
/// Rename a device by its uid. Replies with [`Response::Ok`].
RenameDevice { uid: String, name: String },
/// Delete (deregister) a device by its uid. Replies with [`Response::Ok`].
DeleteDevice { uid: String },
// ---- device folder sync (devices.md) ----------------------------------
/// Add a local folder to this machine's device, uploading its tree and
/// registering the device on first use. Replies with [`Response::Ok`].
AddSyncFolder { local_path: String },
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
ListSyncFolders,
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
/// Replies with [`Response::Ok`].
RemoveSyncFolder { id: i64, delete_remote: bool },
/// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies
/// with [`Response::Ok`].
SetSyncFolderMode { id: i64, mode: String },
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
/// Replies with [`Response::Ok`].
SyncNow { id: Option<i64> },
// ---- sharing a node ---------------------------------------------------
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the
/// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"),
/// with an optional email `message`. Replies with [`Response::Ok`].
ShareNode {
path: String,
emails: Vec<String>,
role: String,
message: Option<String>,
},
/// List the members, pending invitations and public link of the node at
/// mountpoint-relative `path`. Replies with [`Response::Share`].
ListShare { path: String },
/// Change the role of a share entry (member or pending invitation) on the node
/// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]).
/// Replies with [`Response::Ok`].
UpdateShareRole {
path: String,
id: String,
kind: ShareEntryKind,
role: String,
},
/// Remove a share entry (member, pending Proton invite, or external invite)
/// from the node at `path`. Replies with [`Response::Ok`].
RemoveShareEntry {
path: String,
id: String,
kind: ShareEntryKind,
},
/// Create a public link on the node at `path`. `role` is "viewer" or "editor";
/// `password` optionally adds a custom password; `expires` is an optional Unix
/// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL).
CreatePublicLink {
path: String,
role: String,
password: Option<String>,
expires: Option<i64>,
},
/// Remove the public link `id` from the node at `path`. Replies with
/// [`Response::Ok`].
RemovePublicLink { path: String, id: String },
// ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others — collaborative shares that still
/// have members, pending invitations or a public link. Replies with
/// [`Response::SharedByMe`]. Each item carries the share's state so the
/// front-end can render it without a follow-up per node.
ListSharedByMe,
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted. Replies with
/// [`Response::Entries`] (each entry carries its `uid`; `path` is empty since
/// the item lives outside the mount tree).
ListSharedWithMe,
/// Leave a shared node by its `uid`, giving up my access. Replies with
/// [`Response::Ok`].
LeaveShared { uid: String },
// ---- incoming invitations ---------------------------------------------
/// List invitations addressed to me, pending accept or reject. Replies with
/// [`Response::Invitations`].
ListInvitations,
/// Accept the invitation `id`, gaining access to the shared node. Replies with
/// [`Response::Ok`].
AcceptInvitation { id: String },
/// Reject the invitation `id`, declining access. Replies with [`Response::Ok`].
RejectInvitation { id: String },
// ---- bookmarks --------------------------------------------------------
/// List public links saved to my account. Replies with [`Response::Bookmarks`].
ListBookmarks,
/// Save a public link `url` (optionally password-protected) as a bookmark.
/// Replies with [`Response::Ok`].
CreateBookmark {
url: String,
password: Option<String>,
},
/// Remove a saved bookmark by its `token`. Replies with [`Response::Ok`].
DeleteBookmark { token: String },
// ---- activity ---------------------------------------------------------
/// Fetch the daemon's recent activity log, newest first, capped at `limit`
/// entries. Replies with [`Response::Activity`]. The log is persisted, so it
/// survives a daemon restart: it records the mutations and transfers the
/// daemon performs (uploads, downloads, deletes, renames, shares, sync
/// passes, …), so a front-end can show a running "what happened" feed
/// without re-deriving it from anywhere.
ListActivity { limit: usize },
}
/// Which kind of share entry a [`ShareEntry`] is, and which collection an
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`] targets.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShareEntryKind {
/// An accepted member (identified by its membership id).
Member,
/// A pending invitation to a Proton user (identified by its invitation id).
ProtonInvite,
/// A pending invitation to a non-Proton email (identified by its invitation id).
ExternalInvite,
}
/// A registered device in a [`Response::Devices`] listing.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DeviceInfo {
/// Device uid — the handle for [`Request::RenameDevice`]/[`Request::DeleteDevice`].
pub uid: String,
/// Decrypted device name, or a placeholder when it could not be decrypted.
pub name: String,
/// Platform: "Windows", "MacOs" or "Linux".
pub device_type: String,
/// Last sync time, epoch seconds; `None` if it never synced.
pub last_sync: Option<i64>,
}
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncFolderInfo {
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
pub id: i64,
/// Absolute local folder path.
pub local_path: String,
/// The uid of the folder's remote root under the device root.
pub remote_uid: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
/// `idle` | `syncing` | `error` | `conflict`.
pub state: String,
/// Last successful sync, epoch seconds; `0` if never.
pub last_sync: i64,
/// What the folder's sync pass is doing right now, or `None` when no pass is
/// running. Live daemon state, not a stored column.
#[serde(default)]
pub progress: Option<SyncProgress>,
}
/// Which stage a running sync pass is in, in a [`SyncProgress`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncPhase {
/// Walking the local tree, the remote tree and the stored baseline to work
/// out what changed. No per-item counts exist yet.
Scanning,
/// Applying the diff: creating folders, uploading, downloading, deleting.
Applying,
}
/// A snapshot of a sync pass in flight (in [`SyncFolderInfo::progress`]), so a
/// front-end can say what the daemon is doing rather than just "syncing".
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncProgress {
pub phase: SyncPhase,
/// Items applied so far this pass.
pub done: usize,
/// Items known to need applying. This *grows* during a pass: paths are
/// classified depth by depth (a folder must exist remotely before its
/// children can be queued), so the total is what has been discovered so far,
/// not a figure fixed up front.
pub total: usize,
/// The name of an item currently being applied, or empty between items.
/// Several run at once; this is just the most recently started.
pub current: String,
}
/// One member or pending invitation on a node's share (in [`Response::Share`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ShareEntry {
/// Membership id (members) or invitation id (invitations) — the handle for
/// [`Request::UpdateShareRole`]/[`Request::RemoveShareEntry`].
pub id: String,
/// The member's / invitee's email address.
pub email: String,
/// Role: "viewer", "editor", "admin" or "inherited".
pub role: String,
/// Whether this is a member, a pending Proton invite, or an external invite.
pub kind: ShareEntryKind,
}
/// A node's public link (in [`Response::Share`] / [`Response::PublicLink`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PublicLinkInfo {
/// Public-link id — the handle for [`Request::RemovePublicLink`].
pub id: String,
/// The shareable URL including the `#password` fragment, when known (always on
/// creation; absent when only listed).
pub url: Option<String>,
/// Role granted to anyone with the link ("viewer" or "editor").
pub role: String,
/// Expiry, epoch seconds, if set.
pub expires: Option<i64>,
/// Whether a custom password additionally protects the link.
pub has_password: bool,
}
/// One invitation addressed to me (in [`Response::Invitations`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InvitationInfo {
/// Invitation id — the handle for [`Request::AcceptInvitation`]/[`Request::RejectInvitation`].
pub id: String,
/// The email of the user who shared the item.
pub inviter_email: String,
/// The shared item's decrypted name, when available.
pub name: Option<String>,
/// Whether the shared item is a folder.
pub is_dir: bool,
}
/// One saved public link (in [`Response::Bookmarks`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BookmarkInfo {
/// Bookmark token — the handle for [`Request::DeleteBookmark`].
pub token: String,
/// The full public URL including the `#password` fragment.
pub url: String,
/// The bookmarked item's decrypted name, when available.
pub name: Option<String>,
/// Whether the bookmarked item is a folder.
pub is_dir: bool,
}
/// One node I have shared with others (in [`Response::SharedByMe`]). Summarizes
/// the share's state so the "Shared" view renders in one pass: how many people
/// have access, how many invitations are still pending, and the public link if
/// the node has one.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SharedItem {
/// Node uid in `volume~link` form — the handle for opening the node's share
/// (via its mount path) or its details.
pub uid: String,
/// The shared node's decrypted name.
pub name: String,
pub is_dir: bool,
/// Mountpoint-relative path, when the daemon can resolve it (the node lives in
/// my own tree). Empty when the path is unknown.
#[serde(default)]
pub path: String,
/// Accepted members (people who already have access), excluding me.
pub member_count: usize,
/// Pending invitations (Proton + external) not yet accepted.
pub invite_count: usize,
/// The node's public link, if it has one.
pub link: Option<PublicLinkInfo>,
}
/// What happened, in an [`ActivityEntry`]. Kept coarse: a front-end maps each to
/// an icon and a verb, and the human detail lives in the entry's fields.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivityKind {
Upload,
Download,
/// A whole sync pass over one folder, summarising what it moved.
Sync,
Rename,
Move,
CreateFolder,
Trash,
Restore,
DeleteForever,
EmptyTrash,
Share,
PublicLink,
Unshare,
}
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
/// first. Records a mutation or transfer the daemon performed, with enough
/// context to read as a sentence: "Uploaded report.pdf to /docs".
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ActivityEntry {
/// When it happened, epoch seconds.
pub time: i64,
pub kind: ActivityKind,
/// The primary item's name (a file/folder name, or a count like "3 items").
pub target: String,
/// Extra context: a destination path, a new name, an invitee, an error
/// message. Empty when there is nothing to add.
#[serde(default)]
pub detail: String,
/// Whether the operation succeeded. A failed entry still appears, so the log
/// shows what was attempted.
pub ok: bool,
}
/// Which way an active transfer is moving, in a [`TransferItem`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferDirection {
Download,
Upload,
}
/// One long-running daemon job in a [`Response::Transfers`] snapshot: work that
/// takes long enough to need reporting but doesn't move bytes over the wire —
/// walking a local tree, building a remote folder skeleton, indexing `$HOME`.
/// Byte-moving work is a [`TransferItem`] instead.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct JobItem {
/// What the job is, as a front-end would title it ("Uploading files").
pub title: String,
/// What it is doing right now ("Scanning Photos/2024"), or empty.
pub detail: String,
/// Steps finished so far.
pub done: u64,
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
/// May *grow* mid-job as more work is discovered.
pub total: u64,
}
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TransferItem {
/// Node uid in `volume~link` form (empty for an upload whose uid isn't known
/// until the draft is sealed).
pub uid: String,
/// File name being transferred.
pub name: String,
pub direction: TransferDirection,
/// Bytes moved so far.
pub bytes_completed: u64,
/// Total bytes expected, or `0` when unknown (indeterminate progress).
pub bytes_total: u64,
/// Average throughput since the transfer began, bytes per second.
pub speed_bytes_sec: u64,
}
/// One entry in a [`Request::ListDir`] listing.
@@ -67,8 +491,51 @@ pub struct DirEntry {
pub modified: i64,
/// Whether the file is pinned to this device.
pub pinned: bool,
/// Whether the file's full content is present in the local cache (a current,
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
/// clients/daemons predating the field.
#[serde(default)]
pub cached: bool,
/// Node uid in `volume~link` form, for follow-up requests.
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) and for a [`Request::ListTrash`] listing (a
/// trashed node has no path inside the mount at all); populated when an entry
/// can live anywhere in the tree, as for search hits rendered through the
/// browser.
#[serde(default)]
pub path: String,
}
/// 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 hit in a [`Request::SearchLocal`] result: a file on this machine, outside
/// Proton Drive. Unlike [`SearchHit`] there is no uid or pin state — the file is
/// already local, so the front-end opens `path` directly.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LocalHit {
pub name: String,
/// Absolute path on this machine.
pub path: String,
pub is_dir: bool,
pub size: u64,
/// Modification time, epoch seconds.
pub modified: i64,
}
/// One photo in a [`Request::PhotosTimeline`] page.
@@ -78,30 +545,111 @@ pub struct PhotoItem {
pub uid: String,
/// Capture time, epoch seconds (the timeline is newest-first).
pub capture_time: i64,
/// On-disk path to the cached thumbnail, if one was available/fetched.
/// On-disk path to the cached thumbnail, when one is *already* cached. A
/// `None` here means "not fetched yet", not "has no thumbnail" — ask for it
/// with [`Request::PhotoThumbs`].
pub thumb_path: Option<String>,
/// File name, when the daemon knows it.
pub name: Option<String>,
/// Aspect ratio (w/h), remembered by the daemon from the last time this
/// photo's thumbnail was decoded. Lets the gallery justify its rows correctly
/// on the first frame instead of guessing and reflowing as images land.
pub ratio: Option<f64>,
/// True when this photo can never be given a thumbnail — the server has none
/// and its bytes could not be decoded locally. The tile shows a placeholder
/// rather than waiting for an image that will never come.
pub no_thumb: bool,
}
/// One thumbnail in a [`Response::Thumbs`] batch.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoThumb {
pub uid: String,
/// On-disk path, or `None` when there is no thumbnail to serve *yet*.
pub path: Option<String>,
/// True when the daemon is making this thumbnail itself, because the server
/// has none: the photo's full file is downloading and will be scaled when it
/// lands. A `None` path with `pending` set means "ask again shortly"; a `None`
/// path *without* it means the photo can never have a thumbnail, and a
/// front-end should stop asking.
///
/// Generation is not made to block the reply: one 20 MB camera photo takes
/// far longer to fetch than the whole rest of a batch, and holding the batch
/// for it would leave a screenful of ready thumbnails unpainted.
pub pending: bool,
}
/// The daemon's reply to a [`Request`].
#[derive(Serialize, Deserialize, Debug)]
pub enum Response {
/// Current mount status.
/// Current mount status. Carries the cache stats the daemon already holds
/// (`used`/`budget` bytes and the pin list) so a front-end never has to open
/// the on-disk cache itself on its UI thread.
Status {
username: String,
mountpoint: String,
pinned: usize,
/// Bytes of cached content blobs (see [`crate::cache::ContentCache::usage`]).
used: u64,
/// Configured soft byte cap (`0` = unlimited).
budget: u64,
/// The pin registry.
pins: Vec<Pin>,
},
/// A human-readable success message.
Ok { message: String },
/// The pin registry.
Pins { pins: Vec<Pin> },
/// A directory listing (reply to [`Request::ListDir`]).
/// A directory listing (reply to [`Request::ListDir`]) or a trash listing
/// (reply to [`Request::ListTrash`]).
Entries { entries: Vec<DirEntry> },
/// A page of the photos timeline. `available` is false when the account
/// has no photos volume.
Photos { available: bool, items: Vec<PhotoItem> },
Photos {
available: bool,
items: Vec<PhotoItem>,
},
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]).
SearchResults { hits: Vec<SearchHit> },
/// Local-file search results (reply to [`Request::SearchLocal`]). `indexing`
/// is true while a scan of the machine is still running, so a front-end can
/// say "still indexing" instead of "no matches" on a cold first launch.
LocalResults { hits: Vec<LocalHit>, indexing: bool },
/// A snapshot of what the daemon is working on (reply to
/// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs`
/// the longer non-transfer work around them (scans, folder skeletons, the
/// local index, sync passes). Both empty means the daemon is idle.
Transfers {
items: Vec<TransferItem>,
#[serde(default)]
jobs: Vec<JobItem>,
},
/// The account's devices (reply to [`Request::ListDevices`]).
Devices { items: Vec<DeviceInfo> },
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
SyncFolders { items: Vec<SyncFolderInfo> },
/// A node's share: members + pending invitations, and its public link if any
/// (reply to [`Request::ListShare`]).
Share {
entries: Vec<ShareEntry>,
link: Option<PublicLinkInfo>,
},
/// A freshly created public link, carrying the shareable URL (reply to
/// [`Request::CreatePublicLink`]).
PublicLink { link: PublicLinkInfo },
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
Invitations { items: Vec<InvitationInfo> },
/// Saved public links (reply to [`Request::ListBookmarks`]).
Bookmarks { items: Vec<BookmarkInfo> },
/// Nodes I have shared with others (reply to [`Request::ListSharedByMe`]).
SharedByMe { items: Vec<SharedItem> },
/// The daemon's recent activity, newest first (reply to
/// [`Request::ListActivity`]).
Activity { items: Vec<ActivityEntry> },
/// The request failed.
Error { message: String },
}
@@ -123,3 +671,150 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
reader.read_line(&mut resp)?;
Ok(serde_json::from_str(resp.trim())?)
}
#[cfg(test)]
mod tests {
use super::*;
/// The mutation requests must survive a line-delimited JSON round-trip, since
/// that is exactly how they cross the control socket.
#[test]
fn mutation_requests_roundtrip() {
let reqs = [
Request::Rename {
path: "a/b.txt".into(),
new_name: "c.txt".into(),
},
Request::Move {
path: "a/b.txt".into(),
new_parent: "d".into(),
},
Request::Delete {
path: "a/b.txt".into(),
},
Request::CreateFolder {
parent: "a".into(),
name: "new".into(),
},
Request::UploadFile {
parent: "a".into(),
name: "f.bin".into(),
bytes: vec![0, 1, 2, 255],
},
Request::UploadPaths {
parent: "a".into(),
sources: vec!["/home/u/x.txt".into(), "/home/u/pics".into()],
},
];
for req in reqs {
let line = serde_json::to_string(&req).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let back: Request = serde_json::from_str(&line).unwrap();
// Round-trip is lossless: re-serializing yields the same bytes.
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
/// The trash requests carry uids rather than paths; they must survive the same
/// line-delimited round-trip, since a mangled uid would restore or destroy the
/// wrong node.
#[test]
fn trash_requests_roundtrip() {
let reqs = [
Request::ListTrash,
Request::Restore {
uids: vec!["vol~link".into(), "vol~other".into()],
},
Request::DeleteForever {
uids: vec!["vol~link".into()],
},
Request::EmptyTrash,
];
for req in reqs {
let line = serde_json::to_string(&req).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let back: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
/// The sharing and devices requests must survive the same line-delimited JSON
/// round-trip: a mangled id or role would touch the wrong share or member.
#[test]
fn sharing_requests_roundtrip() {
let reqs = [
Request::ListDevices,
Request::RenameDevice {
uid: "dev-1".into(),
name: "laptop".into(),
},
Request::DeleteDevice {
uid: "dev-1".into(),
},
Request::AddSyncFolder {
local_path: "/home/me/Docs".into(),
},
Request::ListSyncFolders,
Request::RemoveSyncFolder {
id: 3,
delete_remote: true,
},
Request::SetSyncFolderMode {
id: 3,
mode: "ondemand".into(),
},
Request::SyncNow { id: Some(3) },
Request::ShareNode {
path: "a/b".into(),
emails: vec!["x@proton.me".into(), "y@example.com".into()],
role: "editor".into(),
message: Some("hi".into()),
},
Request::ListShare { path: "a/b".into() },
Request::UpdateShareRole {
path: "a/b".into(),
id: "mid-1".into(),
kind: ShareEntryKind::Member,
role: "admin".into(),
},
Request::RemoveShareEntry {
path: "a/b".into(),
id: "iid-1".into(),
kind: ShareEntryKind::ExternalInvite,
},
Request::CreatePublicLink {
path: "a/b".into(),
role: "viewer".into(),
password: Some("pw".into()),
expires: Some(1_700_000_000),
},
Request::RemovePublicLink {
path: "a/b".into(),
id: "url-1".into(),
},
Request::ListSharedByMe,
Request::ListActivity { limit: 100 },
Request::ListSharedWithMe,
Request::LeaveShared {
uid: "vol~link".into(),
},
Request::ListInvitations,
Request::AcceptInvitation { id: "inv-1".into() },
Request::RejectInvitation { id: "inv-1".into() },
Request::ListBookmarks,
Request::CreateBookmark {
url: "https://drive.proton.me/urls/tok#pw".into(),
password: None,
},
Request::DeleteBookmark {
token: "tok".into(),
},
];
for req in reqs {
let line = serde_json::to_string(&req).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let back: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&back).unwrap());
}
}
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -16,10 +16,19 @@ pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("database: {0}")]
Db(#[from] rusqlite::Error),
/// No persisted session found — caller must run an interactive login.
#[error("not logged in (no saved session)")]
NotLoggedIn,
/// The stored session can no longer unlock the account key chain: reading
/// the key salts needs the `locked` scope, which a refreshed access token
/// does not have. Only a fresh password login restores it.
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired,
#[error("{0}")]
Other(String),
}
+4
View File
@@ -6,7 +6,11 @@ pub mod auth;
pub mod cache;
pub mod config;
pub mod control;
pub mod db;
pub mod error;
pub mod localindex;
pub mod service;
pub mod shell;
pub use error::{Error, Result};
+224
View File
@@ -0,0 +1,224 @@
//! Filesystem walker behind the daemon's index of *local* (non-Drive) files.
//!
//! The launcher prompt searches Proton Drive and the machine's own files side by
//! side. Drive names come from the `nodes` FTS index; local names come from the
//! `local_files` index this module feeds. The daemon runs [`scan`] on a
//! background thread — never on a FUSE or control-socket thread — and streams
//! batches into [`crate::db::Db::local_upsert_batch`].
//!
//! Walking is deliberately shallow on cost: we stat each entry once (the walker
//! already has the `DirEntry` metadata) and skip the directories that dominate a
//! home directory's inode count without ever being interesting to search
//! (`node_modules`, `target`, caches, VCS internals). The Drive mountpoint is
//! always excluded — walking it would fault in every remote node through FUSE.
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use ignore::{WalkBuilder, WalkState};
/// Directory names skipped anywhere in the tree. These hold build artefacts,
/// dependency trees, and caches: high inode count, no search value.
const SKIP_DIRS: &[&str] = &[
"node_modules",
"target",
"__pycache__",
"venv",
".venv",
"vendor",
"dist-newstyle",
"Trash",
];
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
/// database without bound. Reaching it stops the walk early.
const MAX_ENTRIES: usize = 400_000;
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
/// amortises, small enough that the writer lock is released often (FUSE
/// write-through shares the same connection).
const BATCH: usize = 2_000;
/// One indexed local file or directory.
#[derive(Debug, Clone)]
pub struct LocalEntry {
/// Absolute path.
pub path: String,
/// Final path component.
pub name: String,
pub is_dir: bool,
/// Size in bytes (0 for directories).
pub size: i64,
/// Modification time, epoch seconds.
pub mtime: i64,
}
/// Walk `roots` in parallel, calling `sink` with batches of entries.
///
/// Hidden files and anything under `excludes` (the Drive mountpoint, our own
/// state/cache dirs) are skipped, as are the [`SKIP_DIRS`] names at any depth.
/// Symlinks are not followed, so a link loop cannot hang the scan. Returns the
/// number of entries handed to `sink`.
///
/// `sink` is called from the walker's worker threads, serialized by an internal
/// mutex; it must not block for long.
pub fn scan(
roots: &[PathBuf],
excludes: &[PathBuf],
sink: impl FnMut(Vec<LocalEntry>) + Send,
) -> usize {
let Some((first, rest)) = roots.split_first() else {
return 0;
};
let mut builder = WalkBuilder::new(first);
for root in rest {
builder.add(root);
}
let excludes: Vec<PathBuf> = excludes.to_vec();
builder
// `standard_filters` would honour .gitignore/.ignore files: a source
// tree's ignored-but-present files are still files the user may want to
// find, so only the hidden filter stays on.
.standard_filters(false)
.hidden(true)
.follow_links(false)
.same_file_system(true)
.threads(std::thread::available_parallelism().map_or(4, |n| n.get().min(8)))
.filter_entry(move |entry| {
let path = entry.path();
if excludes.iter().any(|e| path.starts_with(e)) {
return false;
}
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
});
let state = std::sync::Mutex::new(SinkState {
buf: Vec::with_capacity(BATCH),
total: 0,
sink,
});
builder.build_parallel().run(|| {
Box::new(|result| {
let Ok(entry) = result else {
return WalkState::Continue;
};
// Depth 0 is a root itself; indexing it adds nothing to search.
if entry.depth() == 0 {
return WalkState::Continue;
}
let Some(local) = to_entry(&entry) else {
return WalkState::Continue;
};
let mut state = state.lock().unwrap();
state.push(local);
if state.total >= MAX_ENTRIES {
return WalkState::Quit;
}
WalkState::Continue
})
});
let mut state = state.into_inner().unwrap();
state.flush();
state.total
}
/// Batching buffer shared by the walker's worker threads.
struct SinkState<F: FnMut(Vec<LocalEntry>)> {
buf: Vec<LocalEntry>,
total: usize,
sink: F,
}
impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
fn push(&mut self, entry: LocalEntry) {
self.buf.push(entry);
self.total += 1;
if self.buf.len() >= BATCH {
self.flush();
}
}
fn flush(&mut self) {
if !self.buf.is_empty() {
let batch = std::mem::take(&mut self.buf);
(self.sink)(batch);
}
}
}
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
/// JSON wire format) is UTF-8 only.
fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
let path = entry.path().to_str()?.to_string();
let name = entry.file_name().to_str()?.to_string();
let meta = entry.metadata().ok()?;
let is_dir = meta.is_dir();
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs() as i64);
Some(LocalEntry {
path,
name,
is_dir,
size: if is_dir { 0 } else { meta.len() as i64 },
mtime,
})
}
/// The paths a scan should never descend into: the Drive mountpoint (walking it
/// would fault every remote node in through FUSE) plus our own state and cache
/// dirs, which hold blobs no user searches for by name.
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
vec![
mountpoint.to_path_buf(),
state_dir.to_path_buf(),
cache_dir.to_path_buf(),
]
}
#[cfg(test)]
mod tests {
use super::*;
/// A scan indexes plain files, skips the excluded subtree and the junk dirs,
/// and reports names/paths the search index can match on.
#[test]
fn scan_indexes_files_and_honours_excludes() {
let tmp = std::env::temp_dir().join(format!("pdfs-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("docs")).unwrap();
std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(tmp.join("mnt")).unwrap();
std::fs::write(tmp.join("docs/report.pdf"), b"x").unwrap();
std::fs::write(tmp.join("node_modules/pkg/index.js"), b"x").unwrap();
std::fs::write(tmp.join("mnt/remote.txt"), b"x").unwrap();
let mut got = Vec::new();
let n = scan(std::slice::from_ref(&tmp), &[tmp.join("mnt")], |batch| {
got.extend(batch)
});
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
assert_eq!(n, got.len());
assert!(names.contains(&"report.pdf"));
assert!(names.contains(&"docs"));
// Junk dir pruned, mountpoint-style exclude pruned.
assert!(!names.contains(&"index.js"));
assert!(!names.contains(&"node_modules"));
assert!(!names.contains(&"remote.txt"));
let report = got.iter().find(|e| e.name == "report.pdf").unwrap();
assert!(!report.is_dir);
assert_eq!(report.size, 1);
assert!(report.path.ends_with("docs/report.pdf"));
std::fs::remove_dir_all(&tmp).unwrap();
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Thin wrappers over `systemctl --user` for the auto-mount daemon.
//!
//! The mount is owned by a systemd *user* service (`proton-drive.service`, see
//! `packaging/`). Front-ends never spawn `pdfs mount` directly anymore: logging
//! in enables+starts the unit, logging out disables+stops it, and the tray's
//! "Disconnect" just stops it. systemd then handles restart-on-failure and a
//! clean SIGTERM stop (which the daemon turns into a lazy unmount).
//!
//! Every call is best-effort: a failure is logged but not fatal, since the user
//! can always drive `systemctl --user` by hand.
use std::process::{Command, Stdio};
/// The systemd user unit that runs `pdfs daemon`.
pub const SERVICE: &str = "proton-drive.service";
/// Run one `systemctl --user <args> proton-drive.service`, returning whether it
/// succeeded. Output is suppressed; failures are logged.
fn systemctl(args: &[&str]) -> bool {
let ok = Command::new("systemctl")
.arg("--user")
.args(args)
.arg(SERVICE)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !ok {
tracing::warn!(args = ?args, "systemctl --user {SERVICE} failed");
}
ok
}
/// Like [`systemctl`] but treats a non-zero exit as a plain `false` answer
/// rather than a failure to log. For query verbs (`is-active`, `is-failed`)
/// where "no" is the normal, expected result.
fn systemctl_query(args: &[&str]) -> bool {
Command::new("systemctl")
.arg("--user")
.args(args)
.arg(SERVICE)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Enable the service so it survives reboots/logins and start it now. Called on
/// successful login.
pub fn enable_start() -> bool {
systemctl(&["enable", "--now"])
}
/// Stop the running mount without disabling it. Called by the tray's
/// "Disconnect": the next login (or reboot) brings it back.
pub fn stop() -> bool {
systemctl(&["stop"])
}
/// Restart the unit now. Used by a front-end "Retry" action when the mount has
/// failed and the user wants to bring it back without re-logging-in.
pub fn restart() -> bool {
systemctl(&["restart"])
}
/// Whether the unit currently reports `active` (running, or starting up).
/// `systemctl is-active` exits 0 only while the unit is active, so a front-end
/// can use this to tell "service still coming up" from "service is down".
pub fn is_active() -> bool {
systemctl_query(&["is-active"])
}
/// Whether the unit is in the `failed` state. `systemctl is-failed` exits 0
/// only when the unit has failed, letting a front-end show an actionable error
/// (with Retry) rather than an indefinite "connecting…" spinner.
pub fn is_failed() -> bool {
systemctl_query(&["is-failed"])
}
/// Whether the unit is enabled to start on login/boot. `systemctl is-enabled`
/// exits 0 only for an enabled unit, so the Settings page can render the
/// "Start on login" switch in its true state.
pub fn is_enabled() -> bool {
systemctl_query(&["is-enabled"])
}
/// Enable the service so it auto-starts on future logins, without necessarily
/// starting it now beyond what `enable` does. Used by the Settings "Start on
/// login" switch; pairs with [`disable`](disable).
pub fn enable() -> bool {
systemctl(&["enable"])
}
/// Disable auto-start without stopping the running mount. Used by the Settings
/// "Start on login" switch turning off; the current session stays mounted.
pub fn disable() -> bool {
systemctl(&["disable"])
}
/// Disable the service so it no longer auto-starts, and stop it now. Called on
/// logout.
pub fn disable_stop() -> bool {
systemctl(&["disable", "--now"])
}
+97
View File
@@ -0,0 +1,97 @@
//! File-manager integration: right-click actions inside the mounted Drive.
//!
//! Nautilus (and Nemo/Caja, which read the same directories) runs any executable
//! in its scripts folder with the selected paths in the environment, and shows it
//! under a "Scripts" submenu. That gives us pin/unpin from the file manager
//! without shipping a Python extension or a GNOME-version-specific plugin.
//!
//! Installation is idempotent and best-effort: the scripts are (re)written on
//! every app start so an upgraded `pdfs` never leaves a stale script behind, and
//! any failure is logged rather than surfaced — a missing file manager is not an
//! error the user needs to act on.
use std::io;
use std::path::PathBuf;
/// Scripts shipped into the file manager: `(file name, `pdfs` subcommand)`.
const SCRIPTS: [(&str, &str); 2] = [
("Keep offline (Proton Drive)", "pin"),
("Remove offline copy (Proton Drive)", "unpin"),
];
/// The script body. Nautilus passes the selection as newline-separated paths in
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
/// selection when the script runs on a local (non-URI) folder, so we prefer the
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
fn script_body(subcommand: &str) -> String {
format!(
"#!/bin/sh\n\
# Installed by proton-drive-linux. Regenerated on every app start.\n\
set -eu\n\
\n\
paths=\"${{NAUTILUS_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
if [ -z \"$paths\" ]; then\n\
\x20 paths=\"${{NEMO_SCRIPT_SELECTED_FILE_PATHS:-}}\"\n\
fi\n\
if [ -z \"$paths\" ]; then\n\
\x20 for arg in \"$@\"; do\n\
\x20 paths=\"$paths$arg\\n\"\n\
\x20 done\n\
fi\n\
\n\
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
\x20 [ -n \"$path\" ] || continue\n\
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
done\n"
)
}
/// The scripts directories to populate, for whichever file managers are present.
fn script_dirs() -> Vec<PathBuf> {
let Some(data) = dirs_data_home() else {
return Vec::new();
};
["nautilus/scripts", "nemo/scripts"]
.iter()
.map(|rel| data.join(rel))
.collect()
}
/// `$XDG_DATA_HOME`, or `~/.local/share`.
fn dirs_data_home() -> Option<PathBuf> {
if let Some(dir) = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))
}
/// Install (or refresh) the pin/unpin scripts. Best-effort: logs and moves on.
pub fn install_file_manager_scripts() {
for dir in script_dirs() {
// Only populate a file manager the user actually has: creating
// `~/.local/share/nemo` on a GNOME box would be litter.
if !dir.parent().is_some_and(|p| p.exists()) {
continue;
}
if let Err(e) = write_scripts(&dir) {
tracing::warn!("couldn't install file-manager scripts in {dir:?}: {e}");
}
}
}
/// Write both scripts into `dir`, creating it if needed, and make them
/// executable — a script without the exec bit is silently ignored by Nautilus.
fn write_scripts(dir: &PathBuf) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
for (name, subcommand) in SCRIPTS {
let path = dir.join(name);
std::fs::write(&path, script_body(subcommand))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))?;
}
}
tracing::debug!("installed file-manager scripts in {dir:?}");
Ok(())
}
+2
View File
@@ -15,3 +15,5 @@ tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
image.workspace = true
notify = "8"
+4021 -283
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+292
View File
@@ -0,0 +1,292 @@
//! In-memory registry of in-flight work, behind [`Request::GetQueueStatus`].
//!
//! Two kinds of work live here. **Transfers** move bytes: the daemon downloads
//! and uploads whole files through the SDK's streaming [`Read`]/[`Write`]
//! variants, and wrapping that reader/writer in a
//! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a
//! per-transfer counter without the SDK knowing anything about progress.
//! **Jobs** ([`JobGuard`]) cover the long stretches around them that move no
//! bytes — walking a local tree, creating the remote folder skeleton, indexing
//! `$HOME` — which otherwise look to a front-end exactly like an idle daemon.
//!
//! Both register on creation and deregister on guard drop, so the registry
//! always reflects exactly what is in flight — even if the work fails partway
//! and unwinds.
//!
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
/// One registered transfer. `done` is bumped from the I/O wrapper without the
/// registry lock; the rest is immutable for the transfer's lifetime.
struct Entry {
name: String,
uid: String,
direction: TransferDirection,
total: u64,
done: AtomicU64,
started: Instant,
}
/// One registered job. `detail`/`done`/`total` all move as the job runs; `total`
/// may grow when more work is discovered mid-job.
struct JobEntry {
title: String,
detail: Mutex<String>,
done: AtomicU64,
total: AtomicU64,
}
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
/// the FUSE session and the control-socket task (both share one registry via
/// [`Core`]).
#[derive(Default)]
pub struct TransferRegistry {
inner: Mutex<HashMap<u64, Arc<Entry>>>,
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
next: AtomicU64,
}
impl TransferRegistry {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
/// guard that tracks progress and deregisters the transfer when dropped.
pub fn begin(
self: &Arc<Self>,
name: impl Into<String>,
uid: impl Into<String>,
direction: TransferDirection,
total: u64,
) -> TransferGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(Entry {
name: name.into(),
uid: uid.into(),
direction,
total,
done: AtomicU64::new(0),
started: Instant::now(),
});
self.inner.lock().unwrap().insert(id, entry.clone());
TransferGuard {
reg: self.clone(),
id,
entry,
}
}
/// Register a long-running non-transfer job titled `title`, returning a guard
/// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry {
title: title.into(),
detail: Mutex::new(String::new()),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
});
self.jobs.lock().unwrap().insert(id, entry.clone());
JobGuard {
reg: self.clone(),
id,
entry,
}
}
/// Snapshot every in-flight job for [`Response::Transfers`], oldest first, so
/// a front-end's rows keep their order across polls.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn jobs_snapshot(&self) -> Vec<JobItem> {
let map = self.jobs.lock().unwrap();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| {
let e = &map[id];
JobItem {
title: e.title.clone(),
detail: e.detail.lock().unwrap().clone(),
done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed),
}
})
.collect()
}
/// Snapshot every in-flight transfer for [`Response::Transfers`], oldest
/// first so a front-end's rows keep their order across polls. Speed is the
/// running average since the transfer began — simple, and stable enough for a
/// progress widget without per-tick sampling state.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn snapshot(&self) -> Vec<TransferItem> {
let map = self.inner.lock().unwrap();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| &map[id])
.map(|e| {
let done = e.done.load(Ordering::Relaxed);
let secs = e.started.elapsed().as_secs_f64();
let speed = if secs > 0.0 {
(done as f64 / secs) as u64
} else {
0
};
TransferItem {
uid: e.uid.clone(),
name: e.name.clone(),
direction: e.direction,
bytes_completed: done,
bytes_total: e.total,
speed_bytes_sec: speed,
}
})
.collect()
}
}
/// Lifetime handle for a registered transfer: tick progress with [`add`], and
/// the transfer leaves the registry when this drops.
///
/// [`add`]: TransferGuard::add
pub struct TransferGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<Entry>,
}
impl TransferGuard {
/// Record `n` more bytes moved.
pub fn add(&self, n: u64) {
self.entry.done.fetch_add(n, Ordering::Relaxed);
}
}
impl Drop for TransferGuard {
fn drop(&mut self) {
self.reg.inner.lock().unwrap().remove(&self.id);
}
}
/// Lifetime handle for a registered job: describe what it is doing with
/// [`detail`], size it with [`set_total`], advance it with [`step`], and the job
/// leaves the registry when this drops. Shared across the tasks of one batch as
/// an `Arc`, so a concurrent phase can report a single "N of M" line.
///
/// [`detail`]: JobGuard::detail
/// [`set_total`]: JobGuard::set_total
/// [`step`]: JobGuard::step
pub struct JobGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<JobEntry>,
}
impl JobGuard {
/// Say what the job is working on right now; empty clears it.
pub fn detail(&self, what: impl Into<String>) {
*self.entry.detail.lock().unwrap() = what.into();
}
/// Set how many steps the job now knows it has. Safe to raise mid-job as more
/// work is discovered.
pub fn set_total(&self, total: u64) {
self.entry.total.store(total, Ordering::Relaxed);
}
/// Mark one more step done.
pub fn step(&self) {
self.entry.done.fetch_add(1, Ordering::Relaxed);
}
}
impl Drop for JobGuard {
fn drop(&mut self) {
self.reg.jobs.lock().unwrap().remove(&self.id);
}
}
/// A [`Write`] that tallies bytes written to a [`TransferGuard`] (download path).
pub struct CountingWriter<'a, W> {
inner: W,
guard: &'a TransferGuard,
}
impl<'a, W: Write> CountingWriter<'a, W> {
pub fn new(inner: W, guard: &'a TransferGuard) -> Self {
Self { inner, guard }
}
pub fn into_inner(self) -> W {
self.inner
}
}
impl<W: Write> Write for CountingWriter<'_, W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.guard.add(n as u64);
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
/// A [`Read`] that tallies bytes read and *owns* its [`TransferGuard`], so the
/// transfer stays registered exactly as long as the reader lives. Used by the
/// concurrent bulk uploader, where each task hands its reader to the SDK and has
/// nowhere separate to park the guard; the transfer deregisters when the SDK
/// drops the reader after sealing the revision. Owning (rather than borrowing)
/// also keeps each upload future `Send + 'static` for [`tokio::task::spawn`].
pub struct OwnedCountingReader<R> {
inner: R,
guard: TransferGuard,
}
impl<R: Read> OwnedCountingReader<R> {
pub fn new(inner: R, guard: TransferGuard) -> Self {
Self { inner, guard }
}
}
impl<R: Read> Read for OwnedCountingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
Ok(n)
}
}
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
pub struct CountingReader<'a, R> {
inner: R,
guard: &'a TransferGuard,
}
impl<'a, R: Read> CountingReader<'a, R> {
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
Self { inner, guard }
}
}
impl<R: Read> Read for CountingReader<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
Ok(n)
}
}
+6
View File
@@ -14,13 +14,19 @@ path = "src/main.rs"
name = "pdfs-app"
path = "src/app.rs"
[[bin]]
name = "pdfs-prompt"
path = "src/prompt.rs"
[dependencies]
pdfs-core.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
tokio.workspace = true
serde_json.workspace = true
ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" }
+28
View File
@@ -0,0 +1,28 @@
use std::env;
use std::process::Command;
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let status = Command::new("glib-compile-resources")
.args([
"--target",
&format!("{}/pdfs.gresource", out_dir),
"--sourcedir",
"resources/icons",
"resources/pdfs.gresource.xml",
])
.status()
.expect("Failed to execute glib-compile-resources");
if !status.success() {
panic!(
"glib-compile-resources failed with exit code: {:?}",
status.code()
);
}
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
);
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Upload-a-folder action: a folder silhouette with an up arrow knocked out of
it (one path, fill-rule evenodd, so symbolic recolouring keeps working).
Matches the Papirus folder shape (width 14, height 12) with a pixel-aligned,
proportionate up arrow cutout for size consistency. -->
<path fill="#222222" fill-rule="evenodd" d="M 1,3 V 14 C 1,14 1,15 2,15 H 14 C 14,15 15,15 15,14 V 5 C 15,4 14,4 14,4 H 9 L 7,2 H 2 C 2,2 1,2 1,3 Z M 7,12 V 8 H 5 L 8,5 L 11,8 H 9 V 12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<!-- Icons we ship ourselves because no icon theme has them. Registered as an
icon-theme resource path at startup, so they resolve by name (and recolour
with the theme) exactly like a stock symbolic icon. -->
<gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource>
</gresources>
+6964 -382
View File
File diff suppressed because it is too large. Load diff
+113 -34
View File
@@ -18,7 +18,8 @@ use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, send};
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu.
const POLL_INTERVAL: Duration = Duration::from_secs(3);
@@ -32,6 +33,48 @@ struct DriveState {
mounted: bool,
/// Mountpoint to act on (from the daemon when mounted, else the default).
mountpoint: PathBuf,
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
/// disabled menu line under the status line.
sync: String,
}
/// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
/// running job speaks for itself — a scan or an index rebuild is still "busy",
/// and the tray saying nothing there reads as "finished".
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => match jobs.first() {
Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total),
Some(j) => format!("{}…", j.title),
None => String::new(),
},
[t] => {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
if t.bytes_total == 0 {
format!("{arrow} {}…", t.name)
} else {
let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100);
format!("{arrow} {} ({pct}%)", t.name)
}
}
_ => {
let down = items
.iter()
.filter(|t| t.direction == TransferDirection::Download)
.count();
let up = items.len() - down;
match (down, up) {
(d, 0) => format!("↓ {d} downloading"),
(0, u) => format!("↑ {u} uploading"),
(d, u) => format!("↓ {d} · ↑ {u}"),
}
}
}
}
struct DriveTray {
@@ -42,16 +85,24 @@ struct DriveTray {
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(Response::Status { mountpoint, pinned, .. }) => DriveState {
Ok(Response::Status {
mountpoint, pinned, ..
}) => DriveState {
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
mounted: true,
mountpoint: PathBuf::from(mountpoint),
// Same daemon is up, so a cheap follow-up poll gives the sync line.
sync: match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(),
},
},
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => DriveState {
line: "Mount: unexpected daemon response".into(),
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
},
// No daemon: describe login state instead so the menu is still useful.
Err(_) => {
@@ -64,37 +115,32 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
line,
mounted: false,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
}
}
}
}
/// Launch `pdfs mount` detached so the tray keeps running independently.
fn spawn_mount() {
match Command::new("pdfs").arg("mount").spawn() {
Ok(_) => tracing::info!("spawned `pdfs mount`"),
Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"),
}
}
/// Unmount the FUSE filesystem. `fusermount3 -u` is the FUSE-native teardown;
/// fall back to `fusermount` on older systems.
fn unmount(mountpoint: &Path) {
let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status();
let result = try_unmount("fusermount3").or_else(|_| try_unmount("fusermount"));
match result {
Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()),
Ok(s) => tracing::error!("unmount exited with {s}"),
Err(e) => tracing::error!("failed to run fusermount: {e}"),
}
}
fn open_folder(mountpoint: &Path) {
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
}
}
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
/// window. The app binary lives next to the tray binary; fall back to the bare
/// name so a PATH install still works.
fn open_manager() {
let exe = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
.filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from("pdfs-app"));
if let Err(e) = Command::new(&exe).spawn() {
tracing::error!("failed to launch {}: {e}", exe.display());
}
}
impl Tray for DriveTray {
fn id(&self) -> String {
"io.narl.proton-drive-linux".into()
@@ -109,9 +155,8 @@ impl Tray for DriveTray {
}
fn menu(&self) -> Vec<MenuItem<Self>> {
// Values captured into the action closures (which only get `&mut Self`).
let mountpoint = self.state.mountpoint.clone();
let open_mp = mountpoint.clone();
// Value captured into the action closure (which only gets `&mut Self`).
let open_mp = self.state.mountpoint.clone();
let mut items: Vec<MenuItem<Self>> = vec![
StandardItem {
@@ -120,9 +165,30 @@ impl Tray for DriveTray {
..Default::default()
}
.into(),
MenuItem::Separator,
];
// Live sync status, shown only while something is transferring.
if !self.state.sync.is_empty() {
items.push(
StandardItem {
label: self.state.sync.clone(),
enabled: false,
..Default::default()
}
.into(),
);
}
items.extend([
MenuItem::Separator,
StandardItem {
label: "Open Manager".into(),
activate: Box::new(|_: &mut Self| open_manager()),
..Default::default()
}
.into(),
]);
if self.state.mounted {
items.push(
StandardItem {
@@ -132,25 +198,28 @@ impl Tray for DriveTray {
}
.into(),
);
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
// login or reboot brings it back; this is a deliberate disconnect.
items.push(
StandardItem {
label: "Unmount".into(),
label: "Disconnect".into(),
activate: Box::new(move |this: &mut Self| {
unmount(&mountpoint);
service::stop();
this.state.mounted = false;
this.state.line = "Unmounting…".into();
this.state.line = "Disconnecting…".into();
}),
..Default::default()
}
.into(),
);
} else {
// Enable+start the service so it mounts now and on future logins.
items.push(
StandardItem {
label: "Mount".into(),
label: "Connect".into(),
activate: Box::new(|this: &mut Self| {
spawn_mount();
this.state.line = "Mounting…".into();
service::enable_start();
this.state.line = "Connecting…".into();
}),
..Default::default()
}
@@ -174,12 +243,22 @@ impl Tray for DriveTray {
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
let dirs = AppDirs::new().context("resolve app dirs")?;
// Ensure only one instance of the tray runs.
let tray_sock = dirs.tray_socket();
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
tracing::info!("Another instance of pdfs-tray is already running; exiting.");
return Ok(());
}
let _ = std::fs::remove_file(&tray_sock);
let _lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
.context("failed to bind tray single-instance socket")?;
let socket = dirs.control_socket();
let default_mountpoint = dirs.default_mountpoint();
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

+20 -8
View File
@@ -1,26 +1,27 @@
# Maintainer: Nils Pukropp <contact@narl.io>
#
# Local-build PKGBUILD. Builds the proton-drive-linux workspace from the
# checkout this file lives in. Path deps point at a sibling ../proton-sdk-rs
# checkout (the Proton SDK is unpublished), so this is NOT a source-fetch
# package: it compiles the working tree in place.
# checkout this file lives in: this is NOT a source-fetch package, it compiles
# the working tree in place. The Proton SDK is unpublished and comes from its
# GitHub repo (pinned by Cargo.lock), so the build needs network access — no
# sibling checkout required any more.
#
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=0.1.0
pkgver=0.4.0
pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64')
url="https://github.com/narl/proton-drive-linux"
license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita')
makedepends=('cargo')
makedepends=('cargo' 'git')
provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime.
optdepends=('gnome-keyring: credential storage via Secret Service'
'xdg-utils: "Open folder" action from the tray')
options=('!lto')
options=('!lto' '!debug')
# Built in place from the checkout; nothing to fetch or checksum.
source=()
@@ -40,10 +41,13 @@ build() {
cd "$_workspace"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR="$_workspace/target"
cargo build --release --frozen \
# `--locked` not `--frozen`: the lock is authoritative, but cargo must still be
# allowed to fetch the git-sourced SDK.
cargo build --release --locked \
--bin pdfs \
--bin pdfs-tray \
--bin pdfs-app
--bin pdfs-app \
--bin pdfs-prompt
}
package() {
@@ -53,11 +57,19 @@ package() {
install -Dm755 "$rel/pdfs" "$pkgdir/usr/bin/pdfs"
install -Dm755 "$rel/pdfs-tray" "$pkgdir/usr/bin/pdfs-tray"
install -Dm755 "$rel/pdfs-app" "$pkgdir/usr/bin/pdfs-app"
install -Dm755 "$rel/pdfs-prompt" "$pkgdir/usr/bin/pdfs-prompt"
install -Dm644 "packaging/io.narl.proton-drive-linux.desktop" \
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
install -Dm644 "packaging/io.narl.proton-drive-linux-tray.desktop" \
"$pkgdir/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop"
install -Dm644 "packaging/io.narl.proton-drive-linux.svg" \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
# systemd user service that runs the auto-mount daemon. Enabled per-user by
# the GUI on login (`systemctl --user enable --now proton-drive.service`).
install -Dm644 "packaging/proton-drive.service" \
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=Proton Drive (Tray)
GenericName=Cloud Drive Tray
Comment=Proton Drive system tray icon
Exec=pdfs-tray
Icon=io.narl.proton-drive-linux
Terminal=false
Categories=Network;FileTransfer;GTK;
Keywords=proton;drive;cloud;sync;storage;
NoDisplay=true
+1 -1
View File
@@ -10,5 +10,5 @@
<!-- cloud -->
<path fill="#ffffff" d="M44 84c-11 0-20-9-20-20 0-10 7-18 17-20 3-11 13-19 25-19 13 0 24 10 26 23 9 1 16 9 16 18 0 10-8 18-18 18z"/>
<!-- down arrow (on-demand pull) -->
<path fill="#6d4aff" d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 718 B

+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Proton Drive Files-On-Demand mount
Documentation=https://github.com/narl/proton-drive-linux
# Tie the mount to the desktop session: start after it is up and stop with it.
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
# The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it
# mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount.
ExecStart=/usr/bin/pdfs daemon
Restart=on-failure
RestartSec=3
# Give the lazy unmount time to finish before SIGKILL.
TimeoutStopSec=15
[Install]
WantedBy=default.target