mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d131d3dca8 | ||
|
|
5766bc4ed1 | ||
|
|
35bc957cd5 | ||
|
|
f9000f8540 | ||
|
|
d77d7ed817 | ||
|
|
56f2b35aca | ||
|
|
56abe3fe14 |
No files matched your search
Generated
+8
-8
@@ -2825,7 +2825,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "0.2.1"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.1",
|
||||
@@ -2840,7 +2840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "0.2.1"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"keyring",
|
||||
@@ -2857,7 +2857,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "0.2.1"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"libc",
|
||||
@@ -2870,7 +2870,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "0.2.1"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3085,9 +3085,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9380c88a032e2ff079b2d5a6221ccc40ebeee3ebbb4ea60d4d65ef770e4ca8d"
|
||||
checksum = "76e4399b4d9b076c5886c7db87ae7f70322dd7127e6c5a4e7eff8719434d2434"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@@ -3110,9 +3110,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1da22604d2568af2d15be6a45f23f8c5b95fa775bdac1540e833c2eafb587888"
|
||||
checksum = "099f6f20709452f2cdb0cee4db0a82e61a29bec261295812908bf956ffcdaa00"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
+4
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.1"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -11,8 +11,8 @@ 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.3"
|
||||
proton-drive-rs = "0.1.3"
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -33,3 +33,4 @@ clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
fuser = "0.17"
|
||||
libc = "0.2"
|
||||
|
||||
+143
-2
@@ -86,6 +86,41 @@ enum Command {
|
||||
#[arg(long, default_value_t = 50)]
|
||||
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,
|
||||
},
|
||||
/// Show the daemon's in-flight transfers (active uploads/downloads).
|
||||
Transfers,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -113,6 +148,12 @@ fn main() -> Result<()> {
|
||||
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||
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),
|
||||
Command::Transfers => cmd_transfers(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +264,10 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
let dirs = AppDirs::new()?;
|
||||
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)
|
||||
.with_context(|| format!("create mountpoint {}", mountpoint.display()))?;
|
||||
|
||||
@@ -235,7 +279,7 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
let cache = ContentCache::open(
|
||||
dirs.content_cache_dir(),
|
||||
dirs.pins_path(),
|
||||
pdfs_core::config::DEFAULT_CACHE_BUDGET_BYTES,
|
||||
config.resolved_cache_budget(),
|
||||
db.clone(),
|
||||
)
|
||||
.context("open content cache")?;
|
||||
@@ -325,6 +369,37 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_transfers() -> Result<()> {
|
||||
use pdfs_core::control::TransferDirection;
|
||||
match control_request(CtlRequest::GetQueueStatus)? {
|
||||
CtlResponse::Transfers { items } if items.is_empty() => println!("No active transfers."),
|
||||
CtlResponse::Transfers { items } => {
|
||||
for t in items {
|
||||
let arrow = match t.direction {
|
||||
TransferDirection::Download => "↓",
|
||||
TransferDirection::Upload => "↑",
|
||||
};
|
||||
let pct = if t.bytes_total > 0 {
|
||||
format!(
|
||||
"{:.0}%",
|
||||
100.0 * t.bytes_completed as f64 / t.bytes_total as f64
|
||||
)
|
||||
} else {
|
||||
"—".to_string()
|
||||
};
|
||||
let speed = t.speed_bytes_sec as f64 / 1_048_576.0;
|
||||
println!(
|
||||
"{arrow} {:>4} {:>8} / {:>8} {speed:>6.2} MiB/s {}",
|
||||
pct, t.bytes_completed, t.bytes_total, t.name
|
||||
);
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message } => bail!("{message}"),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_pins() -> Result<()> {
|
||||
match control_request(CtlRequest::ListPins)? {
|
||||
CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."),
|
||||
@@ -404,6 +479,72 @@ fn cmd_search(query: String, limit: usize) -> Result<()> {
|
||||
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
|
||||
/// paths are canonicalized (so the daemon can strip its mountpoint prefix);
|
||||
/// 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::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
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`
|
||||
/// 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).
|
||||
@@ -139,7 +142,7 @@ impl ContentCache {
|
||||
block_dir,
|
||||
scratch_dir,
|
||||
pins_path,
|
||||
max_bytes,
|
||||
max_bytes: AtomicU64::new(max_bytes),
|
||||
db,
|
||||
};
|
||||
cache.reconcile()?;
|
||||
@@ -366,7 +369,8 @@ impl ContentCache {
|
||||
/// `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) {
|
||||
if self.max_bytes == 0 {
|
||||
let cap = self.cap();
|
||||
if cap == 0 {
|
||||
return;
|
||||
}
|
||||
// LRU-ordered (oldest first) block entries straight from the index — no
|
||||
@@ -376,11 +380,11 @@ impl ContentCache {
|
||||
return;
|
||||
};
|
||||
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
||||
if total <= self.max_bytes {
|
||||
if total <= cap {
|
||||
return;
|
||||
}
|
||||
for (key, size) in entries {
|
||||
if total <= self.max_bytes {
|
||||
if total <= cap {
|
||||
break;
|
||||
}
|
||||
// `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
|
||||
/// 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. Resolves direct
|
||||
@@ -538,11 +543,11 @@ impl ContentCache {
|
||||
return;
|
||||
};
|
||||
let mut total: u64 = entries.iter().map(|(_, size)| *size).sum();
|
||||
if total <= self.max_bytes {
|
||||
if total <= cap {
|
||||
return;
|
||||
}
|
||||
for (key, size) in entries {
|
||||
if total <= self.max_bytes {
|
||||
if total <= cap {
|
||||
break;
|
||||
}
|
||||
if pinned.contains(&key) {
|
||||
@@ -624,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,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();
|
||||
|
||||
@@ -24,6 +24,16 @@ pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||
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 {
|
||||
@@ -31,10 +41,20 @@ impl Default for AppConfig {
|
||||
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,
|
||||
@@ -70,6 +90,18 @@ impl AppDirs {
|
||||
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.
|
||||
@@ -117,6 +149,16 @@ 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())?;
|
||||
@@ -135,6 +177,8 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -63,6 +63,60 @@ pub enum Request {
|
||||
/// 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 },
|
||||
/// 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 },
|
||||
/// Snapshot the daemon's in-flight transfers (active uploads/downloads).
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Which way an active transfer is moving, in a [`TransferItem`].
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransferDirection {
|
||||
Download,
|
||||
Upload,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -77,6 +131,11 @@ 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
|
||||
@@ -147,6 +206,8 @@ pub enum Response {
|
||||
FilePath { path: String },
|
||||
/// Full-text search results (reply to [`Request::Search`]).
|
||||
SearchResults { hits: Vec<SearchHit> },
|
||||
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
|
||||
Transfers { items: Vec<TransferItem> },
|
||||
/// The request failed.
|
||||
Error { message: String },
|
||||
}
|
||||
@@ -168,3 +229,43 @@ 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],
|
||||
},
|
||||
];
|
||||
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"])
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
+381
-29
@@ -49,16 +49,22 @@ use fuser::{
|
||||
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
||||
};
|
||||
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
|
||||
TransferDirection,
|
||||
};
|
||||
use pdfs_core::db::{Db, StoredNode};
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
|
||||
ProtonPhotosClient, ThumbnailType,
|
||||
};
|
||||
|
||||
mod transfers;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use transfers::{CountingReader, CountingWriter, TransferRegistry};
|
||||
|
||||
/// Attribute/entry cache lifetime handed back to the kernel. Long because the
|
||||
/// Phase 2 event poller actively invalidates changed inodes; without a remote
|
||||
@@ -292,6 +298,9 @@ struct Core {
|
||||
/// requests slice memory instead of re-fetching the whole timeline per page.
|
||||
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
|
||||
timeline: Arc<Mutex<Option<TimelineCache>>>,
|
||||
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
||||
/// across the FUSE session and the control-socket task.
|
||||
transfers: Arc<TransferRegistry>,
|
||||
}
|
||||
|
||||
/// The whole photos timeline plus when it was fetched, for TTL freshness.
|
||||
@@ -519,23 +528,62 @@ impl Core {
|
||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||
let first = offset / 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 {
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
let block = match self.cache.cached_block(uid, mtime, fsize, bidx) {
|
||||
Some(b) => b,
|
||||
match self.cache.cached_block(uid, mtime, fsize, bidx) {
|
||||
Some(b) => blocks.push(Some(b)),
|
||||
None => {
|
||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
||||
let bytes = self
|
||||
.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
|
||||
blocks.push(None);
|
||||
misses.push(bidx);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||
if s < e {
|
||||
@@ -641,6 +689,17 @@ impl Core {
|
||||
error!(%uid, error = %e, "reopen scratch file failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
let name = {
|
||||
let st = self.state.lock().unwrap();
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.map(|e| e.node.name.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let guard = self
|
||||
.transfers
|
||||
.begin(name, uid.to_string(), TransferDirection::Upload, len);
|
||||
let reader = CountingReader::new(reader, &guard);
|
||||
self.rt
|
||||
.block_on(self.client.upload_new_revision_from(
|
||||
&uid,
|
||||
@@ -653,6 +712,7 @@ impl Core {
|
||||
error!(%uid, error = %e, "upload new revision failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
drop(guard);
|
||||
let now = now_secs();
|
||||
{
|
||||
let mut st = self.state.lock().unwrap();
|
||||
@@ -679,6 +739,47 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a whole file's plaintext, registering the transfer so
|
||||
/// `GetQueueStatus` can report its progress. `total` is the expected size
|
||||
/// (`0` if unknown). Streams through [`download_file_to`] so each block ticks
|
||||
/// the progress counter.
|
||||
///
|
||||
/// [`download_file_to`]: ProtonDriveClient::download_file_to
|
||||
fn download_file_tracked(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
name: &str,
|
||||
total: u64,
|
||||
) -> std::result::Result<Vec<u8>, ProtonError> {
|
||||
let guard = self
|
||||
.transfers
|
||||
.begin(name, uid.to_string(), TransferDirection::Download, total);
|
||||
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
|
||||
self.rt
|
||||
.block_on(self.client.download_file_to(uid, &mut out))?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// Like [`download_file_tracked`] for a photo, streaming through the photos
|
||||
/// client's [`download_photo_to`].
|
||||
///
|
||||
/// [`download_file_tracked`]: Core::download_file_tracked
|
||||
/// [`download_photo_to`]: ProtonPhotosClient::download_photo_to
|
||||
fn download_photo_tracked(
|
||||
&self,
|
||||
photos: &ProtonPhotosClient,
|
||||
uid: &NodeUid,
|
||||
name: &str,
|
||||
total: u64,
|
||||
) -> std::result::Result<Vec<u8>, ProtonError> {
|
||||
let guard = self
|
||||
.transfers
|
||||
.begin(name, uid.to_string(), TransferDirection::Download, total);
|
||||
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
|
||||
self.rt.block_on(photos.download_photo_to(uid, &mut out))?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
||||
/// plaintext into the content cache; a folder records a recursive pin and
|
||||
/// downloads every descendant file (selective sync). Returns a human message.
|
||||
@@ -706,8 +807,7 @@ impl Core {
|
||||
return Ok(format!("{name} ({n} files)"));
|
||||
}
|
||||
let bytes = self
|
||||
.rt
|
||||
.block_on(self.client.download_file(&uid))
|
||||
.download_file_tracked(&uid, &name, size)
|
||||
.map_err(|e| format!("download: {e}"))?;
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
@@ -723,7 +823,7 @@ impl Core {
|
||||
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
|
||||
/// fully discovered; the lock is dropped before each network download.
|
||||
fn pin_subtree(&self, ino: u64) -> Result<usize, String> {
|
||||
let mut files: Vec<(NodeUid, i64, u64)> = Vec::new();
|
||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||
let mut stack = vec![ino];
|
||||
while let Some(dir) = stack.pop() {
|
||||
self.ensure_children(dir)
|
||||
@@ -737,6 +837,7 @@ impl Core {
|
||||
} else {
|
||||
files.push((
|
||||
e.uid.clone(),
|
||||
e.node.name.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
));
|
||||
@@ -746,12 +847,12 @@ impl Core {
|
||||
}
|
||||
}
|
||||
let mut count = 0;
|
||||
for (uid, mtime, size) in files {
|
||||
for (uid, name, mtime, size) in files {
|
||||
if self.cache.is_cached(&uid, mtime, size) {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
match self.rt.block_on(self.client.download_file(&uid)) {
|
||||
match self.download_file_tracked(&uid, &name, size) {
|
||||
Ok(bytes) => {
|
||||
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
|
||||
count += 1;
|
||||
@@ -869,6 +970,7 @@ impl Core {
|
||||
size,
|
||||
modified,
|
||||
pinned: self.cache.is_pinned(&uid),
|
||||
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
||||
uid: uid.to_string(),
|
||||
// Listing entries live in the requested dir; the caller derives
|
||||
// the path from its name. Left empty.
|
||||
@@ -1009,8 +1111,7 @@ impl Core {
|
||||
return Ok(p);
|
||||
}
|
||||
let bytes = self
|
||||
.rt
|
||||
.block_on(photos.download_photo(uid))
|
||||
.download_photo_tracked(&photos, uid, &node.name, size)
|
||||
.map_err(|e| format!("download photo: {e}"))?;
|
||||
self.cache
|
||||
.store(uid, mtime, size, &bytes)
|
||||
@@ -1026,26 +1127,178 @@ impl Core {
|
||||
let (ino, uid) = self
|
||||
.resolve_path(rel)
|
||||
.map_err(|e| format!("resolve path: {e:?}"))?;
|
||||
let (mtime, size) = {
|
||||
let (name, mtime, size) = {
|
||||
let st = self.state.lock().unwrap();
|
||||
let e = st.entries.get(&ino).ok_or("node vanished")?;
|
||||
if !e.node.is_file() {
|
||||
return Err("not a regular file".into());
|
||||
}
|
||||
(e.node.modification_time, node_size(&e.node))
|
||||
(
|
||||
e.node.name.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
)
|
||||
};
|
||||
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
|
||||
return Ok(p);
|
||||
}
|
||||
let bytes = self
|
||||
.rt
|
||||
.block_on(self.client.download_file(&uid))
|
||||
.download_file_tracked(&uid, &name, size)
|
||||
.map_err(|e| format!("download: {e}"))?;
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
.map_err(|e| format!("cache store: {e}"))?;
|
||||
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 guard = self
|
||||
.transfers
|
||||
.begin(name, "", TransferDirection::Upload, bytes.len() as u64);
|
||||
let reader = CountingReader::new(std::io::Cursor::new(bytes), &guard);
|
||||
let new_uid = self
|
||||
.rt
|
||||
.block_on(self.client.upload_file_from(
|
||||
&parent_uid,
|
||||
name,
|
||||
media_type_for(name),
|
||||
reader,
|
||||
bytes.len() as i64,
|
||||
Vec::new(),
|
||||
None,
|
||||
false,
|
||||
))
|
||||
.map_err(|e| format!("upload: {e}"))?;
|
||||
drop(guard);
|
||||
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
|
||||
@@ -2143,10 +2396,22 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
capture_time,
|
||||
..Default::default()
|
||||
};
|
||||
match core
|
||||
.rt
|
||||
.block_on(photos.upload_photo(&name, &media_type, &bytes, metadata))
|
||||
{
|
||||
let guard = core.transfers.begin(
|
||||
name.clone(),
|
||||
"",
|
||||
TransferDirection::Upload,
|
||||
bytes.len() as u64,
|
||||
);
|
||||
let reader = CountingReader::new(std::io::Cursor::new(&bytes), &guard);
|
||||
match core.rt.block_on(photos.upload_photo_from(
|
||||
&name,
|
||||
&media_type,
|
||||
reader,
|
||||
bytes.len() as i64,
|
||||
Vec::new(),
|
||||
metadata,
|
||||
false,
|
||||
)) {
|
||||
Ok(uid) => {
|
||||
*core.timeline.lock().unwrap() = None;
|
||||
CtlResponse::Ok {
|
||||
@@ -2171,6 +2436,92 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||
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::GetQueueStatus) => CtlResponse::Transfers {
|
||||
items: core.transfers.snapshot(),
|
||||
},
|
||||
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 {
|
||||
message: format!("bad request: {e}"),
|
||||
},
|
||||
@@ -2277,6 +2628,7 @@ pub fn mount(
|
||||
cache: Arc::new(cache),
|
||||
db,
|
||||
timeline: Arc::new(Mutex::new(None)),
|
||||
transfers: TransferRegistry::new(),
|
||||
};
|
||||
|
||||
let mut config = Config::default();
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! In-memory registry of in-flight transfers, behind [`Request::GetQueueStatus`].
|
||||
//!
|
||||
//! The daemon downloads and uploads whole files through the SDK's streaming
|
||||
//! [`Read`]/[`Write`] variants. 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. A
|
||||
//! [`TransferGuard`] registers a transfer on creation and deregisters it on
|
||||
//! drop, so the registry always reflects exactly what is in flight — even if a
|
||||
//! transfer 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::{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,
|
||||
}
|
||||
|
||||
/// The set of transfers 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>>>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot every in-flight transfer for [`Response::Transfers`]. 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();
|
||||
map.values()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ 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
|
||||
|
||||
+935
-133
File diff suppressed because it is too large.
Load diff
@@ -18,7 +18,7 @@ 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::{Request, Response, TransferDirection, TransferItem, send};
|
||||
use pdfs_core::service;
|
||||
|
||||
/// How often the tray re-polls the daemon to refresh its menu.
|
||||
@@ -33,6 +33,42 @@ 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 transfer 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.
|
||||
fn sync_line(items: &[TransferItem]) -> String {
|
||||
match items {
|
||||
[] => 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 {
|
||||
@@ -49,12 +85,18 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> 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 }) => sync_line(&items),
|
||||
_ => 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(_) => {
|
||||
@@ -67,6 +109,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
line,
|
||||
mounted: false,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +121,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 {
|
||||
fn id(&self) -> String {
|
||||
"io.narl.proton-drive-linux".into()
|
||||
@@ -102,9 +159,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 {
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
//! `pdfs-prompt` — a fast, Google Drive-style launcher prompt for searching
|
||||
//! and opening files or folders in Proton Drive.
|
||||
//!
|
||||
//! Designed to be bound to a system shortcut (e.g. in Hyprland) for quick HUD search.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gtk4::glib;
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{Request, Response, SearchHit, send};
|
||||
|
||||
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
||||
const PROTON_PURPLE: &str = "#6d4aff";
|
||||
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Run one blocking control-socket round-trip on a worker thread, returning a
|
||||
/// channel that yields the [`Response`] once.
|
||||
fn spawn_request(
|
||||
socket: PathBuf,
|
||||
req: Request,
|
||||
) -> async_channel::Receiver<Result<Response, String>> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let result = send(&socket, &req).map_err(|e| e.to_string());
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||
|
||||
app.connect_startup(|_| {
|
||||
load_prompt_theme();
|
||||
});
|
||||
|
||||
app.connect_activate(build_window);
|
||||
|
||||
app.run()
|
||||
}
|
||||
|
||||
/// Override accent colors and style the launcher for a premium HUD look.
|
||||
fn load_prompt_theme() {
|
||||
let css = format!(
|
||||
"@define-color accent_bg_color {PROTON_PURPLE};\n\
|
||||
@define-color accent_color {PROTON_PURPLE};\n\
|
||||
\n\
|
||||
window.launcher-window {{\n\
|
||||
background-color: transparent;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-container {{\n\
|
||||
background-color: @window_bg_color;\n\
|
||||
border: 2px solid {PROTON_PURPLE};\n\
|
||||
border-radius: 12px;\n\
|
||||
padding: 12px;\n\
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-header-box {{\n\
|
||||
padding: 6px 12px;\n\
|
||||
background-color: alpha(@view_bg_color, 0.4);\n\
|
||||
border-radius: 8px;\n\
|
||||
border: 1px solid alpha({PROTON_PURPLE}, 0.2);\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-entry {{\n\
|
||||
font-size: 1.15rem;\n\
|
||||
background: transparent;\n\
|
||||
border: none;\n\
|
||||
box-shadow: none;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-listbox {{\n\
|
||||
background: transparent;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-row {{\n\
|
||||
padding: 10px 14px;\n\
|
||||
border-radius: 6px;\n\
|
||||
margin: 2px 0px;\n\
|
||||
transition: background-color 0.15s ease, color 0.15s ease;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-row:hover {{\n\
|
||||
background-color: alpha({PROTON_PURPLE}, 0.1);\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-row:selected {{\n\
|
||||
background-color: {PROTON_PURPLE};\n\
|
||||
color: white;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-row:selected .dim-label {{\n\
|
||||
color: alpha(white, 0.7);\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-footer {{\n\
|
||||
padding: 6px 12px 0px 12px;\n\
|
||||
font-size: 0.85rem;\n\
|
||||
border-top: 1px solid alpha({PROTON_PURPLE}, 0.1);\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.dim-label {{\n\
|
||||
color: @view_fg_color;\n\
|
||||
opacity: 0.6;\n\
|
||||
font-size: 0.85rem;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.error-box {{\n\
|
||||
padding: 24px;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.error-title {{\n\
|
||||
font-size: 1.25rem;\n\
|
||||
font-weight: bold;\n\
|
||||
margin-bottom: 8px;\n\
|
||||
}}\n"
|
||||
);
|
||||
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_window(app: &adw::Application) {
|
||||
let dirs = match AppDirs::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("cannot resolve app dirs: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let socket = dirs.control_socket();
|
||||
let default_mountpoint = dirs.default_mountpoint();
|
||||
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Proton Drive Launcher")
|
||||
.default_width(650)
|
||||
.default_height(450)
|
||||
.decorated(false)
|
||||
.build();
|
||||
|
||||
window.add_css_class("launcher-window");
|
||||
|
||||
// Prefer dark mode for high-tech HUD look
|
||||
let style_manager = adw::StyleManager::default();
|
||||
style_manager.set_color_scheme(adw::ColorScheme::PreferDark);
|
||||
|
||||
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||
container.add_css_class("launcher-container");
|
||||
window.set_content(Some(&container));
|
||||
|
||||
// Header box: search icon, entry field, and loading spinner
|
||||
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header_box.add_css_class("launcher-header-box");
|
||||
|
||||
let search_icon = gtk4::Image::from_icon_name("system-search-symbolic");
|
||||
header_box.append(&search_icon);
|
||||
|
||||
let entry = gtk4::Entry::builder()
|
||||
.placeholder_text("Search Proton Drive...")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
entry.add_css_class("launcher-entry");
|
||||
header_box.append(&entry);
|
||||
|
||||
let spinner = gtk4::Spinner::new();
|
||||
spinner.set_visible(false);
|
||||
header_box.append(&spinner);
|
||||
|
||||
container.append(&header_box);
|
||||
|
||||
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
|
||||
container.append(&separator);
|
||||
|
||||
// View stack to transition between results list and warning page
|
||||
let stack = adw::ViewStack::new();
|
||||
container.append(&stack);
|
||||
|
||||
// Page 1: Scrolled window and listbox for results
|
||||
let list_box = gtk4::ListBox::new();
|
||||
list_box.add_css_class("launcher-listbox");
|
||||
|
||||
let scrolled_window = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.child(&list_box)
|
||||
.build();
|
||||
stack.add_named(&scrolled_window, Some("search"));
|
||||
|
||||
// Page 2: Error screen if daemon is offline
|
||||
let error_box = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
error_box.add_css_class("error-box");
|
||||
error_box.set_valign(gtk4::Align::Center);
|
||||
error_box.set_halign(gtk4::Align::Center);
|
||||
|
||||
let error_icon = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||
error_icon.set_pixel_size(48);
|
||||
error_icon.add_css_class("brand-icon");
|
||||
error_box.append(&error_icon);
|
||||
|
||||
let error_title = gtk4::Label::builder()
|
||||
.label("Daemon Offline")
|
||||
.justify(gtk4::Justification::Center)
|
||||
.build();
|
||||
error_title.add_css_class("error-title");
|
||||
error_box.append(&error_title);
|
||||
|
||||
let error_desc = gtk4::Label::builder()
|
||||
.label("Proton Drive mount daemon is not running.\nStart the daemon or run `pdfs mount` to connect.")
|
||||
.justify(gtk4::Justification::Center)
|
||||
.build();
|
||||
error_desc.add_css_class("dim-label");
|
||||
error_box.append(&error_desc);
|
||||
|
||||
let retry_button = gtk4::Button::builder()
|
||||
.label("Retry Connection")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry_button.add_css_class("suggested-action");
|
||||
retry_button.add_css_class("pill");
|
||||
error_box.append(&retry_button);
|
||||
|
||||
stack.add_named(&error_box, Some("error"));
|
||||
|
||||
// Footer box: status label
|
||||
let footer_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
footer_box.add_css_class("launcher-footer");
|
||||
|
||||
let status_label = gtk4::Label::builder()
|
||||
.label("Initialising...")
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
status_label.add_css_class("dim-label");
|
||||
footer_box.append(&status_label);
|
||||
|
||||
container.append(&footer_box);
|
||||
|
||||
// Shared state variables
|
||||
let hits = Rc::new(RefCell::new(Vec::<SearchHit>::new()));
|
||||
let mountpoint = Rc::new(RefCell::new(default_mountpoint.display().to_string()));
|
||||
let is_opening = Rc::new(Cell::new(false));
|
||||
|
||||
// Handle retry on connection failure
|
||||
let stack_clone = stack.clone();
|
||||
let socket_clone = socket.clone();
|
||||
let mountpoint_clone = mountpoint.clone();
|
||||
let status_label_clone = status_label.clone();
|
||||
let spinner_clone = spinner.clone();
|
||||
let list_box_clone = list_box.clone();
|
||||
let hits_clone = hits.clone();
|
||||
let entry_clone = entry.clone();
|
||||
|
||||
let check_connection = move || {
|
||||
status_label_clone.set_text("Connecting to daemon...");
|
||||
match send(&socket_clone, &Request::Status) {
|
||||
Ok(Response::Status { mountpoint: mp, .. }) => {
|
||||
*mountpoint_clone.borrow_mut() = mp;
|
||||
stack_clone.set_visible_child_name("search");
|
||||
entry_clone.set_sensitive(true);
|
||||
load_initial_pins(
|
||||
&list_box_clone,
|
||||
&hits_clone,
|
||||
&socket_clone,
|
||||
&status_label_clone,
|
||||
&spinner_clone,
|
||||
);
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
stack_clone.set_visible_child_name("error");
|
||||
entry_clone.set_sensitive(false);
|
||||
status_label_clone.set_text("Daemon offline.");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let check_conn = check_connection.clone();
|
||||
retry_button.connect_clicked(move |_| {
|
||||
check_conn();
|
||||
});
|
||||
|
||||
window.connect_is_active_notify(move |w| {
|
||||
if !w.is_active() {
|
||||
w.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-focus search entry on window show
|
||||
let entry_focus = entry.clone();
|
||||
window.connect_map(move |_| {
|
||||
entry_focus.grab_focus();
|
||||
});
|
||||
|
||||
// Event key controller on entry field for keyboard shortcuts
|
||||
let key_controller = gtk4::EventControllerKey::new();
|
||||
let list_box_keys = list_box.clone();
|
||||
let window_keys = window.clone();
|
||||
let entry_keys = entry.clone();
|
||||
let hits_keys = hits.clone();
|
||||
let is_opening_keys = is_opening.clone();
|
||||
|
||||
key_controller.connect_key_pressed(move |_, key, _keycode, _state| {
|
||||
if is_opening_keys.get() {
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
|
||||
match key {
|
||||
gtk4::gdk::Key::Escape => {
|
||||
window_keys.close();
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Down => {
|
||||
let count = hits_keys.borrow().len();
|
||||
if count > 0 {
|
||||
let selected = list_box_keys.selected_row();
|
||||
let next_idx = if let Some(row) = selected {
|
||||
(row.index() + 1) % count as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if let Some(row) = list_box_keys.row_at_index(next_idx) {
|
||||
list_box_keys.select_row(Some(&row));
|
||||
row.grab_focus();
|
||||
entry_keys.grab_focus();
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Up => {
|
||||
let count = hits_keys.borrow().len();
|
||||
if count > 0 {
|
||||
let selected = list_box_keys.selected_row();
|
||||
let next_idx = if let Some(row) = selected {
|
||||
(row.index() - 1 + count as i32) % count as i32
|
||||
} else {
|
||||
(count - 1) as i32
|
||||
};
|
||||
if let Some(row) = list_box_keys.row_at_index(next_idx) {
|
||||
list_box_keys.select_row(Some(&row));
|
||||
row.grab_focus();
|
||||
entry_keys.grab_focus();
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ => glib::Propagation::Proceed,
|
||||
}
|
||||
});
|
||||
entry.add_controller(key_controller);
|
||||
|
||||
// Entry activation (pressing Return/Enter)
|
||||
let list_box_activate = list_box.clone();
|
||||
let hits_activate = hits.clone();
|
||||
let window_activate = window.clone();
|
||||
let socket_activate = socket.clone();
|
||||
let mountpoint_activate = mountpoint.clone();
|
||||
let status_label_activate = status_label.clone();
|
||||
let spinner_activate = spinner.clone();
|
||||
let is_opening_activate = is_opening.clone();
|
||||
|
||||
entry.connect_activate(move |_| {
|
||||
if is_opening_activate.get() {
|
||||
return;
|
||||
}
|
||||
if let Some(row) = list_box_activate.selected_row() {
|
||||
let idx = row.index() as usize;
|
||||
let hits_borrow = hits_activate.borrow();
|
||||
if idx < hits_borrow.len() {
|
||||
let hit = &hits_borrow[idx];
|
||||
open_hit(
|
||||
&window_activate,
|
||||
hit,
|
||||
&socket_activate,
|
||||
&mountpoint_activate,
|
||||
&status_label_activate,
|
||||
&spinner_activate,
|
||||
is_opening_activate.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// List box row activation (mouse clicks / touch taps)
|
||||
let hits_act = hits.clone();
|
||||
let window_act = window.clone();
|
||||
let socket_act = socket.clone();
|
||||
let mountpoint_act = mountpoint.clone();
|
||||
let status_label_act = status_label.clone();
|
||||
let spinner_act = spinner.clone();
|
||||
let is_opening_act = is_opening.clone();
|
||||
|
||||
list_box.connect_row_activated(move |_, row| {
|
||||
if is_opening_act.get() {
|
||||
return;
|
||||
}
|
||||
let idx = row.index() as usize;
|
||||
let hits_borrow = hits_act.borrow();
|
||||
if idx < hits_borrow.len() {
|
||||
let hit = &hits_borrow[idx];
|
||||
open_hit(
|
||||
&window_act,
|
||||
hit,
|
||||
&socket_act,
|
||||
&mountpoint_act,
|
||||
&status_label_act,
|
||||
&spinner_act,
|
||||
is_opening_act.clone(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Debounced search text entry listener
|
||||
let search_source = Rc::new(RefCell::new(None::<glib::SourceId>));
|
||||
let search_source_changed = search_source.clone();
|
||||
let list_box_search = list_box.clone();
|
||||
let hits_search = hits.clone();
|
||||
let socket_search = socket.clone();
|
||||
let status_label_search = status_label.clone();
|
||||
let spinner_search = spinner.clone();
|
||||
|
||||
entry.connect_changed(move |ent| {
|
||||
if let Some(src) = search_source_changed.borrow_mut().take() {
|
||||
src.remove();
|
||||
}
|
||||
|
||||
let ent_clone = ent.clone();
|
||||
let list_box_clone = list_box_search.clone();
|
||||
let hits_clone = hits_search.clone();
|
||||
let socket_clone = socket_search.clone();
|
||||
let status_label_clone = status_label_search.clone();
|
||||
let spinner_clone = spinner_search.clone();
|
||||
let search_source_cb = search_source_changed.clone();
|
||||
|
||||
let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || {
|
||||
search_source_cb.borrow_mut().take();
|
||||
let query = ent_clone.text().trim().to_string();
|
||||
if query.is_empty() {
|
||||
load_initial_pins(
|
||||
&list_box_clone,
|
||||
&hits_clone,
|
||||
&socket_clone,
|
||||
&status_label_clone,
|
||||
&spinner_clone,
|
||||
);
|
||||
} else {
|
||||
run_prompt_search(
|
||||
&list_box_clone,
|
||||
&hits_clone,
|
||||
&socket_clone,
|
||||
&query,
|
||||
&status_label_clone,
|
||||
&spinner_clone,
|
||||
);
|
||||
}
|
||||
});
|
||||
*search_source_changed.borrow_mut() = Some(src);
|
||||
});
|
||||
|
||||
// Perform the initial connection and load check
|
||||
check_connection();
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Retrieve pinned files from the daemon and render them in the results list.
|
||||
fn load_initial_pins(
|
||||
list_box: >k4::ListBox,
|
||||
hits: &Rc<RefCell<Vec<SearchHit>>>,
|
||||
socket: &Path,
|
||||
status_label: >k4::Label,
|
||||
spinner: >k4::Spinner,
|
||||
) {
|
||||
status_label.set_text("Loading pinned files...");
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
|
||||
let rx = spawn_request(socket.to_path_buf(), Request::ListPins);
|
||||
let list_box = list_box.clone();
|
||||
let hits = hits.clone();
|
||||
let status_label = status_label.clone();
|
||||
let spinner = spinner.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let res = rx.recv().await;
|
||||
spinner.stop();
|
||||
spinner.set_visible(false);
|
||||
|
||||
match res {
|
||||
Ok(Ok(Response::Pins { pins })) => {
|
||||
let mapped_hits: Vec<SearchHit> = pins
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let path_obj = Path::new(&p.path);
|
||||
let name = path_obj
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&p.path)
|
||||
.to_string();
|
||||
SearchHit {
|
||||
name,
|
||||
path: p.path,
|
||||
is_dir: p.recursive,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
uid: p.uid,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
repaint_prompt_results(&list_box, &hits, &mapped_hits);
|
||||
if mapped_hits.is_empty() {
|
||||
status_label.set_text("No pinned files. Type to search.");
|
||||
} else {
|
||||
status_label.set_text("Showing pinned files. Type to search.");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
status_label.set_text("Failed to load pinned files. Type to search.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run search on the daemon and update the results list.
|
||||
fn run_prompt_search(
|
||||
list_box: >k4::ListBox,
|
||||
hits: &Rc<RefCell<Vec<SearchHit>>>,
|
||||
socket: &Path,
|
||||
query: &str,
|
||||
status_label: >k4::Label,
|
||||
spinner: >k4::Spinner,
|
||||
) {
|
||||
status_label.set_text("Searching...");
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
|
||||
let rx = spawn_request(
|
||||
socket.to_path_buf(),
|
||||
Request::Search {
|
||||
query: query.to_string(),
|
||||
limit: 30,
|
||||
},
|
||||
);
|
||||
let list_box = list_box.clone();
|
||||
let hits = hits.clone();
|
||||
let status_label = status_label.clone();
|
||||
let spinner = spinner.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let res = rx.recv().await;
|
||||
spinner.stop();
|
||||
spinner.set_visible(false);
|
||||
|
||||
match res {
|
||||
Ok(Ok(Response::SearchResults { hits: search_hits })) => {
|
||||
repaint_prompt_results(&list_box, &hits, &search_hits);
|
||||
if search_hits.is_empty() {
|
||||
status_label.set_text("No matches found.");
|
||||
} else {
|
||||
status_label.set_text(&format!("Found {} matches.", search_hits.len()));
|
||||
}
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => {
|
||||
status_label.set_text(&format!("Search error: {message}"));
|
||||
}
|
||||
_ => {
|
||||
status_label.set_text("Search request failed.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the provided search hits into the ListBox.
|
||||
fn repaint_prompt_results(
|
||||
list_box: >k4::ListBox,
|
||||
hits: &Rc<RefCell<Vec<SearchHit>>>,
|
||||
new_hits: &[SearchHit],
|
||||
) {
|
||||
// Clear all rows
|
||||
while let Some(child) = list_box.first_child() {
|
||||
list_box.remove(&child);
|
||||
}
|
||||
|
||||
*hits.borrow_mut() = new_hits.to_vec();
|
||||
|
||||
for hit in new_hits {
|
||||
let row = gtk4::ListBoxRow::new();
|
||||
row.add_css_class("launcher-row");
|
||||
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
|
||||
let icon_name = if hit.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"document-symbolic"
|
||||
};
|
||||
let icon = gtk4::Image::from_icon_name(icon_name);
|
||||
row_box.append(&icon);
|
||||
|
||||
let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
text_box.set_hexpand(true);
|
||||
|
||||
let name_label = gtk4::Label::builder()
|
||||
.label(&hit.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
|
||||
let path_label = gtk4::Label::builder()
|
||||
.label(&hit.path)
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
path_label.add_css_class("dim-label");
|
||||
|
||||
text_box.append(&name_label);
|
||||
text_box.append(&path_label);
|
||||
|
||||
row_box.append(&text_box);
|
||||
row.set_child(Some(&row_box));
|
||||
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
// Automatically select the first item
|
||||
if !new_hits.is_empty()
|
||||
&& let Some(row) = list_box.row_at_index(0) {
|
||||
list_box.select_row(Some(&row));
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the selected search hit. Directories open FUSE directly, files trigger cache download.
|
||||
fn open_hit(
|
||||
window: &adw::ApplicationWindow,
|
||||
hit: &SearchHit,
|
||||
socket: &Path,
|
||||
mountpoint: &Rc<RefCell<String>>,
|
||||
status_label: >k4::Label,
|
||||
spinner: >k4::Spinner,
|
||||
is_opening: Rc<Cell<bool>>,
|
||||
) {
|
||||
if hit.is_dir {
|
||||
let mp = mountpoint.borrow();
|
||||
let full_path = Path::new(&*mp).join(&hit.path);
|
||||
tracing::info!("Opening directory: {}", full_path.display());
|
||||
if let Err(e) = Command::new("xdg-open").arg(&full_path).spawn() {
|
||||
tracing::error!("Failed to xdg-open directory: {e}");
|
||||
}
|
||||
window.close();
|
||||
} else {
|
||||
is_opening.set(true);
|
||||
status_label.set_text(&format!("Downloading {}...", hit.name));
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
|
||||
let rx = spawn_request(
|
||||
socket.to_path_buf(),
|
||||
Request::OpenFile {
|
||||
path: hit.path.clone(),
|
||||
},
|
||||
);
|
||||
let window_clone = window.clone();
|
||||
let status_label_clone = status_label.clone();
|
||||
let spinner_clone = spinner.clone();
|
||||
let is_opening_clone = is_opening.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let res = rx.recv().await;
|
||||
spinner_clone.stop();
|
||||
spinner_clone.set_visible(false);
|
||||
is_opening_clone.set(false);
|
||||
|
||||
match res {
|
||||
Ok(Ok(Response::FilePath { path })) => {
|
||||
tracing::info!("Opening file: {}", path);
|
||||
if let Err(e) = Command::new("xdg-open").arg(&path).spawn() {
|
||||
tracing::error!("Failed to xdg-open file: {e}");
|
||||
}
|
||||
window_clone.close();
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => {
|
||||
status_label_clone.set_text(&format!("Error: {message}"));
|
||||
}
|
||||
_ => {
|
||||
status_label_clone.set_text("Error: Failed to communicate with daemon.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -8,7 +8,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=0.2.0
|
||||
pkgver=0.2.1
|
||||
pkgrel=1
|
||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
@@ -43,7 +43,8 @@ build() {
|
||||
cargo build --release --frozen \
|
||||
--bin pdfs \
|
||||
--bin pdfs-tray \
|
||||
--bin pdfs-app
|
||||
--bin pdfs-app \
|
||||
--bin pdfs-prompt
|
||||
}
|
||||
|
||||
package() {
|
||||
@@ -53,6 +54,7 @@ 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"
|
||||
|
||||
Reference in new issue
Block a user