mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172ec4b0b7 | ||
|
|
ec4f5ea32f | ||
|
|
81a0ecce54 | ||
|
|
cc2f370670 | ||
|
|
b3e8e8ab96 | ||
|
|
8e8d42ad3b | ||
|
|
99e80a7206 |
No files matched your search
Generated
+4
-4
@@ -2772,7 +2772,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.1",
|
"clap 4.6.1",
|
||||||
@@ -2787,7 +2787,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"keyring",
|
"keyring",
|
||||||
@@ -2803,7 +2803,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2816,7 +2816,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ enum Command {
|
|||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
/// Run the auto-mount daemon: wait for login, mount, and keep it mounted
|
||||||
|
/// until stopped. This is what the systemd user service runs; you normally
|
||||||
|
/// don't invoke it by hand.
|
||||||
|
Daemon {
|
||||||
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
|
mountpoint: Option<PathBuf>,
|
||||||
|
},
|
||||||
/// Keep a file on this device (download + cache it). Needs a running mount.
|
/// Keep a file on this device (download + cache it). Needs a running mount.
|
||||||
Pin {
|
Pin {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File path, inside the mountpoint or relative to it.
|
||||||
@@ -88,6 +95,7 @@ fn main() -> Result<()> {
|
|||||||
Command::Logout => cmd_logout(),
|
Command::Logout => cmd_logout(),
|
||||||
Command::Status => cmd_status(),
|
Command::Status => cmd_status(),
|
||||||
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
||||||
|
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
|
||||||
Command::Pin { path } => cmd_pin(path),
|
Command::Pin { path } => cmd_pin(path),
|
||||||
Command::Unpin { path } => cmd_unpin(path),
|
Command::Unpin { path } => cmd_unpin(path),
|
||||||
Command::Pins => cmd_pins(),
|
Command::Pins => cmd_pins(),
|
||||||
@@ -158,6 +166,50 @@ fn cmd_status() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
mount_once(mountpoint)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
|
||||||
|
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
|
||||||
|
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||||
|
/// the entry point for the systemd user service.
|
||||||
|
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
// Wait until a session is stored. The GUI enables this service on login,
|
||||||
|
// but the service may also start at boot before the user has logged in.
|
||||||
|
loop {
|
||||||
|
match auth::load() {
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||||
|
tracing::info!("not logged in; waiting…");
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match mount_once(mountpoint.clone()) {
|
||||||
|
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||||
|
tracing::info!("daemon stopping");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
||||||
|
tracing::warn!("mount ended externally; remounting in 2s");
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "mount failed; retrying in 5s");
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One mount run: resume the session, mount, and block until the mount ends
|
||||||
|
/// (clean shutdown or external unmount). Returns the [`MountOutcome`] so the
|
||||||
|
/// daemon loop can decide whether to exit or remount.
|
||||||
|
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 mountpoint = mountpoint.unwrap_or_else(|| dirs.default_mountpoint());
|
||||||
@@ -230,8 +282,8 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
|||||||
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
|
tracing::warn!(error = %e, "failed to persist tokens on shutdown");
|
||||||
}
|
}
|
||||||
|
|
||||||
result.context("mount failed")?;
|
let outcome = result.context("mount failed")?;
|
||||||
Ok(())
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_pin(path: PathBuf) -> Result<()> {
|
fn cmd_pin(path: PathBuf) -> Result<()> {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ use sha2::{Digest, Sha256};
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// Block size for the on-demand block cache. Matches the SDK content block size
|
||||||
|
/// (`DEFAULT_BLOCK_SIZE`, 4 MiB) so each cached block maps to exactly one
|
||||||
|
/// `download_range` fetch with no straddling.
|
||||||
|
pub const BLOCK_SIZE: u64 = 1 << 22;
|
||||||
|
|
||||||
/// Validity tag stored alongside a cached blob. A blob is fresh only if both
|
/// Validity tag stored alongside a cached blob. A blob is fresh only if both
|
||||||
/// fields still match the node's current metadata.
|
/// fields still match the node's current metadata.
|
||||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -61,6 +66,13 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
||||||
/// of `content_dir` so the budget scan never sees thumbnail files.
|
/// of `content_dir` so the budget scan never sees thumbnail files.
|
||||||
thumb_dir: PathBuf,
|
thumb_dir: PathBuf,
|
||||||
|
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
||||||
|
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
||||||
|
/// sees them; blocks carry their own LRU budget.
|
||||||
|
block_dir: PathBuf,
|
||||||
|
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||||
|
/// Emptied on open so a crashed run leaves no orphans.
|
||||||
|
scratch_dir: PathBuf,
|
||||||
/// JSON pin registry path.
|
/// JSON pin registry path.
|
||||||
pins_path: PathBuf,
|
pins_path: PathBuf,
|
||||||
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
|
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
|
||||||
@@ -78,12 +90,21 @@ impl ContentCache {
|
|||||||
std::fs::create_dir_all(&content_dir)?;
|
std::fs::create_dir_all(&content_dir)?;
|
||||||
let thumb_dir = content_dir.join("thumbs");
|
let thumb_dir = content_dir.join("thumbs");
|
||||||
std::fs::create_dir_all(&thumb_dir)?;
|
std::fs::create_dir_all(&thumb_dir)?;
|
||||||
|
let block_dir = content_dir.join("blocks");
|
||||||
|
std::fs::create_dir_all(&block_dir)?;
|
||||||
|
// Scratch holds disk-backed write buffers; a previous run's leftovers are
|
||||||
|
// worthless, so start clean.
|
||||||
|
let scratch_dir = content_dir.join("scratch");
|
||||||
|
let _ = std::fs::remove_dir_all(&scratch_dir);
|
||||||
|
std::fs::create_dir_all(&scratch_dir)?;
|
||||||
if let Some(parent) = pins_path.parent() {
|
if let Some(parent) = pins_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
content_dir,
|
content_dir,
|
||||||
thumb_dir,
|
thumb_dir,
|
||||||
|
block_dir,
|
||||||
|
scratch_dir,
|
||||||
pins_path,
|
pins_path,
|
||||||
max_bytes,
|
max_bytes,
|
||||||
})
|
})
|
||||||
@@ -180,6 +201,123 @@ impl ContentCache {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn block_blob(&self, uid: &NodeUid, idx: u64) -> PathBuf {
|
||||||
|
self.block_dir.join(format!("{}.b{idx}", Self::key(uid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf {
|
||||||
|
self.block_dir
|
||||||
|
.join(format!("{}.b{idx}.meta", Self::key(uid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve cached block `idx` (a [`BLOCK_SIZE`]-aligned chunk) of `uid`, or
|
||||||
|
/// `None` on miss/stale. Validated against `(mtime, size)` like a whole-file
|
||||||
|
/// blob, so a new revision (which bumps the mtime) is detected. Bumps the
|
||||||
|
/// block's mtime for LRU, best effort.
|
||||||
|
pub fn cached_block(&self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Vec<u8>> {
|
||||||
|
let want = Meta { mtime, size };
|
||||||
|
let meta: Meta =
|
||||||
|
serde_json::from_slice(&std::fs::read(self.block_meta(uid, idx)).ok()?).ok()?;
|
||||||
|
if meta != want {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
|
||||||
|
let _ = f.set_times(std::fs::FileTimes::new().set_modified(SystemTime::now()));
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
f.read_to_end(&mut buf).ok()?;
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store `bytes` as cached block `idx` of `uid`, tagged `(mtime, size)`.
|
||||||
|
/// Temp-file-then-rename like [`store`](Self::store); meta written last so a
|
||||||
|
/// crash mid-store fails validation. Enforces the block-cache LRU budget.
|
||||||
|
pub fn store_block(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
size: u64,
|
||||||
|
idx: u64,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let blob = self.block_blob(uid, idx);
|
||||||
|
let tmp = self
|
||||||
|
.block_dir
|
||||||
|
.join(format!("{}.b{idx}.tmp", Self::key(uid)));
|
||||||
|
std::fs::write(&tmp, bytes)?;
|
||||||
|
std::fs::rename(&tmp, &blob)?;
|
||||||
|
std::fs::write(
|
||||||
|
self.block_meta(uid, idx),
|
||||||
|
serde_json::to_vec(&Meta { mtime, size })?,
|
||||||
|
)?;
|
||||||
|
self.enforce_block_budget();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evict least-recently-used block-cache files until the block dir fits
|
||||||
|
/// `max_bytes`. No-op when the cap is disabled (`0`). All blocks are
|
||||||
|
/// evictable — pinned files are served from whole-file blobs, never blocks.
|
||||||
|
fn enforce_block_budget(&self) {
|
||||||
|
if self.max_bytes == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
let mut blobs: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
|
||||||
|
let Ok(rd) = std::fs::read_dir(&self.block_dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name.ends_with(".meta") || name.ends_with(".tmp") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(meta) = entry.metadata() else { continue };
|
||||||
|
if !meta.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += meta.len();
|
||||||
|
let atime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
blobs.push((path, meta.len(), atime));
|
||||||
|
}
|
||||||
|
if total <= self.max_bytes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blobs.sort_by_key(|(_, _, atime)| *atime);
|
||||||
|
for (path, len, _) in blobs {
|
||||||
|
if total <= self.max_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let meta = path.with_file_name(format!(
|
||||||
|
"{}.meta",
|
||||||
|
path.file_name().unwrap_or_default().to_string_lossy()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(meta);
|
||||||
|
total = total.saturating_sub(len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a fresh, empty read-write scratch file for a disk-backed write
|
||||||
|
/// handle. Returns the open file and its path (for cleanup on release).
|
||||||
|
pub fn create_scratch(&self) -> Result<(std::fs::File, PathBuf)> {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
static N: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let path = self.scratch_dir.join(format!(
|
||||||
|
"w-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&path)?;
|
||||||
|
Ok((file, path))
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove any cached blob + meta for `uid`, including its thumbnails (best
|
/// Remove any cached blob + meta for `uid`, including its thumbnails (best
|
||||||
/// effort; absence is fine). Thumbnails are tied to the revision, so a
|
/// effort; absence is fine). Thumbnails are tied to the revision, so a
|
||||||
/// content eviction must drop them too.
|
/// content eviction must drop them too.
|
||||||
@@ -190,6 +328,19 @@ impl ContentCache {
|
|||||||
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
|
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
|
||||||
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
|
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
|
||||||
}
|
}
|
||||||
|
// Drop every cached block (and its meta/tmp) for this uid.
|
||||||
|
let prefix = format!("{}.b", Self::key(uid));
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
|
||||||
|
for entry in rd.flatten() {
|
||||||
|
if entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|n| n.starts_with(&prefix))
|
||||||
|
{
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
|
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
|
||||||
@@ -559,4 +710,63 @@ mod tests {
|
|||||||
c.evict(&u);
|
c.evict(&u);
|
||||||
assert!(!c.is_cached(&u, 1, 3));
|
assert!(!c.is_cached(&u, 1, 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_store_then_read() {
|
||||||
|
let (c, _d) = cache();
|
||||||
|
let u = uid("a");
|
||||||
|
c.store_block(&u, 100, 4096, 0, b"block-zero").unwrap();
|
||||||
|
assert_eq!(c.cached_block(&u, 100, 4096, 0).unwrap(), b"block-zero");
|
||||||
|
// A different index is a separate cache entry, absent here.
|
||||||
|
assert!(c.cached_block(&u, 100, 4096, 1).is_none());
|
||||||
|
// A new revision (mtime/size bump) invalidates the block.
|
||||||
|
assert!(c.cached_block(&u, 101, 4096, 0).is_none());
|
||||||
|
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evict_drops_blocks() {
|
||||||
|
let (c, _d) = cache();
|
||||||
|
let u = uid("a");
|
||||||
|
c.store_block(&u, 1, 8, 0, b"aaaa").unwrap();
|
||||||
|
c.store_block(&u, 1, 8, 1, b"bbbb").unwrap();
|
||||||
|
c.evict(&u);
|
||||||
|
assert!(c.cached_block(&u, 1, 8, 0).is_none());
|
||||||
|
assert!(c.cached_block(&u, 1, 8, 1).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_budget_evicts_lru() {
|
||||||
|
// Cap fits two 4-byte blocks but not three.
|
||||||
|
let (c, _d) = cache_capped(8);
|
||||||
|
let u = uid("a");
|
||||||
|
c.store_block(&u, 1, 12, 0, b"aaaa").unwrap();
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
c.store_block(&u, 1, 12, 1, b"bbbb").unwrap();
|
||||||
|
// Touch block 0 so block 1 is the least-recently-used.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
assert!(c.cached_block(&u, 1, 12, 0).is_some());
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
c.store_block(&u, 1, 12, 2, b"cccc").unwrap();
|
||||||
|
assert!(
|
||||||
|
c.cached_block(&u, 1, 12, 0).is_some(),
|
||||||
|
"recently-read survives"
|
||||||
|
);
|
||||||
|
assert!(c.cached_block(&u, 1, 12, 1).is_none(), "LRU block evicted");
|
||||||
|
assert!(c.cached_block(&u, 1, 12, 2).is_some(), "newest survives");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scratch_file_is_writable_and_isolated() {
|
||||||
|
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||||
|
let (c, _d) = cache();
|
||||||
|
let (mut f1, p1) = c.create_scratch().unwrap();
|
||||||
|
let (_f2, p2) = c.create_scratch().unwrap();
|
||||||
|
assert_ne!(p1, p2, "each scratch file is unique");
|
||||||
|
f1.write_all(b"scratch").unwrap();
|
||||||
|
f1.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
let mut s = String::new();
|
||||||
|
f1.read_to_string(&mut s).unwrap();
|
||||||
|
assert_eq!(s, "scratch");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -57,9 +57,10 @@ impl AppDirs {
|
|||||||
let path = self.config_path();
|
let path = self.config_path();
|
||||||
if path.exists()
|
if path.exists()
|
||||||
&& let Ok(content) = std::fs::read_to_string(&path)
|
&& let Ok(content) = std::fs::read_to_string(&path)
|
||||||
&& let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
&& let Ok(config) = serde_json::from_str::<AppConfig>(&content)
|
||||||
return config;
|
{
|
||||||
}
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
|
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
|
||||||
|
|||||||
@@ -85,11 +85,19 @@ pub struct PhotoItem {
|
|||||||
/// The daemon's reply to a [`Request`].
|
/// The daemon's reply to a [`Request`].
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub enum Response {
|
pub enum Response {
|
||||||
/// Current mount status.
|
/// Current mount status. Carries the cache stats the daemon already holds
|
||||||
|
/// (`used`/`budget` bytes and the pin list) so a front-end never has to open
|
||||||
|
/// the on-disk cache itself on its UI thread.
|
||||||
Status {
|
Status {
|
||||||
username: String,
|
username: String,
|
||||||
mountpoint: String,
|
mountpoint: String,
|
||||||
pinned: usize,
|
pinned: usize,
|
||||||
|
/// Bytes of cached content blobs (see [`crate::cache::ContentCache::usage`]).
|
||||||
|
used: u64,
|
||||||
|
/// Configured soft byte cap (`0` = unlimited).
|
||||||
|
budget: u64,
|
||||||
|
/// The pin registry.
|
||||||
|
pins: Vec<Pin>,
|
||||||
},
|
},
|
||||||
/// A human-readable success message.
|
/// A human-readable success message.
|
||||||
Ok { message: String },
|
Ok { message: String },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod cache;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Thin wrappers over `systemctl --user` for the auto-mount daemon.
|
||||||
|
//!
|
||||||
|
//! The mount is owned by a systemd *user* service (`proton-drive.service`, see
|
||||||
|
//! `packaging/`). Front-ends never spawn `pdfs mount` directly anymore: logging
|
||||||
|
//! in enables+starts the unit, logging out disables+stops it, and the tray's
|
||||||
|
//! "Disconnect" just stops it. systemd then handles restart-on-failure and a
|
||||||
|
//! clean SIGTERM stop (which the daemon turns into a lazy unmount).
|
||||||
|
//!
|
||||||
|
//! Every call is best-effort: a failure is logged but not fatal, since the user
|
||||||
|
//! can always drive `systemctl --user` by hand.
|
||||||
|
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
/// The systemd user unit that runs `pdfs daemon`.
|
||||||
|
pub const SERVICE: &str = "proton-drive.service";
|
||||||
|
|
||||||
|
/// Run one `systemctl --user <args> proton-drive.service`, returning whether it
|
||||||
|
/// succeeded. Output is suppressed; failures are logged.
|
||||||
|
fn systemctl(args: &[&str]) -> bool {
|
||||||
|
let ok = Command::new("systemctl")
|
||||||
|
.arg("--user")
|
||||||
|
.args(args)
|
||||||
|
.arg(SERVICE)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !ok {
|
||||||
|
tracing::warn!(args = ?args, "systemctl --user {SERVICE} failed");
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`systemctl`] but treats a non-zero exit as a plain `false` answer
|
||||||
|
/// rather than a failure to log. For query verbs (`is-active`, `is-failed`)
|
||||||
|
/// where "no" is the normal, expected result.
|
||||||
|
fn systemctl_query(args: &[&str]) -> bool {
|
||||||
|
Command::new("systemctl")
|
||||||
|
.arg("--user")
|
||||||
|
.args(args)
|
||||||
|
.arg(SERVICE)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable the service so it survives reboots/logins and start it now. Called on
|
||||||
|
/// successful login.
|
||||||
|
pub fn enable_start() -> bool {
|
||||||
|
systemctl(&["enable", "--now"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the running mount without disabling it. Called by the tray's
|
||||||
|
/// "Disconnect": the next login (or reboot) brings it back.
|
||||||
|
pub fn stop() -> bool {
|
||||||
|
systemctl(&["stop"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the unit now. Used by a front-end "Retry" action when the mount has
|
||||||
|
/// failed and the user wants to bring it back without re-logging-in.
|
||||||
|
pub fn restart() -> bool {
|
||||||
|
systemctl(&["restart"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the unit currently reports `active` (running, or starting up).
|
||||||
|
/// `systemctl is-active` exits 0 only while the unit is active, so a front-end
|
||||||
|
/// can use this to tell "service still coming up" from "service is down".
|
||||||
|
pub fn is_active() -> bool {
|
||||||
|
systemctl_query(&["is-active"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the unit is in the `failed` state. `systemctl is-failed` exits 0
|
||||||
|
/// only when the unit has failed, letting a front-end show an actionable error
|
||||||
|
/// (with Retry) rather than an indefinite "connecting…" spinner.
|
||||||
|
pub fn is_failed() -> bool {
|
||||||
|
systemctl_query(&["is-failed"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable the service so it no longer auto-starts, and stop it now. Called on
|
||||||
|
/// logout.
|
||||||
|
pub fn disable_stop() -> bool {
|
||||||
|
systemctl(&["disable", "--now"])
|
||||||
|
}
|
||||||
+557
-114
@@ -23,10 +23,19 @@
|
|||||||
//! into the on-disk [`ContentCache`] and every later `read` is served from disk
|
//! into the on-disk [`ContentCache`] and every later `read` is served from disk
|
||||||
//! instead of the network. Writes and remote events evict the cache so it never
|
//! instead of the network. Writes and remote events evict the cache so it never
|
||||||
//! goes stale.
|
//! goes stale.
|
||||||
|
//!
|
||||||
|
//! Reads of unpinned files no longer hit the network per call: [`Core::read_range`]
|
||||||
|
//! fetches and caches [`BLOCK_SIZE`]-aligned blocks, so sequential/sparse reads
|
||||||
|
//! reuse the on-disk block cache. Writes are disk-backed: each [`WriteHandle`]
|
||||||
|
//! stages authored bytes in a scratch file and tracks them with an [`Intervals`]
|
||||||
|
//! set, so a multi-GiB write never buffers in RAM and only the untouched
|
||||||
|
//! remainder of the file is pulled from the remote — lazily, at commit.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader, Write as _};
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -39,7 +48,7 @@ use fuser::{
|
|||||||
ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen,
|
ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen,
|
||||||
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
ReplyWrite, Request, Session, TimeOrNow, WriteFlags,
|
||||||
};
|
};
|
||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
|
||||||
use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse};
|
use pdfs_core::control::{DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse};
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||||
use proton_drive_rs::{
|
use proton_drive_rs::{
|
||||||
@@ -75,18 +84,90 @@ struct Entry {
|
|||||||
node: Node,
|
node: Node,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buffered state for a file opened for writing. The full plaintext is
|
/// A set of non-overlapping `[start, end)` byte ranges, kept sorted and merged.
|
||||||
/// assembled here and uploaded as one new revision on flush/release, because
|
/// Tracks which bytes of a [`WriteHandle`]'s scratch file were authored locally
|
||||||
/// the SDK seals whole revisions rather than byte ranges.
|
/// (vs. still living only in the remote base), so reads and the commit gap-fill
|
||||||
|
/// know which regions to pull from the network.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Intervals(Vec<(u64, u64)>);
|
||||||
|
|
||||||
|
impl Intervals {
|
||||||
|
/// Mark `[start, end)` as authored, coalescing with any touching ranges.
|
||||||
|
fn add(&mut self, start: u64, end: u64) {
|
||||||
|
if start >= end {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.0.push((start, end));
|
||||||
|
self.0.sort_by_key(|&(s, _)| s);
|
||||||
|
let mut merged: Vec<(u64, u64)> = Vec::with_capacity(self.0.len());
|
||||||
|
for &(s, e) in &self.0 {
|
||||||
|
match merged.last_mut() {
|
||||||
|
Some(last) if s <= last.1 => last.1 = last.1.max(e),
|
||||||
|
_ => merged.push((s, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.0 = merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop everything at or beyond `len` (a shrink/truncate).
|
||||||
|
fn clip(&mut self, len: u64) {
|
||||||
|
self.0.retain(|&(s, _)| s < len);
|
||||||
|
for iv in &mut self.0 {
|
||||||
|
iv.1 = iv.1.min(len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split `[start, end)` into contiguous `(s, e, authored)` segments, in
|
||||||
|
/// order. `authored == true` means the bytes live in the scratch file;
|
||||||
|
/// `false` means they must come from the remote base (or are a hole).
|
||||||
|
fn segments(&self, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut pos = start;
|
||||||
|
for &(s, e) in &self.0 {
|
||||||
|
if e <= start {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if s >= end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let ws = s.max(start);
|
||||||
|
let we = e.min(end);
|
||||||
|
if pos < ws {
|
||||||
|
out.push((pos, ws, false));
|
||||||
|
}
|
||||||
|
out.push((ws, we, true));
|
||||||
|
pos = we;
|
||||||
|
}
|
||||||
|
if pos < end {
|
||||||
|
out.push((pos, end, false));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for a file opened for writing. Authored bytes are staged in an on-disk
|
||||||
|
/// scratch file (positional reads/writes) rather than RAM, so a multi-GiB write
|
||||||
|
/// never balloons the daemon. On flush/release the scratch file — gap-filled
|
||||||
|
/// from the remote base where untouched — is streamed up as one new revision,
|
||||||
|
/// since the SDK seals whole revisions rather than byte ranges.
|
||||||
struct WriteHandle {
|
struct WriteHandle {
|
||||||
ino: u64,
|
ino: u64,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
/// The plaintext being assembled; valid only once `seeded`.
|
/// Disk-backed staging buffer. Shared (`Arc`) so reads can use it without
|
||||||
buf: Vec<u8>,
|
/// holding the state lock across I/O. Accessed positionally (`read_at`/
|
||||||
/// Whether `buf` holds the file's current content (or was emptied by a
|
/// `write_at`), so a clone never disturbs another's file offset.
|
||||||
/// truncate). Until then a partial write must hydrate the base first.
|
file: Arc<File>,
|
||||||
seeded: bool,
|
/// Scratch file path, removed on release.
|
||||||
/// Whether `buf` diverged from the remote and needs an upload.
|
path: PathBuf,
|
||||||
|
/// Byte ranges authored into `file`. Everything else in `[0, len)` is base.
|
||||||
|
written: Intervals,
|
||||||
|
/// Logical file size (may exceed authored bytes after a truncate-extend).
|
||||||
|
len: u64,
|
||||||
|
/// Size of the remote base at open, for serving untouched ranges.
|
||||||
|
base_size: u64,
|
||||||
|
/// Modification time of the remote base at open, validating its block cache.
|
||||||
|
base_mtime: i64,
|
||||||
|
/// Whether anything diverged from the remote and needs an upload.
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,35 +370,183 @@ impl Core {
|
|||||||
nodes.into_iter().next().ok_or(Errno::ENOENT)
|
nodes.into_iter().next().ok_or(Errno::ENOENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a write handle's buffer as a new revision if it is dirty, clearing
|
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
|
||||||
/// the dirty flag and refreshing the entry's size/mtime on success. No-op for
|
/// the on-disk caches before the network: a whole-file blob (pinned files)
|
||||||
/// a clean (or unknown) handle. Network I/O happens without the lock held.
|
/// first, then the block cache — fetching only the [`BLOCK_SIZE`]-aligned
|
||||||
|
/// blocks that overlap the request and caching each. `mtime`/`fsize` validate
|
||||||
|
/// both caches. Network I/O runs without any lock held.
|
||||||
|
fn read_range(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mtime: i64,
|
||||||
|
fsize: u64,
|
||||||
|
offset: u64,
|
||||||
|
len: u64,
|
||||||
|
) -> Result<Vec<u8>, Errno> {
|
||||||
|
if let Some(bytes) = self.cache.read_range(uid, mtime, fsize, offset, len) {
|
||||||
|
return Ok(bytes);
|
||||||
|
}
|
||||||
|
if offset >= fsize || len == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let end = offset.saturating_add(len).min(fsize);
|
||||||
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
|
let first = offset / BLOCK_SIZE;
|
||||||
|
let last = (end - 1) / BLOCK_SIZE;
|
||||||
|
for bidx in first..=last {
|
||||||
|
let bstart = bidx * BLOCK_SIZE;
|
||||||
|
let block = match self.cache.cached_block(uid, mtime, fsize, bidx) {
|
||||||
|
Some(b) => 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let s = (offset.max(bstart) - bstart) as usize;
|
||||||
|
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||||
|
if s < e {
|
||||||
|
out.extend_from_slice(&block[s..e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve a read against an open write handle: stitch authored ranges (from
|
||||||
|
/// the scratch file) and untouched ranges (from the remote base via the
|
||||||
|
/// block cache) in order, zero-filling any region past the base.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn serve_open_read(
|
||||||
|
&self,
|
||||||
|
file: &Arc<File>,
|
||||||
|
len: u64,
|
||||||
|
uid: &NodeUid,
|
||||||
|
base_mtime: i64,
|
||||||
|
base_size: u64,
|
||||||
|
written: &Intervals,
|
||||||
|
offset: u64,
|
||||||
|
size: u64,
|
||||||
|
) -> Result<Vec<u8>, Errno> {
|
||||||
|
if offset >= len || size == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let end = offset.saturating_add(size).min(len);
|
||||||
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
|
for (s, e, authored) in written.segments(offset, end) {
|
||||||
|
if authored {
|
||||||
|
let mut buf = vec![0u8; (e - s) as usize];
|
||||||
|
file.read_exact_at(&mut buf, s).map_err(|err| {
|
||||||
|
warn!(%uid, error = %err, "scratch read failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
out.extend_from_slice(&buf);
|
||||||
|
} else {
|
||||||
|
let bend = e.min(base_size);
|
||||||
|
if s < bend {
|
||||||
|
out.extend_from_slice(&self.read_range(
|
||||||
|
uid,
|
||||||
|
base_mtime,
|
||||||
|
base_size,
|
||||||
|
s,
|
||||||
|
bend - s,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
// Anything past the base is a hole: zero-fill.
|
||||||
|
let zeros = e.saturating_sub(s.max(base_size));
|
||||||
|
out.resize(out.len() + zeros as usize, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a write handle's scratch file as a new revision if it is dirty.
|
||||||
|
/// Untouched bytes within the base are filled from the remote first (reusing
|
||||||
|
/// the block cache), so a partial overwrite never had to pre-download the
|
||||||
|
/// whole file. On success the handle's base is advanced to the just-sealed
|
||||||
|
/// revision and `written` cleared, so later reads of untouched regions see
|
||||||
|
/// the new content. No-op for a clean (or unknown) handle. Network I/O runs
|
||||||
|
/// without the lock held.
|
||||||
fn commit(&self, fh: u64) -> Result<(), Errno> {
|
fn commit(&self, fh: u64) -> Result<(), Errno> {
|
||||||
let (uid, buf, ino) = {
|
let (uid, file, path, len, base_mtime, base_size, written, ino) = {
|
||||||
let st = self.state.lock().unwrap();
|
let st = self.state.lock().unwrap();
|
||||||
match st.handles.get(&fh) {
|
match st.handles.get(&fh) {
|
||||||
Some(h) if h.dirty => (h.uid.clone(), h.buf.clone(), h.ino),
|
Some(h) if h.dirty => (
|
||||||
|
h.uid.clone(),
|
||||||
|
h.file.clone(),
|
||||||
|
h.path.clone(),
|
||||||
|
h.len,
|
||||||
|
h.base_mtime,
|
||||||
|
h.base_size,
|
||||||
|
h.written.clone(),
|
||||||
|
h.ino,
|
||||||
|
),
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Materialize the full new content in the scratch file: ensure its length,
|
||||||
|
// then fill every untouched range that overlaps the base with base bytes.
|
||||||
|
file.set_len(len).map_err(|e| {
|
||||||
|
error!(%uid, error = %e, "resize scratch file failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
for (s, e, authored) in written.segments(0, len) {
|
||||||
|
if authored {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bend = e.min(base_size);
|
||||||
|
if s >= bend {
|
||||||
|
continue; // wholly past the base: already zero-filled on disk
|
||||||
|
}
|
||||||
|
let bytes = self.read_range(&uid, base_mtime, base_size, s, bend - s)?;
|
||||||
|
file.write_all_at(&bytes, s).map_err(|err| {
|
||||||
|
error!(%uid, error = %err, "scratch gap-fill write failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
// Stream the scratch file up as one revision (fresh handle reads from 0).
|
||||||
|
let reader = File::open(&path).map_err(|e| {
|
||||||
|
error!(%uid, error = %e, "reopen scratch file failed");
|
||||||
|
Errno::EIO
|
||||||
|
})?;
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.upload_new_revision(&uid, &buf))
|
.block_on(self.client.upload_new_revision_from(
|
||||||
|
&uid,
|
||||||
|
reader,
|
||||||
|
len as i64,
|
||||||
|
Vec::new(),
|
||||||
|
None,
|
||||||
|
))
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!(%uid, error = %e, "upload new revision failed");
|
error!(%uid, error = %e, "upload new revision failed");
|
||||||
Errno::EIO
|
Errno::EIO
|
||||||
})?;
|
})?;
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let mut st = self.state.lock().unwrap();
|
{
|
||||||
if let Some(h) = st.handles.get_mut(&fh) {
|
let mut st = self.state.lock().unwrap();
|
||||||
h.dirty = false;
|
if let Some(h) = st.handles.get_mut(&fh) {
|
||||||
|
h.dirty = false;
|
||||||
|
// The scratch file now equals the sealed revision; treat it all as
|
||||||
|
// base so further reads of untouched bytes hit the new content.
|
||||||
|
h.written = Intervals::default();
|
||||||
|
h.base_mtime = now;
|
||||||
|
h.base_size = len;
|
||||||
|
}
|
||||||
|
st.set_size(ino, len);
|
||||||
|
st.touch_mtime(ino, now);
|
||||||
}
|
}
|
||||||
st.set_size(ino, buf.len() as u64);
|
// The sealed content differs from any cached blob/blocks; refresh a pinned
|
||||||
st.touch_mtime(ino, now);
|
// file's whole-file blob, otherwise evict so reads re-fetch fresh.
|
||||||
drop(st);
|
|
||||||
// The sealed content differs from any cached blob; if the file is
|
|
||||||
// pinned, refresh the cache so reads stay served from disk.
|
|
||||||
if self.cache.is_pinned(&uid) {
|
if self.cache.is_pinned(&uid) {
|
||||||
let _ = self.cache.store(&uid, now, buf.len() as u64, &buf);
|
if let Ok(bytes) = std::fs::read(&path) {
|
||||||
|
let _ = self.cache.store(&uid, now, len, &bytes);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.cache.evict(&uid);
|
self.cache.evict(&uid);
|
||||||
}
|
}
|
||||||
@@ -821,10 +1050,12 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||||
let uid = {
|
let (uid, base_mtime, base_size) = {
|
||||||
let st = self.core.state.lock().unwrap();
|
let st = self.core.state.lock().unwrap();
|
||||||
match st.entries.get(&ino.0) {
|
match st.entries.get(&ino.0) {
|
||||||
Some(e) if e.node.is_file() => e.uid.clone(),
|
Some(e) if e.node.is_file() => {
|
||||||
|
(e.uid.clone(), e.node.modification_time, node_size(&e.node))
|
||||||
|
}
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
reply.error(Errno::EISDIR);
|
reply.error(Errno::EISDIR);
|
||||||
return;
|
return;
|
||||||
@@ -835,12 +1066,21 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Read-only opens stay stateless (fh 0). A write open allocates a buffer
|
// Read-only opens stay stateless (fh 0). A write open allocates a
|
||||||
// handle, hydrated lazily on the first write that needs the base content.
|
// disk-backed handle; bytes are authored into a scratch file and the
|
||||||
|
// untouched remainder is pulled from the base lazily (on read / commit).
|
||||||
if flags.acc_mode() == OpenAccMode::O_RDONLY {
|
if flags.acc_mode() == OpenAccMode::O_RDONLY {
|
||||||
reply.opened(FileHandle(0), FopenFlags::empty());
|
reply.opened(FileHandle(0), FopenFlags::empty());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let (file, path) = match self.core.cache.create_scratch() {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => {
|
||||||
|
error!(%uid, error = %e, "create scratch file failed");
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut st = self.core.state.lock().unwrap();
|
let mut st = self.core.state.lock().unwrap();
|
||||||
let fh = st.next_fh;
|
let fh = st.next_fh;
|
||||||
st.next_fh += 1;
|
st.next_fh += 1;
|
||||||
@@ -849,8 +1089,14 @@ impl Filesystem for ProtonFs {
|
|||||||
WriteHandle {
|
WriteHandle {
|
||||||
ino: ino.0,
|
ino: ino.0,
|
||||||
uid,
|
uid,
|
||||||
buf: Vec::new(),
|
file: Arc::new(file),
|
||||||
seeded: false,
|
path,
|
||||||
|
written: Intervals::default(),
|
||||||
|
// Starts at the current size; reads in [0, base_size) come from
|
||||||
|
// the base until overwritten.
|
||||||
|
len: base_size,
|
||||||
|
base_size,
|
||||||
|
base_mtime,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -868,16 +1114,40 @@ impl Filesystem for ProtonFs {
|
|||||||
_lock_owner: Option<LockOwner>,
|
_lock_owner: Option<LockOwner>,
|
||||||
reply: ReplyData,
|
reply: ReplyData,
|
||||||
) {
|
) {
|
||||||
|
// A file open for writing is served from its handle so reads see the
|
||||||
|
// in-flight (possibly unsaved) content: authored bytes come from the
|
||||||
|
// scratch file, untouched bytes from the base.
|
||||||
|
let handle = {
|
||||||
|
let st = self.core.state.lock().unwrap();
|
||||||
|
st.handles.values().find(|h| h.ino == ino.0).map(|h| {
|
||||||
|
(
|
||||||
|
h.file.clone(),
|
||||||
|
h.len,
|
||||||
|
h.uid.clone(),
|
||||||
|
h.base_mtime,
|
||||||
|
h.base_size,
|
||||||
|
h.written.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
|
||||||
|
match self.core.serve_open_read(
|
||||||
|
&file,
|
||||||
|
len,
|
||||||
|
&uid,
|
||||||
|
base_mtime,
|
||||||
|
base_size,
|
||||||
|
&written,
|
||||||
|
offset,
|
||||||
|
size as u64,
|
||||||
|
) {
|
||||||
|
Ok(bytes) => reply.data(&bytes),
|
||||||
|
Err(e) => reply.error(e),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let (uid, mtime, fsize) = {
|
let (uid, mtime, fsize) = {
|
||||||
let st = self.core.state.lock().unwrap();
|
let st = self.core.state.lock().unwrap();
|
||||||
// If the file is open for writing and its buffer is populated, serve
|
|
||||||
// from there so reads see the in-flight (possibly unsaved) content.
|
|
||||||
if let Some(h) = st.handles.values().find(|h| h.ino == ino.0 && h.seeded) {
|
|
||||||
let off = (offset as usize).min(h.buf.len());
|
|
||||||
let end = (off + size as usize).min(h.buf.len());
|
|
||||||
reply.data(&h.buf[off..end]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match st.entries.get(&ino.0) {
|
match st.entries.get(&ino.0) {
|
||||||
Some(e) if e.node.is_file() => {
|
Some(e) if e.node.is_file() => {
|
||||||
(e.uid.clone(), e.node.modification_time, node_size(&e.node))
|
(e.uid.clone(), e.node.modification_time, node_size(&e.node))
|
||||||
@@ -892,25 +1162,12 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Pinned (or otherwise cached) files are served straight from disk.
|
|
||||||
if let Some(bytes) = self
|
|
||||||
.core
|
|
||||||
.cache
|
|
||||||
.read_range(&uid, mtime, fsize, offset, size as u64)
|
|
||||||
{
|
|
||||||
reply.data(&bytes);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match self
|
match self
|
||||||
.core
|
.core
|
||||||
.rt
|
.read_range(&uid, mtime, fsize, offset, size as u64)
|
||||||
.block_on(self.core.client.download_range(&uid, offset, size as u64))
|
|
||||||
{
|
{
|
||||||
Ok(bytes) => reply.data(&bytes),
|
Ok(bytes) => reply.data(&bytes),
|
||||||
Err(e) => {
|
Err(e) => reply.error(e),
|
||||||
warn!(%uid, offset, size, error = %e, "download_range failed");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,6 +1219,14 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let (file, path) = match self.core.cache.create_scratch() {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => {
|
||||||
|
error!(%new_uid, error = %e, "create scratch file failed");
|
||||||
|
reply.error(Errno::EIO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut st = self.core.state.lock().unwrap();
|
let mut st = self.core.state.lock().unwrap();
|
||||||
let ino = st.intern(parent, node);
|
let ino = st.intern(parent, node);
|
||||||
if let Some(kids) = st.children.get_mut(&parent)
|
if let Some(kids) = st.children.get_mut(&parent)
|
||||||
@@ -973,11 +1238,16 @@ impl Filesystem for ProtonFs {
|
|||||||
st.next_fh += 1;
|
st.next_fh += 1;
|
||||||
st.handles.insert(
|
st.handles.insert(
|
||||||
fh,
|
fh,
|
||||||
|
// A brand-new file: empty base, everything written is authored.
|
||||||
WriteHandle {
|
WriteHandle {
|
||||||
ino,
|
ino,
|
||||||
uid: new_uid,
|
uid: new_uid,
|
||||||
buf: Vec::new(),
|
file: Arc::new(file),
|
||||||
seeded: true,
|
path,
|
||||||
|
written: Intervals::default(),
|
||||||
|
len: 0,
|
||||||
|
base_size: 0,
|
||||||
|
base_mtime: now_secs(),
|
||||||
dirty: false,
|
dirty: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1004,60 +1274,34 @@ impl Filesystem for ProtonFs {
|
|||||||
reply: ReplyWrite,
|
reply: ReplyWrite,
|
||||||
) {
|
) {
|
||||||
let fh = fh.0;
|
let fh = fh.0;
|
||||||
// Partial writes need the file's current content; hydrate it once.
|
// Stage the bytes straight into the scratch file (no base download): only
|
||||||
let need_seed = {
|
// the untouched remainder is pulled from the remote, and only at commit.
|
||||||
|
let file = {
|
||||||
let st = self.core.state.lock().unwrap();
|
let st = self.core.state.lock().unwrap();
|
||||||
match st.handles.get(&fh) {
|
match st.handles.get(&fh) {
|
||||||
Some(h) => !h.seeded,
|
Some(h) => h.file.clone(),
|
||||||
None => {
|
None => {
|
||||||
reply.error(Errno::EBADF);
|
reply.error(Errno::EBADF);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if need_seed {
|
if let Err(e) = file.write_all_at(data, offset) {
|
||||||
let uid = self
|
error!(ino = ino.0, fh, error = %e, "scratch write failed");
|
||||||
.core
|
reply.error(Errno::EIO);
|
||||||
.state
|
return;
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.handles
|
|
||||||
.get(&fh)
|
|
||||||
.map(|h| h.uid.clone());
|
|
||||||
let Some(uid) = uid else {
|
|
||||||
reply.error(Errno::EBADF);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let base = match self.core.rt.block_on(self.core.client.download_file(&uid)) {
|
|
||||||
Ok(b) => b,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(%uid, error = %e, "seed write buffer failed");
|
|
||||||
reply.error(Errno::EIO);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut st = self.core.state.lock().unwrap();
|
|
||||||
if let Some(h) = st.handles.get_mut(&fh)
|
|
||||||
&& !h.seeded
|
|
||||||
{
|
|
||||||
h.buf = base;
|
|
||||||
h.seeded = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let off = offset as usize;
|
|
||||||
let new_len = {
|
let new_len = {
|
||||||
let mut st = self.core.state.lock().unwrap();
|
let mut st = self.core.state.lock().unwrap();
|
||||||
let Some(h) = st.handles.get_mut(&fh) else {
|
let Some(h) = st.handles.get_mut(&fh) else {
|
||||||
reply.error(Errno::EBADF);
|
reply.error(Errno::EBADF);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let end = off + data.len();
|
let end = offset + data.len() as u64;
|
||||||
if h.buf.len() < end {
|
h.written.add(offset, end);
|
||||||
h.buf.resize(end, 0);
|
h.len = h.len.max(end);
|
||||||
}
|
|
||||||
h.buf[off..end].copy_from_slice(data);
|
|
||||||
h.dirty = true;
|
h.dirty = true;
|
||||||
let len = h.buf.len() as u64;
|
let len = h.len;
|
||||||
st.set_size(ino.0, len);
|
st.set_size(ino.0, len);
|
||||||
len
|
len
|
||||||
};
|
};
|
||||||
@@ -1067,7 +1311,7 @@ impl Filesystem for ProtonFs {
|
|||||||
offset,
|
offset,
|
||||||
len = data.len(),
|
len = data.len(),
|
||||||
new_len,
|
new_len,
|
||||||
"buffered write"
|
"staged write"
|
||||||
);
|
);
|
||||||
reply.written(data.len() as u32);
|
reply.written(data.len() as u32);
|
||||||
}
|
}
|
||||||
@@ -1100,8 +1344,16 @@ impl Filesystem for ProtonFs {
|
|||||||
let mut st = self.core.state.lock().unwrap();
|
let mut st = self.core.state.lock().unwrap();
|
||||||
match st.handles.get_mut(&fh) {
|
match st.handles.get_mut(&fh) {
|
||||||
Some(h) => {
|
Some(h) => {
|
||||||
h.buf.resize(size as usize, 0);
|
if size < h.len {
|
||||||
h.seeded = true;
|
// Shrink: drop authored ranges past the new end.
|
||||||
|
h.written.clip(size);
|
||||||
|
} else if size > h.len {
|
||||||
|
// Grow: the new tail is defined as zeros, so claim
|
||||||
|
// it as authored rather than base content.
|
||||||
|
h.written.add(h.len, size);
|
||||||
|
}
|
||||||
|
let _ = h.file.set_len(size);
|
||||||
|
h.len = size;
|
||||||
h.dirty = true;
|
h.dirty = true;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -1207,7 +1459,10 @@ impl Filesystem for ProtonFs {
|
|||||||
reply: ReplyEmpty,
|
reply: ReplyEmpty,
|
||||||
) {
|
) {
|
||||||
let res = self.core.commit(fh.0);
|
let res = self.core.commit(fh.0);
|
||||||
self.core.state.lock().unwrap().handles.remove(&fh.0);
|
let handle = self.core.state.lock().unwrap().handles.remove(&fh.0);
|
||||||
|
if let Some(h) = handle {
|
||||||
|
let _ = std::fs::remove_file(&h.path);
|
||||||
|
}
|
||||||
match res {
|
match res {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
@@ -1571,11 +1826,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
||||||
Ok(CtlRequest::Status) => CtlResponse::Status {
|
Ok(CtlRequest::Status) => {
|
||||||
username: username.to_string(),
|
let pins = core.cache.list_pins();
|
||||||
mountpoint: mountpoint.display().to_string(),
|
CtlResponse::Status {
|
||||||
pinned: core.cache.list_pins().len(),
|
username: username.to_string(),
|
||||||
},
|
mountpoint: mountpoint.display().to_string(),
|
||||||
|
pinned: pins.len(),
|
||||||
|
used: core.cache.usage(),
|
||||||
|
budget: core.cache.budget(),
|
||||||
|
pins,
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
Ok(name) => CtlResponse::Ok {
|
Ok(name) => CtlResponse::Ok {
|
||||||
@@ -1667,12 +1928,53 @@ fn run_control_socket(core: Core, username: String, mountpoint: PathBuf, listene
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mount the filesystem at `mountpoint` and block until it is unmounted.
|
/// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean
|
||||||
|
/// shutdown) or remount (the mount went away under it).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MountOutcome {
|
||||||
|
/// The daemon was asked to stop (SIGTERM/SIGINT) and we lazily unmounted
|
||||||
|
/// ourselves. The caller should exit cleanly.
|
||||||
|
Shutdown,
|
||||||
|
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||||
|
/// The caller may want to remount.
|
||||||
|
Unmounted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort teardown of a stale mount left behind by a crashed daemon. A
|
||||||
|
/// previous run that died without unmounting leaves the kernel mount in place,
|
||||||
|
/// so the fresh `Session::new` below would fail with EBUSY ("Device or resource
|
||||||
|
/// busy"). `fusermount3 -u -z` is the lazy (detach) unmount, which succeeds even
|
||||||
|
/// when the old mount is still busy. Swallow all output/errors: if there is no
|
||||||
|
/// stale mount this is simply a no-op.
|
||||||
|
fn clear_stale_mount(mountpoint: &Path) {
|
||||||
|
for bin in ["fusermount3", "fusermount"] {
|
||||||
|
let ok = std::process::Command::new(bin)
|
||||||
|
.arg("-u")
|
||||||
|
.arg("-z")
|
||||||
|
.arg(mountpoint)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
info!(mountpoint = %mountpoint.display(), "cleared stale mount before remount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
|
||||||
|
/// daemon is asked to stop.
|
||||||
///
|
///
|
||||||
/// Fetches the My Files root up front (so an auth/network failure surfaces
|
/// Fetches the My Files root up front (so an auth/network failure surfaces
|
||||||
/// before the kernel mount), then spawns the Phase 2 event-sync task, the
|
/// before the kernel mount), then spawns the Phase 2 event-sync task, the
|
||||||
/// Phase 4 control socket, and takes over the calling thread in the FUSE session
|
/// Phase 4 control socket, and runs the FUSE session on its own thread while
|
||||||
/// loop. `rt` must be a handle to a *running* multi-threaded runtime.
|
/// this thread waits for either a stop signal (SIGTERM/SIGINT) or the kernel
|
||||||
|
/// mount ending on its own. On a stop signal we lazily unmount ourselves
|
||||||
|
/// (`umount_and_join`, the MNT_DETACH path that succeeds even while downloads
|
||||||
|
/// are in flight), so `systemctl --user stop` is always a clean teardown.
|
||||||
|
/// `rt` must be a handle to a *running* multi-threaded runtime.
|
||||||
pub fn mount(
|
pub fn mount(
|
||||||
client: ProtonDriveClient,
|
client: ProtonDriveClient,
|
||||||
rt: tokio::runtime::Handle,
|
rt: tokio::runtime::Handle,
|
||||||
@@ -1680,7 +1982,7 @@ pub fn mount(
|
|||||||
cache: ContentCache,
|
cache: ContentCache,
|
||||||
control_socket: &Path,
|
control_socket: &Path,
|
||||||
username: String,
|
username: String,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<MountOutcome> {
|
||||||
let root = rt
|
let root = rt
|
||||||
.block_on(client.get_my_files_folder())
|
.block_on(client.get_my_files_folder())
|
||||||
.map_err(|e| std::io::Error::other(format!("fetch My Files root: {e}")))?;
|
.map_err(|e| std::io::Error::other(format!("fetch My Files root: {e}")))?;
|
||||||
@@ -1707,6 +2009,10 @@ pub fn mount(
|
|||||||
MountOption::Subtype("protondrive".to_string()),
|
MountOption::Subtype("protondrive".to_string()),
|
||||||
MountOption::DefaultPermissions,
|
MountOption::DefaultPermissions,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// A crashed previous run can leave the kernel mount in place, which makes
|
||||||
|
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
|
||||||
|
clear_stale_mount(mountpoint);
|
||||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
|
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
|
||||||
|
|
||||||
// Bind the control socket before the FUSE session takes over the thread. A
|
// Bind the control socket before the FUSE session takes over the thread. A
|
||||||
@@ -1725,16 +2031,153 @@ pub fn mount(
|
|||||||
let fs = ProtonFs::new(core.clone(), root);
|
let fs = ProtonFs::new(core.clone(), root);
|
||||||
|
|
||||||
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
|
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
|
||||||
// for the event task. `spawn` runs the session loop on its own thread;
|
// for the event task. `spawn` runs the session loop on its own background
|
||||||
// `join` then blocks here until the filesystem is unmounted, preserving
|
// thread; we then wait here for either a stop signal or the mount ending.
|
||||||
// `mount`'s blocking contract.
|
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
let notifier = bg.notifier();
|
||||||
let notifier = session.notifier();
|
|
||||||
rt.spawn(run_event_sync(
|
rt.spawn(run_event_sync(
|
||||||
client, scope, core.state, core.cache, notifier,
|
client, scope, core.state, core.cache, notifier,
|
||||||
));
|
));
|
||||||
|
|
||||||
let result = session.join();
|
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
||||||
|
// are delivered onto the async runtime; bridge them onto a sync channel so
|
||||||
|
// the loop below can react without blocking a worker thread. A bounded
|
||||||
|
// channel of 1 is enough — we only need to know that *a* stop arrived.
|
||||||
|
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
|
||||||
|
rt.spawn(async move {
|
||||||
|
let mut sigterm =
|
||||||
|
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "install SIGTERM handler failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut sigint =
|
||||||
|
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "install SIGINT handler failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
_ = sigterm.recv() => info!("received SIGTERM"),
|
||||||
|
_ = sigint.recv() => info!("received SIGINT"),
|
||||||
|
}
|
||||||
|
let _ = sig_tx.try_send(());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
|
||||||
|
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
|
||||||
|
// mount ending on its own (→ the session thread finishes). Poll instead of
|
||||||
|
// blocking on `join` so we can also notice the signal.
|
||||||
|
let outcome = loop {
|
||||||
|
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
||||||
|
Ok(()) => {
|
||||||
|
info!("stop requested; unmounting");
|
||||||
|
if let Err(e) = bg.umount_and_join() {
|
||||||
|
warn!(error = %e, "umount_and_join failed");
|
||||||
|
}
|
||||||
|
break MountOutcome::Shutdown;
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
|
if bg.guard.is_finished() {
|
||||||
|
info!("mount ended externally");
|
||||||
|
if let Err(e) = bg.join() {
|
||||||
|
warn!(error = %e, "session join failed");
|
||||||
|
}
|
||||||
|
break MountOutcome::Unmounted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
|
// Signal task gone (failed to install); fall back to join.
|
||||||
|
let _ = bg.join();
|
||||||
|
break MountOutcome::Unmounted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _ = std::fs::remove_file(control_socket);
|
let _ = std::fs::remove_file(control_socket);
|
||||||
result
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Intervals;
|
||||||
|
|
||||||
|
/// Flatten `segments` into a readable form for assertions.
|
||||||
|
fn segs(iv: &Intervals, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
|
||||||
|
iv.segments(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_merges_into_one_run() {
|
||||||
|
// Sequential writes (an append) coalesce into a single authored range.
|
||||||
|
let mut iv = Intervals::default();
|
||||||
|
iv.add(0, 10);
|
||||||
|
iv.add(10, 20);
|
||||||
|
iv.add(20, 25);
|
||||||
|
assert_eq!(segs(&iv, 0, 25), vec![(0, 25, true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_overwrite_leaves_base_gap() {
|
||||||
|
// Author [0,4) and [8,12); [4,8) stays base. A read of [0,12) must stitch
|
||||||
|
// authored / base / authored in order.
|
||||||
|
let mut iv = Intervals::default();
|
||||||
|
iv.add(0, 4);
|
||||||
|
iv.add(8, 12);
|
||||||
|
assert_eq!(
|
||||||
|
segs(&iv, 0, 12),
|
||||||
|
vec![(0, 4, true), (4, 8, false), (8, 12, true)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlapping_writes_coalesce() {
|
||||||
|
let mut iv = Intervals::default();
|
||||||
|
iv.add(0, 10);
|
||||||
|
iv.add(5, 15);
|
||||||
|
iv.add(14, 20);
|
||||||
|
assert_eq!(segs(&iv, 0, 20), vec![(0, 20, true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segments_clamp_to_request_window() {
|
||||||
|
let mut iv = Intervals::default();
|
||||||
|
iv.add(0, 100);
|
||||||
|
// A sub-window of one big authored range is a single authored segment.
|
||||||
|
assert_eq!(segs(&iv, 20, 50), vec![(20, 50, true)]);
|
||||||
|
// A window entirely outside any authored range is all base.
|
||||||
|
let empty = Intervals::default();
|
||||||
|
assert_eq!(segs(&empty, 0, 8), vec![(0, 8, false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_shrink_drops_tail() {
|
||||||
|
// Grow-then-shrink: clip removes/truncates authored ranges past the end.
|
||||||
|
let mut iv = Intervals::default();
|
||||||
|
iv.add(0, 100);
|
||||||
|
iv.clip(40);
|
||||||
|
assert_eq!(segs(&iv, 0, 40), vec![(0, 40, true)]);
|
||||||
|
// Authored ranges wholly past the new end disappear.
|
||||||
|
let mut iv2 = Intervals::default();
|
||||||
|
iv2.add(0, 10);
|
||||||
|
iv2.add(50, 60);
|
||||||
|
iv2.clip(40);
|
||||||
|
assert_eq!(segs(&iv2, 0, 40), vec![(0, 10, true), (10, 40, false)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_extend_authors_zero_tail() {
|
||||||
|
// setattr grow claims the new tail as authored (defined zeros), so commit
|
||||||
|
// never pulls it from the base.
|
||||||
|
let mut iv = Intervals::default();
|
||||||
|
iv.add(0, 10); // base content authored over
|
||||||
|
let old_len = 10u64;
|
||||||
|
let new_len = 30u64;
|
||||||
|
iv.add(old_len, new_len);
|
||||||
|
assert_eq!(segs(&iv, 0, 30), vec![(0, 30, true)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+429
-174
@@ -5,18 +5,21 @@
|
|||||||
//! [`pdfs-tray`](crate) SNI icon. It presents four things in a Proton-purple,
|
//! [`pdfs-tray`](crate) SNI icon. It presents four things in a Proton-purple,
|
||||||
//! Google-Drive-style layout:
|
//! Google-Drive-style layout:
|
||||||
//!
|
//!
|
||||||
//! * a **login form** (email / password / optional 2FA) driving
|
//! * a **login form** (email / password, with a lazy 2FA dialog shown only when
|
||||||
//! [`pdfs_core::auth::login`];
|
//! the account requires it) driving [`pdfs_core::auth::login`]; logging in
|
||||||
//! * a **mount toggle** that spawns `pdfs mount` / `fusermount3 -u`;
|
//! enables the systemd mount service;
|
||||||
//! * a **cache usage** read-out from the on-disk [`ContentCache`]; and
|
//! * a **read-only mount status** line (the mount is managed automatically);
|
||||||
|
//! * a **cache usage** read-out; and
|
||||||
//! * the **pin list** with per-file unpin.
|
//! * the **pin list** with per-file unpin.
|
||||||
//!
|
//!
|
||||||
//! State the GUI only displays (cache size, pins, login identity) is read
|
//! Mount status, cache usage and the pin list all ride along on one
|
||||||
//! straight off disk via `pdfs-core`, so the window is useful even with no mount
|
//! [`Request::Status`] round-trip to the daemon (which owns the cache), fetched on
|
||||||
//! daemon running; live mount status comes from the daemon over the control
|
//! a worker thread so the periodic refresh never blocks the GTK main loop. Login
|
||||||
//! socket the CLI and tray already speak.
|
//! identity is cached in [`Ui`] and refreshed only on login/logout, so the 2s tick
|
||||||
|
//! never touches the keyring.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -25,12 +28,12 @@ use std::time::Duration;
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk4::gio;
|
use gtk4::gio;
|
||||||
use gtk4::glib;
|
use gtk4::glib;
|
||||||
use gtk4::glib::{BoxedAnyObject, SignalHandlerId};
|
use gtk4::glib::BoxedAnyObject;
|
||||||
|
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::config::{AppDirs, DEFAULT_CACHE_BUDGET_BYTES};
|
|
||||||
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send};
|
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send};
|
||||||
|
use pdfs_core::service;
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
const PHOTOS_PAGE: usize = 60;
|
const PHOTOS_PAGE: usize = 60;
|
||||||
@@ -41,6 +44,17 @@ const APP_ID: &str = "io.narl.proton-drive-linux";
|
|||||||
const PROTON_PURPLE: &str = "#6d4aff";
|
const PROTON_PURPLE: &str = "#6d4aff";
|
||||||
/// How often the window re-reads mount status, cache usage and the pin list.
|
/// How often the window re-reads mount status, cache usage and the pin list.
|
||||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
/// Backoff between auto-retries of a Files/Photos load while the mount service
|
||||||
|
/// is still coming up (see [`load_browser`] / [`load_gallery`]).
|
||||||
|
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
|
||||||
|
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
|
||||||
|
struct PinRow {
|
||||||
|
row: adw::ActionRow,
|
||||||
|
/// The unpin button, absent on the placeholder row.
|
||||||
|
unpin: Option<gtk4::Button>,
|
||||||
|
}
|
||||||
|
|
||||||
/// All widgets the periodic refresh and the action handlers mutate, plus the
|
/// All widgets the periodic refresh and the action handlers mutate, plus the
|
||||||
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
|
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
|
||||||
@@ -48,22 +62,39 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
|||||||
struct Ui {
|
struct Ui {
|
||||||
dirs: AppDirs,
|
dirs: AppDirs,
|
||||||
stack: adw::ViewStack,
|
stack: adw::ViewStack,
|
||||||
|
/// Header spinner shown while any open/load round-trip is in flight; ref-
|
||||||
|
/// counted via [`Self::busy`] so concurrent operations don't stop it early.
|
||||||
|
spinner: gtk4::Spinner,
|
||||||
|
busy: Cell<u32>,
|
||||||
|
/// Keys (relative path / photo uid) of open requests currently in flight, so
|
||||||
|
/// a double-click on the same entry is a no-op instead of a second download.
|
||||||
|
opening: RefCell<HashSet<String>>,
|
||||||
|
/// Resolved login identity, cached so the periodic [`refresh`] never hits the
|
||||||
|
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
||||||
|
/// login / logout. `None` = signed out.
|
||||||
|
session: RefCell<Option<auth::StoredSession>>,
|
||||||
|
/// Whether a [`Request::Status`] round-trip is already in flight, so the 2s
|
||||||
|
/// refresh tick doesn't pile worker threads up on a slow/wedged daemon.
|
||||||
|
status_inflight: Cell<bool>,
|
||||||
// Login page.
|
// Login page.
|
||||||
email: adw::EntryRow,
|
email: adw::EntryRow,
|
||||||
password: adw::PasswordEntryRow,
|
password: adw::PasswordEntryRow,
|
||||||
totp: adw::EntryRow,
|
|
||||||
login_button: gtk4::Button,
|
login_button: gtk4::Button,
|
||||||
login_status: gtk4::Label,
|
login_status: gtk4::Label,
|
||||||
// Main page.
|
// Main page.
|
||||||
account_row: adw::ActionRow,
|
account_row: adw::ActionRow,
|
||||||
mount_row: adw::SwitchRow,
|
/// Read-only mount status line. The mount is driven by the systemd user
|
||||||
mount_handler: RefCell<Option<SignalHandlerId>>,
|
/// service (enabled on login), not by the user — so this only reports.
|
||||||
|
mount_row: adw::ActionRow,
|
||||||
cache_bar: gtk4::ProgressBar,
|
cache_bar: gtk4::ProgressBar,
|
||||||
cache_label: gtk4::Label,
|
cache_label: gtk4::Label,
|
||||||
pins_group: adw::PreferencesGroup,
|
pins_group: adw::PreferencesGroup,
|
||||||
/// Rows currently shown under [`Self::pins_group`], so a refresh can clear
|
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
|
||||||
/// the previous batch before re-adding.
|
/// diff against them and only rebuild when the pin set actually changes.
|
||||||
pin_rows: RefCell<Vec<adw::ActionRow>>,
|
pin_rows: RefCell<Vec<PinRow>>,
|
||||||
|
/// The pin paths last rendered, the diff baseline for [`repaint_pins`].
|
||||||
|
/// `None` = nothing built yet; `Some(empty)` = the placeholder is shown.
|
||||||
|
pins_state: RefCell<Option<Vec<String>>>,
|
||||||
/// Whether the last refresh saw a live mount daemon. Gates the unpin
|
/// Whether the last refresh saw a live mount daemon. Gates the unpin
|
||||||
/// buttons, which need the daemon to evict + re-hydrate.
|
/// buttons, which need the daemon to evict + re-hydrate.
|
||||||
mounted: RefCell<bool>,
|
mounted: RefCell<bool>,
|
||||||
@@ -76,14 +107,37 @@ struct Ui {
|
|||||||
browser_back: gtk4::Button,
|
browser_back: gtk4::Button,
|
||||||
browser_crumb: gtk4::Label,
|
browser_crumb: gtk4::Label,
|
||||||
browser_status: gtk4::Label,
|
browser_status: gtk4::Label,
|
||||||
|
/// Shown beside [`Self::browser_status`] when a load failed because the mount
|
||||||
|
/// service is down (not merely starting); restarts the service and reloads.
|
||||||
|
browser_retry: gtk4::Button,
|
||||||
/// Mountpoint-relative path the browser is showing (empty = root).
|
/// Mountpoint-relative path the browser is showing (empty = root).
|
||||||
browser_path: RefCell<String>,
|
browser_path: RefCell<String>,
|
||||||
// Photos (gallery) page.
|
// Photos (gallery) page.
|
||||||
gallery_model: gio::ListStore,
|
gallery_model: gio::ListStore,
|
||||||
gallery_status: gtk4::Label,
|
gallery_status: gtk4::Label,
|
||||||
|
gallery_retry: gtk4::Button,
|
||||||
gallery_more: gtk4::Button,
|
gallery_more: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Ui {
|
||||||
|
/// Begin a unit of background work: show + spin the header spinner.
|
||||||
|
fn busy_begin(&self) {
|
||||||
|
self.busy.set(self.busy.get() + 1);
|
||||||
|
self.spinner.set_visible(true);
|
||||||
|
self.spinner.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a unit of background work: stop the spinner once the last one is done.
|
||||||
|
fn busy_end(&self) {
|
||||||
|
let remaining = self.busy.get().saturating_sub(1);
|
||||||
|
self.busy.set(remaining);
|
||||||
|
if remaining == 0 {
|
||||||
|
self.spinner.stop();
|
||||||
|
self.spinner.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
@@ -167,38 +221,48 @@ fn build_window(app: &adw::Application) {
|
|||||||
|
|
||||||
let switcher = adw::ViewSwitcherBar::builder().stack(&stack).build();
|
let switcher = adw::ViewSwitcherBar::builder().stack(&stack).build();
|
||||||
|
|
||||||
|
// Header spinner, hidden until a background open/load is in flight.
|
||||||
|
let spinner = gtk4::Spinner::new();
|
||||||
|
spinner.set_visible(false);
|
||||||
|
|
||||||
let ui = Rc::new(Ui {
|
let ui = Rc::new(Ui {
|
||||||
dirs,
|
dirs,
|
||||||
stack: stack.clone(),
|
stack: stack.clone(),
|
||||||
|
spinner: spinner.clone(),
|
||||||
|
busy: Cell::new(0),
|
||||||
|
opening: RefCell::new(HashSet::new()),
|
||||||
|
session: RefCell::new(auth::load().ok()),
|
||||||
|
status_inflight: Cell::new(false),
|
||||||
email: login_widgets.0,
|
email: login_widgets.0,
|
||||||
password: login_widgets.1,
|
password: login_widgets.1,
|
||||||
totp: login_widgets.2,
|
login_button: login_widgets.2,
|
||||||
login_button: login_widgets.3,
|
login_status: login_widgets.3,
|
||||||
login_status: login_widgets.4,
|
|
||||||
account_row: main_widgets.0,
|
account_row: main_widgets.0,
|
||||||
mount_row: main_widgets.1,
|
mount_row: main_widgets.1,
|
||||||
mount_handler: RefCell::new(None),
|
|
||||||
cache_bar: main_widgets.2,
|
cache_bar: main_widgets.2,
|
||||||
cache_label: main_widgets.3,
|
cache_label: main_widgets.3,
|
||||||
pins_group: main_widgets.4,
|
pins_group: main_widgets.4,
|
||||||
pin_rows: RefCell::new(Vec::new()),
|
pin_rows: RefCell::new(Vec::new()),
|
||||||
|
pins_state: RefCell::new(None),
|
||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
switcher: switcher.clone(),
|
switcher: switcher.clone(),
|
||||||
browser_model: browser_widgets.0,
|
browser_model: browser_widgets.0,
|
||||||
browser_back: browser_widgets.1,
|
browser_back: browser_widgets.1,
|
||||||
browser_crumb: browser_widgets.2,
|
browser_crumb: browser_widgets.2,
|
||||||
browser_status: browser_widgets.3,
|
browser_status: browser_widgets.3,
|
||||||
|
browser_retry: browser_widgets.6,
|
||||||
browser_path: RefCell::new(String::new()),
|
browser_path: RefCell::new(String::new()),
|
||||||
gallery_model: gallery_widgets.0,
|
gallery_model: gallery_widgets.0,
|
||||||
gallery_status: gallery_widgets.1,
|
gallery_status: gallery_widgets.1,
|
||||||
|
gallery_retry: gallery_widgets.4,
|
||||||
gallery_more: gallery_widgets.2,
|
gallery_more: gallery_widgets.2,
|
||||||
});
|
});
|
||||||
|
|
||||||
wire_login(&ui);
|
wire_login(&ui);
|
||||||
wire_logout(&ui, &main_widgets.5);
|
wire_logout(&ui, &main_widgets.5);
|
||||||
wire_mount_switch(&ui);
|
|
||||||
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
|
wire_browser(&ui, &browser_widgets.4, &browser_widgets.5);
|
||||||
wire_gallery(&ui, &gallery_widgets.3);
|
wire_gallery(&ui, &gallery_widgets.3);
|
||||||
|
wire_retry(&ui);
|
||||||
|
|
||||||
// Lazily load the Files / Photos pages the first time they're shown, so the
|
// Lazily load the Files / Photos pages the first time they're shown, so the
|
||||||
// network round-trip only happens on demand rather than on every refresh.
|
// network round-trip only happens on demand rather than on every refresh.
|
||||||
@@ -218,6 +282,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
brand.append(&icon);
|
brand.append(&icon);
|
||||||
brand.append(>k4::Label::new(Some("Proton Drive")));
|
brand.append(>k4::Label::new(Some("Proton Drive")));
|
||||||
header.set_title_widget(Some(&brand));
|
header.set_title_widget(Some(&brand));
|
||||||
|
header.pack_end(&spinner);
|
||||||
|
|
||||||
let toolbar = adw::ToolbarView::new();
|
let toolbar = adw::ToolbarView::new();
|
||||||
toolbar.add_top_bar(&header);
|
toolbar.add_top_bar(&header);
|
||||||
@@ -251,15 +316,15 @@ fn build_window(app: &adw::Application) {
|
|||||||
window.present();
|
window.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The login page: an email row, password row, optional 2FA row, a primary
|
/// The login page: an email row, password row, a primary "Sign in" button and a
|
||||||
/// "Sign in" button and a status label, centred in a clamp.
|
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
|
||||||
|
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn build_login_page() -> (
|
fn build_login_page() -> (
|
||||||
gtk4::Widget,
|
gtk4::Widget,
|
||||||
(
|
(
|
||||||
adw::EntryRow,
|
adw::EntryRow,
|
||||||
adw::PasswordEntryRow,
|
adw::PasswordEntryRow,
|
||||||
adw::EntryRow,
|
|
||||||
gtk4::Button,
|
gtk4::Button,
|
||||||
gtk4::Label,
|
gtk4::Label,
|
||||||
),
|
),
|
||||||
@@ -271,12 +336,8 @@ fn build_login_page() -> (
|
|||||||
|
|
||||||
let email = adw::EntryRow::builder().title("Email or username").build();
|
let email = adw::EntryRow::builder().title("Email or username").build();
|
||||||
let password = adw::PasswordEntryRow::builder().title("Password").build();
|
let password = adw::PasswordEntryRow::builder().title("Password").build();
|
||||||
let totp = adw::EntryRow::builder()
|
|
||||||
.title("Two-factor code (if enabled)")
|
|
||||||
.build();
|
|
||||||
group.add(&email);
|
group.add(&email);
|
||||||
group.add(&password);
|
group.add(&password);
|
||||||
group.add(&totp);
|
|
||||||
|
|
||||||
let login_button = gtk4::Button::builder()
|
let login_button = gtk4::Button::builder()
|
||||||
.label("Sign in")
|
.label("Sign in")
|
||||||
@@ -319,7 +380,7 @@ fn build_login_page() -> (
|
|||||||
|
|
||||||
(
|
(
|
||||||
scroll.upcast(),
|
scroll.upcast(),
|
||||||
(email, password, totp, login_button, login_status),
|
(email, password, login_button, login_status),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +392,7 @@ fn build_main_page() -> (
|
|||||||
gtk4::Widget,
|
gtk4::Widget,
|
||||||
(
|
(
|
||||||
adw::ActionRow,
|
adw::ActionRow,
|
||||||
adw::SwitchRow,
|
adw::ActionRow,
|
||||||
gtk4::ProgressBar,
|
gtk4::ProgressBar,
|
||||||
gtk4::Label,
|
gtk4::Label,
|
||||||
adw::PreferencesGroup,
|
adw::PreferencesGroup,
|
||||||
@@ -351,10 +412,11 @@ fn build_main_page() -> (
|
|||||||
account_row.add_suffix(&logout_button);
|
account_row.add_suffix(&logout_button);
|
||||||
account_group.add(&account_row);
|
account_group.add(&account_row);
|
||||||
|
|
||||||
// Mount group: a switch row driving the FUSE mount.
|
// Mount group: a read-only status line. The mount is managed automatically
|
||||||
|
// by the systemd user service; there is no toggle to fiddle with.
|
||||||
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
|
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
|
||||||
let mount_row = adw::SwitchRow::builder()
|
let mount_row = adw::ActionRow::builder()
|
||||||
.title("Mount Proton Drive")
|
.title("Proton Drive")
|
||||||
.subtitle("Not mounted")
|
.subtitle("Not mounted")
|
||||||
.build();
|
.build();
|
||||||
mount_group.add(&mount_row);
|
mount_group.add(&mount_row);
|
||||||
@@ -417,7 +479,6 @@ fn wire_login(ui: &Rc<Ui>) {
|
|||||||
button.connect_clicked(move |_| {
|
button.connect_clicked(move |_| {
|
||||||
let username = ui.email.text().to_string();
|
let username = ui.email.text().to_string();
|
||||||
let password = ui.password.text().to_string();
|
let password = ui.password.text().to_string();
|
||||||
let totp = ui.totp.text().to_string();
|
|
||||||
if username.is_empty() || password.is_empty() {
|
if username.is_empty() || password.is_empty() {
|
||||||
ui.login_status.set_text("Enter your email and password.");
|
ui.login_status.set_text("Enter your email and password.");
|
||||||
return;
|
return;
|
||||||
@@ -425,7 +486,17 @@ fn wire_login(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
ui.login_button.set_sensitive(false);
|
ui.login_button.set_sensitive(false);
|
||||||
ui.login_status.set_text("Signing in…");
|
ui.login_status.set_text("Signing in…");
|
||||||
let rx = spawn_login(username, password, totp);
|
let (rx, totp_req_rx) = spawn_login(username, password);
|
||||||
|
|
||||||
|
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||||
|
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||||
|
// back a code (or is cancelled, dropping the sender).
|
||||||
|
let ui_2fa = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
if let Ok(code_tx) = totp_req_rx.recv().await {
|
||||||
|
prompt_2fa(&ui_2fa, code_tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -438,7 +509,10 @@ fn wire_login(ui: &Rc<Ui>) {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui.login_status.set_text("");
|
ui.login_status.set_text("");
|
||||||
ui.password.set_text("");
|
ui.password.set_text("");
|
||||||
ui.totp.set_text("");
|
// Cache the new identity so `refresh` never hits the keyring.
|
||||||
|
*ui.session.borrow_mut() = auth::load().ok();
|
||||||
|
// Enable+start the mount service now that we have a session.
|
||||||
|
service::enable_start();
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
}
|
}
|
||||||
Err(e) => ui.login_status.set_text(&format!("Sign-in failed: {e}")),
|
Err(e) => ui.login_status.set_text(&format!("Sign-in failed: {e}")),
|
||||||
@@ -448,15 +522,21 @@ fn wire_login(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||||
/// runtime, returning a channel that yields the result once. The TOTP closure
|
/// runtime. Returns two channels: the first yields the final login result once;
|
||||||
/// supplies whatever was typed in the 2FA field, erroring if the account needs a
|
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||||
/// code but none was entered.
|
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
|
||||||
|
/// closure blocks the worker on that sender until the dialog answers, so the
|
||||||
|
/// code is requested lazily and can't expire before the password proof.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
fn spawn_login(
|
fn spawn_login(
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
totp: String,
|
) -> (
|
||||||
) -> async_channel::Receiver<Result<(), String>> {
|
async_channel::Receiver<Result<(), String>>,
|
||||||
|
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||||
|
) {
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
|
let (totp_req_tx, totp_req_rx) = async_channel::bounded(1);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -470,138 +550,210 @@ fn spawn_login(
|
|||||||
};
|
};
|
||||||
let result = rt.block_on(async move {
|
let result = rt.block_on(async move {
|
||||||
auth::login(&username, &password, || {
|
auth::login(&username, &password, || {
|
||||||
let code = totp.trim();
|
// 2FA required: hand a one-shot sender to the UI and block until
|
||||||
if code.is_empty() {
|
// the dialog supplies the code. A dropped sender (cancelled
|
||||||
Err(pdfs_core::Error::Other("two-factor code required".into()))
|
// dialog) surfaces as a cancelled login.
|
||||||
} else {
|
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
|
||||||
Ok(code.to_string())
|
totp_req_tx
|
||||||
}
|
.send_blocking(code_tx)
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
|
||||||
|
code_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
let _ = tx.send_blocking(result);
|
||||||
});
|
});
|
||||||
rx
|
(rx, totp_req_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect the sign-out button: forget the stored session and drop back to the
|
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||||
/// login page. Unmounts first so the daemon isn't left without credentials.
|
/// login worker via `code_tx`. Cancelling (or closing) drops the sender, which
|
||||||
|
/// the worker reads as a cancelled login.
|
||||||
|
fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Two-factor authentication")
|
||||||
|
.body("Enter the code from your authenticator app.")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
let entry = adw::EntryRow::builder()
|
||||||
|
.title("Authentication code")
|
||||||
|
.build();
|
||||||
|
group.add(&entry);
|
||||||
|
dialog.set_extra_child(Some(&group));
|
||||||
|
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("confirm", "Confirm");
|
||||||
|
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("confirm"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let code_tx = RefCell::new(Some(code_tx));
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
// On cancel/close we take + drop `tx` without sending, so the worker's
|
||||||
|
// recv errors out and the login is reported as cancelled.
|
||||||
|
if let Some(tx) = code_tx.borrow_mut().take()
|
||||||
|
&& resp == "confirm"
|
||||||
|
{
|
||||||
|
let _ = tx.send(entry.text().trim().to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let parent = ui.login_button.root().and_downcast::<gtk4::Window>();
|
||||||
|
dialog.present(parent.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect the sign-out button: disable+stop the mount service (so the daemon
|
||||||
|
/// isn't left running without credentials), forget the stored session, and drop
|
||||||
|
/// back to the login page.
|
||||||
fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
button.connect_clicked(move |_| {
|
button.connect_clicked(move |_| {
|
||||||
if *ui.mounted.borrow() {
|
service::disable_stop();
|
||||||
unmount(&ui.dirs.default_mountpoint());
|
|
||||||
}
|
|
||||||
if let Err(e) = auth::logout() {
|
if let Err(e) = auth::logout() {
|
||||||
tracing::error!("logout failed: {e}");
|
tracing::error!("logout failed: {e}");
|
||||||
}
|
}
|
||||||
|
*ui.session.borrow_mut() = None;
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect the mount switch. The handler id is stored so the refresh loop can
|
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
||||||
/// block it while syncing the switch to the real mount state (otherwise setting
|
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
|
||||||
/// `active` would re-trigger a mount/unmount).
|
/// reload the page.
|
||||||
fn wire_mount_switch(ui: &Rc<Ui>) {
|
fn wire_retry(ui: &Rc<Ui>) {
|
||||||
let ui_for_handler = ui.clone();
|
let ui_browser = ui.clone();
|
||||||
let handler = ui.mount_row.connect_active_notify(move |row| {
|
ui.browser_retry.clone().connect_clicked(move |_| {
|
||||||
let mountpoint = ui_for_handler.dirs.default_mountpoint();
|
service::restart();
|
||||||
if row.is_active() {
|
load_browser(&ui_browser);
|
||||||
spawn_mount();
|
});
|
||||||
} else {
|
let ui_gallery = ui.clone();
|
||||||
unmount(&mountpoint);
|
ui.gallery_retry.clone().connect_clicked(move |_| {
|
||||||
}
|
service::restart();
|
||||||
|
load_gallery(&ui_gallery, false);
|
||||||
});
|
});
|
||||||
*ui.mount_handler.borrow_mut() = Some(handler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read mount status, login identity, cache usage and the pin list, and
|
/// Repaint the window from the cached login identity, then kick an async mount-
|
||||||
/// repaint the window. Cheap, synchronous local reads — safe on the main loop.
|
/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring),
|
||||||
|
/// and the status round-trip is offloaded to a worker so the main loop never
|
||||||
|
/// blocks on a slow or wedged daemon.
|
||||||
fn refresh(ui: &Rc<Ui>) {
|
fn refresh(ui: &Rc<Ui>) {
|
||||||
// Login identity decides which page is shown.
|
// Login identity decides which page is shown. Read the cached session — set
|
||||||
let session = auth::load().ok();
|
// at startup and on login/logout — never the keyring.
|
||||||
match &session {
|
{
|
||||||
Some(s) => {
|
let session = ui.session.borrow();
|
||||||
// Only pull the user onto "main" when they're sitting on the login
|
match session.as_ref() {
|
||||||
// page; otherwise leave whichever signed-in page they navigated to.
|
Some(s) => {
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
// Only pull the user onto "main" when they're sitting on the login
|
||||||
ui.stack.set_visible_child_name("main");
|
// page; otherwise leave whichever signed-in page they navigated to.
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
||||||
|
ui.stack.set_visible_child_name("main");
|
||||||
|
}
|
||||||
|
ui.switcher.set_reveal(true);
|
||||||
|
ui.account_row.set_title(&s.username);
|
||||||
|
ui.account_row.set_subtitle("Proton account");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui.stack.set_visible_child_name("login");
|
||||||
|
ui.switcher.set_reveal(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
ui.switcher.set_reveal(true);
|
|
||||||
ui.account_row.set_title(&s.username);
|
|
||||||
ui.account_row.set_subtitle("Proton account");
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
ui.stack.set_visible_child_name("login");
|
|
||||||
ui.switcher.set_reveal(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live mount status from the daemon, falling back to "not mounted".
|
refresh_status(ui);
|
||||||
let socket = ui.dirs.control_socket();
|
|
||||||
let mount = match send(&socket, &Request::Status) {
|
|
||||||
Ok(Response::Status { mountpoint, .. }) => Some(mountpoint),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
*ui.mounted.borrow_mut() = mount.is_some();
|
|
||||||
|
|
||||||
// Sync the switch without re-triggering its handler.
|
|
||||||
if let Some(id) = ui.mount_handler.borrow().as_ref() {
|
|
||||||
ui.mount_row.block_signal(id);
|
|
||||||
ui.mount_row.set_active(mount.is_some());
|
|
||||||
ui.mount_row.unblock_signal(id);
|
|
||||||
}
|
|
||||||
ui.mount_row.set_subtitle(&match &mount {
|
|
||||||
Some(mp) => format!("Mounted at {mp}"),
|
|
||||||
None => "Not mounted".into(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache usage + pins, read straight off disk so they work offline too.
|
|
||||||
let cache = ContentCache::open(
|
|
||||||
ui.dirs.content_cache_dir(),
|
|
||||||
ui.dirs.pins_path(),
|
|
||||||
DEFAULT_CACHE_BUDGET_BYTES,
|
|
||||||
);
|
|
||||||
match cache {
|
|
||||||
Ok(cache) => {
|
|
||||||
let used = cache.usage();
|
|
||||||
let budget = cache.budget();
|
|
||||||
let fraction = if budget == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
(used as f64 / budget as f64).min(1.0)
|
|
||||||
};
|
|
||||||
ui.cache_bar.set_fraction(fraction);
|
|
||||||
ui.cache_label.set_text(&format!(
|
|
||||||
"{} of {} used",
|
|
||||||
human_bytes(used),
|
|
||||||
human_bytes(budget)
|
|
||||||
));
|
|
||||||
repaint_pins(ui, &cache.list_pins());
|
|
||||||
}
|
|
||||||
Err(e) => ui.cache_label.set_text(&format!("Cache unavailable: {e}")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the rows under the pins group with one [`adw::ActionRow`] per pin,
|
/// Fetch mount status + cache stats from the daemon on a worker thread and repaint
|
||||||
/// each with an unpin button enabled only while a mount daemon is running.
|
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
|
||||||
fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin]) {
|
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
|
||||||
for row in ui.pin_rows.borrow_mut().drain(..) {
|
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
|
||||||
ui.pins_group.remove(&row);
|
/// the tick can't stack threads on a stalled daemon.
|
||||||
|
fn refresh_status(ui: &Rc<Ui>) {
|
||||||
|
if ui.status_inflight.get() {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
ui.status_inflight.set(true);
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::Status);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.status_inflight.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Status {
|
||||||
|
mountpoint,
|
||||||
|
used,
|
||||||
|
budget,
|
||||||
|
pins,
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
*ui.mounted.borrow_mut() = true;
|
||||||
|
ui.mount_row
|
||||||
|
.set_subtitle(&format!("Mounted at {mountpoint}"));
|
||||||
|
let fraction = if budget == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
(used as f64 / budget as f64).min(1.0)
|
||||||
|
};
|
||||||
|
ui.cache_bar.set_fraction(fraction);
|
||||||
|
ui.cache_label.set_text(&format!(
|
||||||
|
"{} of {} used",
|
||||||
|
human_bytes(used),
|
||||||
|
human_bytes(budget)
|
||||||
|
));
|
||||||
|
repaint_pins(&ui, &pins, true);
|
||||||
|
}
|
||||||
|
// Daemon unreachable (still starting, or down): report not-mounted and
|
||||||
|
// grey out the unpin buttons in place, but leave the last-known pin
|
||||||
|
// rows and cache read-out so the page doesn't flicker on a blip.
|
||||||
|
_ => {
|
||||||
|
*ui.mounted.borrow_mut() = false;
|
||||||
|
ui.mount_row.set_subtitle("Not mounted");
|
||||||
|
for r in ui.pin_rows.borrow().iter() {
|
||||||
|
if let Some(b) = &r.unpin {
|
||||||
|
b.set_sensitive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
|
||||||
|
/// mount daemon is running (`mounted`). Diffs against the last batch by path: when
|
||||||
|
/// the set is unchanged (the common case on the 2s tick) it only flips the unpin
|
||||||
|
/// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list
|
||||||
|
/// and drop scroll/selection every tick.
|
||||||
|
fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted: bool) {
|
||||||
|
let desired: Vec<String> = pins.iter().map(|p| p.path.clone()).collect();
|
||||||
|
if ui.pins_state.borrow().as_ref() == Some(&desired) {
|
||||||
|
for r in ui.pin_rows.borrow().iter() {
|
||||||
|
if let Some(b) = &r.unpin {
|
||||||
|
b.set_sensitive(mounted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for pr in ui.pin_rows.borrow_mut().drain(..) {
|
||||||
|
ui.pins_group.remove(&pr.row);
|
||||||
|
}
|
||||||
|
*ui.pins_state.borrow_mut() = Some(desired);
|
||||||
|
|
||||||
if pins.is_empty() {
|
if pins.is_empty() {
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title("No pinned files")
|
.title("No pinned files")
|
||||||
.subtitle("Right-click a file in the mount to keep it offline.")
|
.subtitle("Right-click a file in the mount to keep it offline.")
|
||||||
.build();
|
.build();
|
||||||
ui.pins_group.add(&row);
|
ui.pins_group.add(&row);
|
||||||
ui.pin_rows.borrow_mut().push(row);
|
ui.pin_rows.borrow_mut().push(PinRow { row, unpin: None });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mounted = *ui.mounted.borrow();
|
|
||||||
for pin in pins {
|
for pin in pins {
|
||||||
let name = Path::new(&pin.path)
|
let name = Path::new(&pin.path)
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -635,7 +787,10 @@ fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin]) {
|
|||||||
row.add_suffix(&unpin);
|
row.add_suffix(&unpin);
|
||||||
|
|
||||||
ui.pins_group.add(&row);
|
ui.pins_group.add(&row);
|
||||||
ui.pin_rows.borrow_mut().push(row);
|
ui.pin_rows.borrow_mut().push(PinRow {
|
||||||
|
row,
|
||||||
|
unpin: Some(unpin),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +820,7 @@ fn spawn_request(
|
|||||||
/// The factories that render entries — and the columns — need the [`Ui`] handle
|
/// The factories that render entries — and the columns — need the [`Ui`] handle
|
||||||
/// for activation and the right-click menu, so they're installed later in
|
/// for activation and the right-click menu, so they're installed later in
|
||||||
/// [`wire_browser`]; this builder only assembles the empty widgets.
|
/// [`wire_browser`]; this builder only assembles the empty widgets.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
fn build_browser_page() -> (
|
fn build_browser_page() -> (
|
||||||
gtk4::Widget,
|
gtk4::Widget,
|
||||||
(
|
(
|
||||||
@@ -674,6 +830,7 @@ fn build_browser_page() -> (
|
|||||||
gtk4::Label,
|
gtk4::Label,
|
||||||
gtk4::GridView,
|
gtk4::GridView,
|
||||||
gtk4::ColumnView,
|
gtk4::ColumnView,
|
||||||
|
gtk4::Button,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||||
@@ -718,6 +875,18 @@ fn build_browser_page() -> (
|
|||||||
status.add_css_class("dim-label");
|
status.add_css_class("dim-label");
|
||||||
status.set_visible(false);
|
status.set_visible(false);
|
||||||
|
|
||||||
|
// Shown only when a load failed because the mount is down; restarts it.
|
||||||
|
let retry = gtk4::Button::builder()
|
||||||
|
.label("Retry")
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
retry.add_css_class("pill");
|
||||||
|
retry.set_visible(false);
|
||||||
|
|
||||||
|
let status_box = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||||
|
status_box.append(&status);
|
||||||
|
status_box.append(&retry);
|
||||||
|
|
||||||
// Icon grid.
|
// Icon grid.
|
||||||
let grid = gtk4::GridView::builder()
|
let grid = gtk4::GridView::builder()
|
||||||
.model(>k4::SingleSelection::new(Some(model.clone())))
|
.model(>k4::SingleSelection::new(Some(model.clone())))
|
||||||
@@ -764,12 +933,12 @@ fn build_browser_page() -> (
|
|||||||
inner.set_margin_start(12);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(12);
|
||||||
inner.append(&header);
|
inner.append(&header);
|
||||||
inner.append(&status);
|
inner.append(&status_box);
|
||||||
inner.append(&view_stack);
|
inner.append(&view_stack);
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
inner.upcast(),
|
||||||
(model, back, crumb, status, grid, column_view),
|
(model, back, crumb, status, grid, column_view, retry),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -999,9 +1168,22 @@ fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
*ui.browser_path.borrow_mut() = rel;
|
*ui.browser_path.borrow_mut() = rel;
|
||||||
load_browser(ui);
|
load_browser(ui);
|
||||||
} else {
|
} else {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::OpenFile { path: rel });
|
// Ignore a repeat activation of a file already downloading, so an
|
||||||
|
// impatient double-click doesn't kick off a second round-trip.
|
||||||
|
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::OpenFile { path: rel.clone() },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
ui.opening.borrow_mut().remove(&rel);
|
||||||
|
match result {
|
||||||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
||||||
Ok(Ok(Response::Error { message })) => {
|
Ok(Ok(Response::Error { message })) => {
|
||||||
tracing::error!("open file failed: {message}")
|
tracing::error!("open file failed: {message}")
|
||||||
@@ -1099,27 +1281,61 @@ fn load_browser(ui: &Rc<Ui>) {
|
|||||||
&path
|
&path
|
||||||
});
|
});
|
||||||
ui.browser_back.set_sensitive(!path.is_empty());
|
ui.browser_back.set_sensitive(!path.is_empty());
|
||||||
ui.browser_status.set_visible(false);
|
|
||||||
|
|
||||||
|
// Drop the previous folder's rows up front: a slow reply must not leave stale
|
||||||
|
// entries visible, where clicking one would open with a wrong relative path.
|
||||||
|
ui.browser_model.remove_all();
|
||||||
|
ui.browser_retry.set_visible(false);
|
||||||
|
ui.browser_status.set_label("Loading…");
|
||||||
|
ui.browser_status.set_visible(true);
|
||||||
|
|
||||||
|
ui.busy_begin();
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListDir { path });
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListDir { path });
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match result {
|
||||||
Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries),
|
Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries),
|
||||||
Ok(Ok(Response::Error { message })) => browser_message(&ui, &message),
|
Ok(Ok(Response::Error { message })) => browser_message(&ui, &message),
|
||||||
Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."),
|
Ok(Ok(_)) => browser_message(&ui, "Unexpected reply from daemon."),
|
||||||
Ok(Err(_)) | Err(_) => browser_message(&ui, "Mount Proton Drive to browse your files."),
|
Ok(Err(_)) | Err(_) => browser_unreachable(&ui),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the model and show a single status line instead.
|
/// Clear the model and show a single status line instead. Hides the Retry button
|
||||||
|
/// (used for "in-band" outcomes: empty folder, or an error the daemon returned).
|
||||||
fn browser_message(ui: &Rc<Ui>, message: &str) {
|
fn browser_message(ui: &Rc<Ui>, message: &str) {
|
||||||
ui.browser_model.remove_all();
|
ui.browser_model.remove_all();
|
||||||
|
ui.browser_retry.set_visible(false);
|
||||||
ui.browser_status.set_label(message);
|
ui.browser_status.set_label(message);
|
||||||
ui.browser_status.set_visible(true);
|
ui.browser_status.set_visible(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The daemon didn't answer. Distinguish *still starting* (auto-retry, no
|
||||||
|
/// button) from *down* (actionable error + Retry), so a cold start self-heals
|
||||||
|
/// once the systemd mount comes up but a real failure stays visible.
|
||||||
|
fn browser_unreachable(ui: &Rc<Ui>) {
|
||||||
|
if service::is_failed() || !service::is_active() {
|
||||||
|
ui.browser_status
|
||||||
|
.set_label("Couldn't reach Proton Drive. The mount service isn't running.");
|
||||||
|
ui.browser_status.set_visible(true);
|
||||||
|
ui.browser_retry.set_visible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.browser_status.set_label("Connecting to Proton Drive…");
|
||||||
|
ui.browser_status.set_visible(true);
|
||||||
|
ui.browser_retry.set_visible(false);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||||
|
// Only keep polling while the Files page is the one on screen.
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("browser") {
|
||||||
|
load_browser(&ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Repopulate the shared model — folders first, then case-insensitive by name —
|
/// Repopulate the shared model — folders first, then case-insensitive by name —
|
||||||
/// which refreshes both the grid and the column list.
|
/// which refreshes both the grid and the column list.
|
||||||
fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
@@ -1146,7 +1362,13 @@ fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
/// "Load more" button and a status label.
|
/// "Load more" button and a status label.
|
||||||
fn build_gallery_page() -> (
|
fn build_gallery_page() -> (
|
||||||
gtk4::Widget,
|
gtk4::Widget,
|
||||||
(gio::ListStore, gtk4::Label, gtk4::Button, gtk4::GridView),
|
(
|
||||||
|
gio::ListStore,
|
||||||
|
gtk4::Label,
|
||||||
|
gtk4::Button,
|
||||||
|
gtk4::GridView,
|
||||||
|
gtk4::Button,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||||
|
|
||||||
@@ -1187,6 +1409,14 @@ fn build_gallery_page() -> (
|
|||||||
status.add_css_class("dim-label");
|
status.add_css_class("dim-label");
|
||||||
status.set_visible(false);
|
status.set_visible(false);
|
||||||
|
|
||||||
|
// Shown only when a load failed because the mount is down; restarts it.
|
||||||
|
let retry = gtk4::Button::builder()
|
||||||
|
.label("Retry")
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
retry.add_css_class("pill");
|
||||||
|
retry.set_visible(false);
|
||||||
|
|
||||||
let more = gtk4::Button::builder()
|
let more = gtk4::Button::builder()
|
||||||
.label("Load more")
|
.label("Load more")
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
@@ -1205,10 +1435,11 @@ fn build_gallery_page() -> (
|
|||||||
inner.set_margin_start(12);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(12);
|
||||||
inner.append(&status);
|
inner.append(&status);
|
||||||
|
inner.append(&retry);
|
||||||
inner.append(&scroll);
|
inner.append(&scroll);
|
||||||
inner.append(&more);
|
inner.append(&more);
|
||||||
|
|
||||||
(inner.upcast(), (model, status, more, grid))
|
(inner.upcast(), (model, status, more, grid, retry))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire the gallery: activating a tile downloads the photo and opens it; the
|
/// Wire the gallery: activating a tile downloads the photo and opens it; the
|
||||||
@@ -1223,9 +1454,21 @@ fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
|||||||
.downcast_ref::<BoxedAnyObject>()
|
.downcast_ref::<BoxedAnyObject>()
|
||||||
.map(|o| o.borrow::<PhotoItem>().uid.clone());
|
.map(|o| o.borrow::<PhotoItem>().uid.clone());
|
||||||
let Some(uid) = uid else { return };
|
let Some(uid) = uid else { return };
|
||||||
let rx = spawn_request(ui_open.dirs.control_socket(), Request::OpenPhoto { uid });
|
// Ignore a repeat activation of a photo already downloading.
|
||||||
|
if !ui_open.opening.borrow_mut().insert(uid.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui_open.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui_open.dirs.control_socket(),
|
||||||
|
Request::OpenPhoto { uid: uid.clone() },
|
||||||
|
);
|
||||||
|
let ui = ui_open.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
ui.opening.borrow_mut().remove(&uid);
|
||||||
|
match result {
|
||||||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
||||||
Ok(Ok(Response::Error { message })) => {
|
Ok(Ok(Response::Error { message })) => {
|
||||||
tracing::error!("open photo failed: {message}")
|
tracing::error!("open photo failed: {message}")
|
||||||
@@ -1244,13 +1487,19 @@ fn wire_gallery(ui: &Rc<Ui>, grid: >k4::GridView) {
|
|||||||
/// Fetch a timeline page from the daemon. When `append` is false the model is
|
/// Fetch a timeline page from the daemon. When `append` is false the model is
|
||||||
/// cleared first (fresh load); otherwise the next page is tacked on.
|
/// cleared first (fresh load); otherwise the next page is tacked on.
|
||||||
fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||||
if !append {
|
if append {
|
||||||
|
ui.gallery_status.set_visible(false);
|
||||||
|
} else {
|
||||||
|
// Fresh load: clear the grid and show Loading until the first page lands.
|
||||||
ui.gallery_model.remove_all();
|
ui.gallery_model.remove_all();
|
||||||
|
ui.gallery_retry.set_visible(false);
|
||||||
|
ui.gallery_status.set_label("Loading…");
|
||||||
|
ui.gallery_status.set_visible(true);
|
||||||
}
|
}
|
||||||
let offset = ui.gallery_model.n_items() as usize;
|
let offset = ui.gallery_model.n_items() as usize;
|
||||||
ui.gallery_status.set_visible(false);
|
|
||||||
ui.gallery_more.set_sensitive(false);
|
ui.gallery_more.set_sensitive(false);
|
||||||
|
|
||||||
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
ui.dirs.control_socket(),
|
ui.dirs.control_socket(),
|
||||||
Request::PhotosTimeline {
|
Request::PhotosTimeline {
|
||||||
@@ -1260,8 +1509,10 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
);
|
);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
ui.gallery_more.set_sensitive(true);
|
ui.gallery_more.set_sensitive(true);
|
||||||
match rx.recv().await {
|
match result {
|
||||||
Ok(Ok(Response::Photos { available, items })) => {
|
Ok(Ok(Response::Photos { available, items })) => {
|
||||||
if !available {
|
if !available {
|
||||||
gallery_message(&ui, "This account has no photos.");
|
gallery_message(&ui, "This account has no photos.");
|
||||||
@@ -1280,18 +1531,41 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message })) => gallery_message(&ui, &message),
|
Ok(Ok(Response::Error { message })) => gallery_message(&ui, &message),
|
||||||
Ok(Ok(_)) => gallery_message(&ui, "Unexpected reply from daemon."),
|
Ok(Ok(_)) => gallery_message(&ui, "Unexpected reply from daemon."),
|
||||||
Ok(Err(_)) | Err(_) => gallery_message(&ui, "Mount Proton Drive to see your photos."),
|
Ok(Err(_)) | Err(_) => gallery_unreachable(&ui),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a gallery status line and hide the pager.
|
/// Show a gallery status line and hide the pager + Retry button.
|
||||||
fn gallery_message(ui: &Rc<Ui>, message: &str) {
|
fn gallery_message(ui: &Rc<Ui>, message: &str) {
|
||||||
|
ui.gallery_retry.set_visible(false);
|
||||||
ui.gallery_status.set_label(message);
|
ui.gallery_status.set_label(message);
|
||||||
ui.gallery_status.set_visible(true);
|
ui.gallery_status.set_visible(true);
|
||||||
ui.gallery_more.set_visible(false);
|
ui.gallery_more.set_visible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photos counterpart of [`browser_unreachable`]: auto-retry while the mount is
|
||||||
|
/// still starting, surface an actionable error + Retry once it's actually down.
|
||||||
|
fn gallery_unreachable(ui: &Rc<Ui>) {
|
||||||
|
ui.gallery_more.set_visible(false);
|
||||||
|
if service::is_failed() || !service::is_active() {
|
||||||
|
ui.gallery_status
|
||||||
|
.set_label("Couldn't reach Proton Drive. The mount service isn't running.");
|
||||||
|
ui.gallery_status.set_visible(true);
|
||||||
|
ui.gallery_retry.set_visible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.gallery_status.set_label("Connecting to Proton Drive…");
|
||||||
|
ui.gallery_status.set_visible(true);
|
||||||
|
ui.gallery_retry.set_visible(false);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("gallery") {
|
||||||
|
load_gallery(&ui, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a local path with the user's default handler.
|
/// Open a local path with the user's default handler.
|
||||||
fn open_path(path: &str) {
|
fn open_path(path: &str) {
|
||||||
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
||||||
@@ -1299,25 +1573,6 @@ fn open_path(path: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch `pdfs mount` detached so the window keeps running independently.
|
|
||||||
fn spawn_mount() {
|
|
||||||
match Command::new("pdfs").arg("mount").spawn() {
|
|
||||||
Ok(_) => tracing::info!("spawned `pdfs mount`"),
|
|
||||||
Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tear down the FUSE mount. `fusermount3 -u` is the native path; fall back to
|
|
||||||
/// `fusermount` on older systems.
|
|
||||||
fn unmount(mountpoint: &Path) {
|
|
||||||
let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status();
|
|
||||||
match try_unmount("fusermount3").or_else(|_| try_unmount("fusermount")) {
|
|
||||||
Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()),
|
|
||||||
Ok(s) => tracing::error!("unmount exited with {s}"),
|
|
||||||
Err(e) => tracing::error!("failed to run fusermount: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
|
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
|
||||||
fn human_bytes(bytes: u64) -> String {
|
fn human_bytes(bytes: u64) -> String {
|
||||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||||
|
|||||||
+12
-29
@@ -19,6 +19,7 @@ use ksni::{MenuItem, Tray, TrayService};
|
|||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{Request, Response, send};
|
use pdfs_core::control::{Request, Response, send};
|
||||||
|
use pdfs_core::service;
|
||||||
|
|
||||||
/// How often the tray re-polls the daemon to refresh its menu.
|
/// How often the tray re-polls the daemon to refresh its menu.
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||||
@@ -71,26 +72,6 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch `pdfs mount` detached so the tray keeps running independently.
|
|
||||||
fn spawn_mount() {
|
|
||||||
match Command::new("pdfs").arg("mount").spawn() {
|
|
||||||
Ok(_) => tracing::info!("spawned `pdfs mount`"),
|
|
||||||
Err(e) => tracing::error!("failed to spawn `pdfs mount`: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unmount the FUSE filesystem. `fusermount3 -u` is the FUSE-native teardown;
|
|
||||||
/// fall back to `fusermount` on older systems.
|
|
||||||
fn unmount(mountpoint: &Path) {
|
|
||||||
let try_unmount = |bin: &str| Command::new(bin).arg("-u").arg(mountpoint).status();
|
|
||||||
let result = try_unmount("fusermount3").or_else(|_| try_unmount("fusermount"));
|
|
||||||
match result {
|
|
||||||
Ok(s) if s.success() => tracing::info!("unmounted {}", mountpoint.display()),
|
|
||||||
Ok(s) => tracing::error!("unmount exited with {s}"),
|
|
||||||
Err(e) => tracing::error!("failed to run fusermount: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn open_folder(mountpoint: &Path) {
|
fn open_folder(mountpoint: &Path) {
|
||||||
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
|
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
|
||||||
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
|
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
|
||||||
@@ -111,9 +92,8 @@ impl Tray for DriveTray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn menu(&self) -> Vec<MenuItem<Self>> {
|
fn menu(&self) -> Vec<MenuItem<Self>> {
|
||||||
// Values captured into the action closures (which only get `&mut Self`).
|
// Value captured into the action closure (which only gets `&mut Self`).
|
||||||
let mountpoint = self.state.mountpoint.clone();
|
let open_mp = self.state.mountpoint.clone();
|
||||||
let open_mp = mountpoint.clone();
|
|
||||||
|
|
||||||
let mut items: Vec<MenuItem<Self>> = vec![
|
let mut items: Vec<MenuItem<Self>> = vec![
|
||||||
StandardItem {
|
StandardItem {
|
||||||
@@ -134,25 +114,28 @@ impl Tray for DriveTray {
|
|||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
|
||||||
|
// login or reboot brings it back; this is a deliberate disconnect.
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Unmount".into(),
|
label: "Disconnect".into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
unmount(&mountpoint);
|
service::stop();
|
||||||
this.state.mounted = false;
|
this.state.mounted = false;
|
||||||
this.state.line = "Unmounting…".into();
|
this.state.line = "Disconnecting…".into();
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Enable+start the service so it mounts now and on future logins.
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Mount".into(),
|
label: "Connect".into(),
|
||||||
activate: Box::new(|this: &mut Self| {
|
activate: Box::new(|this: &mut Self| {
|
||||||
spawn_mount();
|
service::enable_start();
|
||||||
this.state.line = "Mounting…".into();
|
this.state.line = "Connecting…".into();
|
||||||
}),
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -8,7 +8,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=0.1.0
|
pkgver=0.1.2
|
||||||
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')
|
||||||
@@ -61,5 +61,10 @@ package() {
|
|||||||
install -Dm644 "packaging/io.narl.proton-drive-linux.svg" \
|
install -Dm644 "packaging/io.narl.proton-drive-linux.svg" \
|
||||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
||||||
|
|
||||||
|
# systemd user service that runs the auto-mount daemon. Enabled per-user by
|
||||||
|
# the GUI on login (`systemctl --user enable --now proton-drive.service`).
|
||||||
|
install -Dm644 "packaging/proton-drive.service" \
|
||||||
|
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
|
||||||
|
|
||||||
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Proton Drive Files-On-Demand mount
|
||||||
|
Documentation=https://github.com/narl/proton-drive-linux
|
||||||
|
# Tie the mount to the desktop session: start after it is up and stop with it.
|
||||||
|
After=graphical-session.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it
|
||||||
|
# mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount.
|
||||||
|
ExecStart=/usr/bin/pdfs daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# Give the lazy unmount time to finish before SIGKILL.
|
||||||
|
TimeoutStopSec=15
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Reference in new issue
Block a user