mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
added settings + non blocking ui actions
This commit is contained in:
1 parent
5e71148fec
commit
56abe3fe14
9 files changed
+1455
-159
No files matched your search
+109
-2
@@ -86,6 +86,39 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 50)]
|
#[arg(long, default_value_t = 50)]
|
||||||
limit: usize,
|
limit: usize,
|
||||||
},
|
},
|
||||||
|
/// Rename a file or folder via the running daemon.
|
||||||
|
Rename {
|
||||||
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
/// New name (a single path component).
|
||||||
|
new_name: String,
|
||||||
|
},
|
||||||
|
/// Move a file or folder into another folder via the running daemon.
|
||||||
|
Move {
|
||||||
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Destination folder path, inside the mountpoint or relative to it.
|
||||||
|
new_parent: PathBuf,
|
||||||
|
},
|
||||||
|
/// Trash a file or folder via the running daemon.
|
||||||
|
Rm {
|
||||||
|
/// File/folder path, inside the mountpoint or relative to it.
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
/// Create a folder via the running daemon.
|
||||||
|
Mkdir {
|
||||||
|
/// Parent folder path, inside the mountpoint or relative to it.
|
||||||
|
parent: PathBuf,
|
||||||
|
/// New folder name (a single path component).
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Upload a local file into a Drive folder via the running daemon.
|
||||||
|
Upload {
|
||||||
|
/// Local file to upload.
|
||||||
|
file: PathBuf,
|
||||||
|
/// Destination folder path, inside the mountpoint or relative to it.
|
||||||
|
parent: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
@@ -113,6 +146,11 @@ fn main() -> Result<()> {
|
|||||||
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||||
Command::Search { query, limit } => cmd_search(query, limit),
|
Command::Search { query, limit } => cmd_search(query, limit),
|
||||||
|
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||||
|
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||||
|
Command::Rm { path } => cmd_rm(path),
|
||||||
|
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
|
||||||
|
Command::Upload { file, parent } => cmd_upload(file, parent),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +261,10 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||||
let dirs = AppDirs::new()?;
|
let dirs = AppDirs::new()?;
|
||||||
dirs.ensure()?;
|
dirs.ensure()?;
|
||||||
let mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint());
|
let config = dirs.load_config();
|
||||||
|
// An explicit `--mountpoint` wins; otherwise honor the Settings-page choice
|
||||||
|
// (config), falling back to the default location.
|
||||||
|
let mountpoint = mountpoint.unwrap_or_else(|| dirs.resolved_mountpoint(&config));
|
||||||
std::fs::create_dir_all(&mountpoint)
|
std::fs::create_dir_all(&mountpoint)
|
||||||
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
||||||
|
|
||||||
@@ -235,7 +276,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
let cache = ContentCache::open(
|
let cache = ContentCache::open(
|
||||||
dirs.content_cache_dir(),
|
dirs.content_cache_dir(),
|
||||||
dirs.pins_path(),
|
dirs.pins_path(),
|
||||||
pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES,
|
config.resolved_cache_budget(),
|
||||||
db.clone(),
|
db.clone(),
|
||||||
)
|
)
|
||||||
.context("open content cache")?;
|
.context("open content cache")?;
|
||||||
@@ -404,6 +445,72 @@ fn cmd_search(query: String, limit: usize) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> {
|
||||||
|
match control_request(CtlRequest::Rename {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
new_name,
|
||||||
|
})? {
|
||||||
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> {
|
||||||
|
match control_request(CtlRequest::Move {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
new_parent: path_arg(&new_parent)?,
|
||||||
|
})? {
|
||||||
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_rm(path: PathBuf) -> Result<()> {
|
||||||
|
match control_request(CtlRequest::Delete {
|
||||||
|
path: path_arg(&path)?,
|
||||||
|
})? {
|
||||||
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> {
|
||||||
|
match control_request(CtlRequest::CreateFolder {
|
||||||
|
parent: path_arg(&parent)?,
|
||||||
|
name,
|
||||||
|
})? {
|
||||||
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_upload(file: PathBuf, parent: PathBuf) -> Result<()> {
|
||||||
|
let bytes = std::fs::read(&file).with_context(|| format!("read {}", file.display()))?;
|
||||||
|
let name = file
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or_else(|| anyhow!("source file has no valid name"))?
|
||||||
|
.to_owned();
|
||||||
|
match control_request(CtlRequest::UploadFile {
|
||||||
|
parent: path_arg(&parent)?,
|
||||||
|
name,
|
||||||
|
bytes,
|
||||||
|
})? {
|
||||||
|
CtlResponse::Ok { message } => println!("{message}"),
|
||||||
|
CtlResponse::Error { message } => bail!("{message}"),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Normalize a user-supplied path argument for sending to the daemon. Absolute
|
/// Normalize a user-supplied path argument for sending to the daemon. Absolute
|
||||||
/// paths are canonicalized (so the daemon can strip its mountpoint prefix);
|
/// paths are canonicalized (so the daemon can strip its mountpoint prefix);
|
||||||
/// relative paths are passed through and resolved against the mount root.
|
/// relative paths are passed through and resolved against the mount root.
|
||||||
|
|||||||
+106
-10
@@ -20,6 +20,7 @@ use std::collections::{BTreeMap, HashSet};
|
|||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
@@ -100,8 +101,10 @@ pub struct ContentCache {
|
|||||||
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
|
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
|
||||||
/// evicts least-recently-used *unpinned* blobs back under the cap. `0`
|
/// evicts least-recently-used *unpinned* blobs back under the cap. `0`
|
||||||
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
|
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
|
||||||
/// the cache over budget.
|
/// the cache over budget. Atomic so the daemon can retune it at runtime
|
||||||
max_bytes: u64,
|
/// (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
|
/// 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
|
/// store/read/evict updates it, and the budget enforcers query it instead of
|
||||||
/// scanning the cache directories (plan.md P4).
|
/// scanning the cache directories (plan.md P4).
|
||||||
@@ -139,7 +142,7 @@ impl ContentCache {
|
|||||||
block_dir,
|
block_dir,
|
||||||
scratch_dir,
|
scratch_dir,
|
||||||
pins_path,
|
pins_path,
|
||||||
max_bytes,
|
max_bytes: AtomicU64::new(max_bytes),
|
||||||
db,
|
db,
|
||||||
};
|
};
|
||||||
cache.reconcile()?;
|
cache.reconcile()?;
|
||||||
@@ -366,7 +369,8 @@ impl ContentCache {
|
|||||||
/// `max_bytes`. No-op when the cap is disabled (`0`). All blocks are
|
/// `max_bytes`. No-op when the cap is disabled (`0`). All blocks are
|
||||||
/// evictable — pinned files are served from whole-file blobs, never blocks.
|
/// evictable — pinned files are served from whole-file blobs, never blocks.
|
||||||
fn enforce_block_budget(&self) {
|
fn enforce_block_budget(&self) {
|
||||||
if self.max_bytes == 0 {
|
let cap = self.cap();
|
||||||
|
if cap == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// LRU-ordered (oldest first) block entries straight from the index — no
|
// LRU-ordered (oldest first) block entries straight from the index — no
|
||||||
@@ -376,11 +380,11 @@ impl ContentCache {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
||||||
if total <= self.max_bytes {
|
if total <= cap {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (key, size) in entries {
|
for (key, size) in entries {
|
||||||
if total <= self.max_bytes {
|
if total <= cap {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// `key` is the block file's name (and its `.meta` sibling's stem).
|
// `key` is the block file's name (and its `.meta` sibling's stem).
|
||||||
@@ -517,7 +521,8 @@ impl ContentCache {
|
|||||||
/// cache already fits. Pinned blobs are skipped, so a cache held entirely by
|
/// cache already fits. Pinned blobs are skipped, so a cache held entirely by
|
||||||
/// pins can legitimately stay over budget.
|
/// pins can legitimately stay over budget.
|
||||||
fn enforce_budget(&self) {
|
fn enforce_budget(&self) {
|
||||||
if self.max_bytes == 0 {
|
let cap = self.cap();
|
||||||
|
if cap == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Pinned blobs (by cache key) are exempt from eviction. Resolves direct
|
// Pinned blobs (by cache key) are exempt from eviction. Resolves direct
|
||||||
@@ -538,11 +543,11 @@ impl ContentCache {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
||||||
if total <= self.max_bytes {
|
if total <= cap {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (key, size) in entries {
|
for (key, size) in entries {
|
||||||
if total <= self.max_bytes {
|
if total <= cap {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if pinned.contains(&key) {
|
if pinned.contains(&key) {
|
||||||
@@ -624,7 +629,61 @@ impl ContentCache {
|
|||||||
/// Configured soft byte cap (`0` = unlimited), for display alongside
|
/// Configured soft byte cap (`0` = unlimited), for display alongside
|
||||||
/// [`usage`](Self::usage).
|
/// [`usage`](Self::usage).
|
||||||
pub fn budget(&self) -> u64 {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +881,43 @@ mod tests {
|
|||||||
assert!(c.is_cached(&d, 1, 4));
|
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]
|
#[test]
|
||||||
fn thumbnail_store_read_and_evict() {
|
fn thumbnail_store_read_and_evict() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
|||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub app_version: String,
|
pub app_version: String,
|
||||||
pub user_agent: 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 {
|
impl Default for AppConfig {
|
||||||
@@ -31,10 +41,20 @@ impl Default for AppConfig {
|
|||||||
Self {
|
Self {
|
||||||
app_version: APP_VERSION.to_string(),
|
app_version: APP_VERSION.to_string(),
|
||||||
user_agent: USER_AGENT.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.
|
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
||||||
pub struct AppDirs {
|
pub struct AppDirs {
|
||||||
dirs: ProjectDirs,
|
dirs: ProjectDirs,
|
||||||
@@ -70,6 +90,18 @@ impl AppDirs {
|
|||||||
default_config
|
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.
|
/// Persistent state: inode map / cache index DB lives here.
|
||||||
pub fn state_dir(&self) -> PathBuf {
|
pub fn state_dir(&self) -> PathBuf {
|
||||||
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||||
@@ -117,6 +149,16 @@ impl AppDirs {
|
|||||||
.unwrap_or_else(|| PathBuf::from("/tmp/ProtonDrive"))
|
.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.
|
/// Create state + cache dirs if missing.
|
||||||
pub fn ensure(&self) -> Result<()> {
|
pub fn ensure(&self) -> Result<()> {
|
||||||
std::fs::create_dir_all(self.state_dir())?;
|
std::fs::create_dir_all(self.state_dir())?;
|
||||||
@@ -135,6 +177,8 @@ mod tests {
|
|||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||||
user_agent: "test-agent/1.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 json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
|||||||
@@ -63,6 +63,32 @@ pub enum Request {
|
|||||||
/// Full-text search node names against the daemon's local metadata index.
|
/// Full-text search node names against the daemon's local metadata index.
|
||||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||||
Search { query: String, limit: usize },
|
Search { 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>,
|
||||||
|
},
|
||||||
|
/// 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 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One entry in a [`Request::ListDir`] listing.
|
/// One entry in a [`Request::ListDir`] listing.
|
||||||
@@ -77,6 +103,11 @@ pub struct DirEntry {
|
|||||||
pub modified: i64,
|
pub modified: i64,
|
||||||
/// Whether the file is pinned to this device.
|
/// Whether the file is pinned to this device.
|
||||||
pub pinned: bool,
|
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.
|
/// Node uid in `volume~link` form, for follow-up requests.
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
|
/// Full mountpoint-relative path. Empty for a [`Request::ListDir`] listing
|
||||||
@@ -168,3 +199,43 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
|||||||
reader.read_line(&mut resp)?;
|
reader.read_line(&mut resp)?;
|
||||||
Ok(serde_json::from_str(resp.trim())?)
|
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],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,26 @@ pub fn is_failed() -> bool {
|
|||||||
systemctl_query(&["is-failed"])
|
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
|
/// Disable the service so it no longer auto-starts, and stop it now. Called on
|
||||||
/// logout.
|
/// logout.
|
||||||
pub fn disable_stop() -> bool {
|
pub fn disable_stop() -> bool {
|
||||||
|
|||||||
+276
-14
@@ -49,6 +49,7 @@ use fuser::{
|
|||||||
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
||||||
};
|
};
|
||||||
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
|
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
|
||||||
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
|
DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
|
||||||
};
|
};
|
||||||
@@ -519,23 +520,62 @@ impl Core {
|
|||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
let first = offset / BLOCK_SIZE;
|
let first = offset / BLOCK_SIZE;
|
||||||
let last = (end - 1) / BLOCK_SIZE;
|
let last = (end - 1) / BLOCK_SIZE;
|
||||||
|
|
||||||
|
// Collect the blocks overlapping the request, serving any already cached
|
||||||
|
// and fetching the rest concurrently. A multi-block read (e.g. a media
|
||||||
|
// player buffering, or a large sequential read split into one FUSE call)
|
||||||
|
// would otherwise stall on each block round-trip in turn; downloading the
|
||||||
|
// misses in parallel saturates the connection and bounds latency at the
|
||||||
|
// slowest single block instead of their sum.
|
||||||
|
let mut blocks: Vec<Option<Vec<u8>>> = Vec::with_capacity((last - first + 1) as usize);
|
||||||
|
let mut misses: Vec<u64> = Vec::new();
|
||||||
for bidx in first..=last {
|
for bidx in first..=last {
|
||||||
let bstart = bidx * BLOCK_SIZE;
|
match self.cache.cached_block(uid, mtime, fsize, bidx) {
|
||||||
let block = match self.cache.cached_block(uid, mtime, fsize, bidx) {
|
Some(b) => blocks.push(Some(b)),
|
||||||
Some(b) => b,
|
|
||||||
None => {
|
None => {
|
||||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
blocks.push(None);
|
||||||
let bytes = self
|
misses.push(bidx);
|
||||||
.rt
|
|
||||||
.block_on(self.client.download_range(uid, bstart, blen))
|
|
||||||
.map_err(|e| {
|
|
||||||
warn!(%uid, bstart, blen, error = %e, "download_range failed");
|
|
||||||
Errno::EIO
|
|
||||||
})?;
|
|
||||||
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
|
|
||||||
bytes
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !misses.is_empty() {
|
||||||
|
let fetched = self.rt.block_on(async {
|
||||||
|
let mut set = tokio::task::JoinSet::new();
|
||||||
|
for &bidx in &misses {
|
||||||
|
let client = self.client.clone();
|
||||||
|
let uid = uid.clone();
|
||||||
|
let bstart = bidx * BLOCK_SIZE;
|
||||||
|
let blen = BLOCK_SIZE.min(fsize - bstart);
|
||||||
|
set.spawn(async move {
|
||||||
|
client
|
||||||
|
.download_range(&uid, bstart, blen)
|
||||||
|
.await
|
||||||
|
.map(|bytes| (bidx, bytes))
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(%uid, bstart, blen, error = %e, "download_range failed");
|
||||||
|
Errno::EIO
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(misses.len());
|
||||||
|
while let Some(joined) = set.join_next().await {
|
||||||
|
// A join error means the task panicked; surface it as EIO.
|
||||||
|
out.push(joined.map_err(|_| Errno::EIO)??);
|
||||||
|
}
|
||||||
|
Ok::<_, Errno>(out)
|
||||||
|
})?;
|
||||||
|
for (bidx, bytes) in fetched {
|
||||||
|
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
|
||||||
|
blocks[(bidx - first) as usize] = Some(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, block) in blocks.into_iter().enumerate() {
|
||||||
|
let bidx = first + i as u64;
|
||||||
|
let bstart = bidx * BLOCK_SIZE;
|
||||||
|
// Every slot is populated: cache hits up front, misses by the fetch above.
|
||||||
|
let block = block.expect("block fetched or cached");
|
||||||
let s = (offset.max(bstart) - bstart) as usize;
|
let s = (offset.max(bstart) - bstart) as usize;
|
||||||
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||||
if s < e {
|
if s < e {
|
||||||
@@ -869,6 +909,7 @@ impl Core {
|
|||||||
size,
|
size,
|
||||||
modified,
|
modified,
|
||||||
pinned: self.cache.is_pinned(&uid),
|
pinned: self.cache.is_pinned(&uid),
|
||||||
|
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
// Listing entries live in the requested dir; the caller derives
|
// Listing entries live in the requested dir; the caller derives
|
||||||
// the path from its name. Left empty.
|
// the path from its name. Left empty.
|
||||||
@@ -1046,6 +1087,144 @@ impl Core {
|
|||||||
.map_err(|e| format!("cache store: {e}"))?;
|
.map_err(|e| format!("cache store: {e}"))?;
|
||||||
Ok(self.cache.content_path(&uid))
|
Ok(self.cache.content_path(&uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the cached child listing of `rel`'s parent directory so the next
|
||||||
|
/// `ListDir` re-enumerates it from the server. No-op when the parent can't be
|
||||||
|
/// resolved (e.g. `rel` is the root). Resolves the parent without holding the
|
||||||
|
/// state lock, then invalidates under it.
|
||||||
|
fn invalidate_parent_listing(&self, rel: &Path) {
|
||||||
|
let parent = rel.parent().unwrap_or_else(|| Path::new(""));
|
||||||
|
if let Ok((pino, _)) = self.resolve_path(parent) {
|
||||||
|
self.state.lock().unwrap().invalidate_listing(pino);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a file or folder to `new_name`. `rel` is mountpoint-relative.
|
||||||
|
/// Mirrors the FUSE `rename` write path: rename on the remote, forget the
|
||||||
|
/// node so it re-interns under its new name, and drop the parent listing so
|
||||||
|
/// the next `ListDir` re-enumerates.
|
||||||
|
fn rename(&self, rel: &Path, new_name: &str) -> Result<String, String> {
|
||||||
|
if new_name.is_empty() || new_name.contains('/') {
|
||||||
|
return Err(format!("invalid name: {new_name:?}"));
|
||||||
|
}
|
||||||
|
let (_ino, uid) = self
|
||||||
|
.resolve_path(rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
|
self.rt
|
||||||
|
.block_on(self.client.rename_node(&uid, new_name, None))
|
||||||
|
.map_err(|e| format!("rename: {e}"))?;
|
||||||
|
self.state.lock().unwrap().forget(&uid);
|
||||||
|
self.invalidate_parent_listing(rel);
|
||||||
|
Ok(new_name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a file or folder into the folder at `new_parent_rel`. Both paths are
|
||||||
|
/// mountpoint-relative. Forgets the node and invalidates both the source and
|
||||||
|
/// destination listings so each re-enumerates on next access.
|
||||||
|
fn move_to(&self, rel: &Path, new_parent_rel: &Path) -> Result<String, String> {
|
||||||
|
let (_ino, uid) = self
|
||||||
|
.resolve_path(rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
|
let (pino, new_parent_uid) = self
|
||||||
|
.resolve_path(new_parent_rel)
|
||||||
|
.map_err(|e| format!("resolve new parent: {e:?}"))?;
|
||||||
|
self.rt
|
||||||
|
.block_on(self.client.move_node(&uid, &new_parent_uid))
|
||||||
|
.map_err(|e| format!("move: {e}"))?;
|
||||||
|
let name = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.forget(&uid)
|
||||||
|
.map(|(_, n)| n)
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.invalidate_parent_listing(rel);
|
||||||
|
self.state.lock().unwrap().invalidate_listing(pino);
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
|
||||||
|
/// evicts any cached content, and invalidates the parent listing.
|
||||||
|
fn delete(&self, rel: &Path) -> Result<String, String> {
|
||||||
|
let (_ino, uid) = self
|
||||||
|
.resolve_path(rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
|
self.rt
|
||||||
|
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
|
||||||
|
.map_err(|e| format!("trash: {e}"))?;
|
||||||
|
let name = self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.forget(&uid)
|
||||||
|
.map(|(_, n)| n)
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.cache.evict(&uid);
|
||||||
|
self.invalidate_parent_listing(rel);
|
||||||
|
Ok(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a folder named `name` under the mountpoint-relative `parent_rel`.
|
||||||
|
/// Interns the new node directly so it shows up without a re-enumeration.
|
||||||
|
fn create_folder(&self, parent_rel: &Path, name: &str) -> Result<String, String> {
|
||||||
|
if name.is_empty() || name.contains('/') {
|
||||||
|
return Err(format!("invalid name: {name:?}"));
|
||||||
|
}
|
||||||
|
let (pino, parent_uid) = self
|
||||||
|
.resolve_path(parent_rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
|
self.ensure_children(pino)
|
||||||
|
.map_err(|e| format!("enumerate: {e:?}"))?;
|
||||||
|
let new_uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.client
|
||||||
|
.create_folder(&parent_uid, name, Some(now_secs())),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("create folder: {e}"))?;
|
||||||
|
let node = self
|
||||||
|
.fetch_node(&new_uid)
|
||||||
|
.map_err(|e| format!("fetch node: {e:?}"))?;
|
||||||
|
let mut st = self.state.lock().unwrap();
|
||||||
|
let ino = st.intern(pino, node);
|
||||||
|
if let Some(kids) = st.children.get_mut(&pino)
|
||||||
|
&& !kids.contains(&ino)
|
||||||
|
{
|
||||||
|
kids.push(ino);
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a file named `name` with content `bytes` into the
|
||||||
|
/// mountpoint-relative `parent_rel` folder. Interns the new node directly.
|
||||||
|
fn upload(&self, parent_rel: &Path, name: &str, bytes: &[u8]) -> Result<String, String> {
|
||||||
|
if name.is_empty() || name.contains('/') {
|
||||||
|
return Err(format!("invalid name: {name:?}"));
|
||||||
|
}
|
||||||
|
let (pino, parent_uid) = self
|
||||||
|
.resolve_path(parent_rel)
|
||||||
|
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||||
|
self.ensure_children(pino)
|
||||||
|
.map_err(|e| format!("enumerate: {e:?}"))?;
|
||||||
|
let new_uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.client
|
||||||
|
.upload_file(&parent_uid, name, media_type_for(name), bytes),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("upload: {e}"))?;
|
||||||
|
let node = self
|
||||||
|
.fetch_node(&new_uid)
|
||||||
|
.map_err(|e| format!("fetch node: {e:?}"))?;
|
||||||
|
let mut st = self.state.lock().unwrap();
|
||||||
|
let ino = st.intern(pino, node);
|
||||||
|
if let Some(kids) = st.children.get_mut(&pino)
|
||||||
|
&& !kids.contains(&ino)
|
||||||
|
{
|
||||||
|
kids.push(ino);
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
||||||
@@ -2171,6 +2350,89 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||||
Err(e) => CtlResponse::Error { message: e },
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.rename(&rel, &new_name) {
|
||||||
|
Ok(name) => CtlResponse::Ok {
|
||||||
|
message: format!("renamed to {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::Move { path, new_parent }) => {
|
||||||
|
match (
|
||||||
|
rel_to_mount(mountpoint, &path),
|
||||||
|
rel_to_mount(mountpoint, &new_parent),
|
||||||
|
) {
|
||||||
|
(Ok(rel), Ok(parent_rel)) => match core.move_to(&rel, &parent_rel) {
|
||||||
|
Ok(name) => CtlResponse::Ok {
|
||||||
|
message: format!("moved {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
(Err(e), _) | (_, Err(e)) => CtlResponse::Error { message: e },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.delete(&rel) {
|
||||||
|
Ok(name) => CtlResponse::Ok {
|
||||||
|
message: format!("trashed {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) {
|
||||||
|
Ok(parent_rel) => match core.create_folder(&parent_rel, &name) {
|
||||||
|
Ok(name) => CtlResponse::Ok {
|
||||||
|
message: format!("created folder {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::UploadFile {
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
bytes,
|
||||||
|
}) => match rel_to_mount(mountpoint, &parent) {
|
||||||
|
Ok(parent_rel) => match core.upload(&parent_rel, &name, &bytes) {
|
||||||
|
Ok(name) => CtlResponse::Ok {
|
||||||
|
message: format!("uploaded {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error { message: e },
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PurgeCache) => {
|
||||||
|
let freed = core.cache.clear_unpinned();
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: format!(
|
||||||
|
"purged {:.1} MiB of unpinned cache",
|
||||||
|
freed as f64 / 1_048_576.0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::SetCacheBudget { bytes }) => {
|
||||||
|
core.cache.set_budget(bytes);
|
||||||
|
// Persist so the next mount keeps the new cap. Best-effort: a config
|
||||||
|
// write failure is reported but the live cap is already applied.
|
||||||
|
match AppDirs::new().map(|dirs| {
|
||||||
|
let mut cfg = dirs.load_config();
|
||||||
|
cfg.cache_budget = Some(bytes);
|
||||||
|
dirs.save_config(&cfg)
|
||||||
|
}) {
|
||||||
|
Ok(Ok(())) => CtlResponse::Ok {
|
||||||
|
message: format!("cache budget set to {bytes} bytes"),
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => CtlResponse::Error {
|
||||||
|
message: format!("budget applied but config write failed: {e}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::Error {
|
||||||
|
message: format!("budget applied but config unavailable: {e}"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => CtlResponse::Error {
|
Err(e) => CtlResponse::Error {
|
||||||
message: format!("bad request: {e}"),
|
message: format!("bad request: {e}"),
|
||||||
},
|
},
|
||||||
|
|||||||
+808
-132
File diff suppressed because it is too large.
Load diff
@@ -78,6 +78,20 @@ fn open_folder(mountpoint: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl Tray for DriveTray {
|
||||||
fn id(&self) -> String {
|
fn id(&self) -> String {
|
||||||
"io.narl.proton-drive-linux".into()
|
"io.narl.proton-drive-linux".into()
|
||||||
@@ -103,6 +117,12 @@ impl Tray for DriveTray {
|
|||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
MenuItem::Separator,
|
MenuItem::Separator,
|
||||||
|
StandardItem {
|
||||||
|
label: "Open Manager".into(),
|
||||||
|
activate: Box::new(|_: &mut Self| open_manager()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if self.state.mounted {
|
if self.state.mounted {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=0.2.0
|
pkgver=0.2.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
Reference in new issue
Block a user