Compare commits

...
4 Commits
Author SHA1 Message Date
nvrl d131d3dca8 implemented proton drive prompt 2026-07-01 00:21:42 +02:00
nvrl 5766bc4ed1 transfer ui 2026-07-01 00:02:35 +02:00
nvrl 35bc957cd5 transfer progress api 2026-06-30 23:52:41 +02:00
nvrl f9000f8540 version bump 2026-06-30 21:42:09 +02:00
11 changed files with 1264 additions and 34 deletions

No files matched your search

Generated
+8 -8
View File
@@ -2825,7 +2825,7 @@ dependencies = [
[[package]]
name = "pdfs-cli"
version = "0.2.2"
version = "0.2.3"
dependencies = [
"anyhow",
"clap 4.6.1",
@@ -2840,7 +2840,7 @@ dependencies = [
[[package]]
name = "pdfs-core"
version = "0.2.2"
version = "0.2.3"
dependencies = [
"directories",
"keyring",
@@ -2857,7 +2857,7 @@ dependencies = [
[[package]]
name = "pdfs-fuse"
version = "0.2.2"
version = "0.2.3"
dependencies = [
"fuser",
"libc",
@@ -2870,7 +2870,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "0.2.2"
version = "0.2.3"
dependencies = [
"anyhow",
"async-channel",
@@ -3085,9 +3085,9 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9380c88a032e2ff079b2d5a6221ccc40ebeee3ebbb4ea60d4d65ef770e4ca8d"
checksum = "76e4399b4d9b076c5886c7db87ae7f70322dd7127e6c5a4e7eff8719434d2434"
dependencies = [
"async-trait",
"base64",
@@ -3110,9 +3110,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1da22604d2568af2d15be6a45f23f8c5b95fa775bdac1540e833c2eafb587888"
checksum = "099f6f20709452f2cdb0cee4db0a82e61a29bec261295812908bf956ffcdaa00"
dependencies = [
"aes-gcm",
"async-trait",
+4 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "0.2.2"
version = "0.2.3"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
@@ -11,8 +11,8 @@ rust-version = "1.96"
[workspace.dependencies]
# Local Proton SDK crates (path deps until published; switch to versioned later).
proton-sdk = "0.1.2"
proton-drive-rs = "0.1.2"
proton-sdk = "0.1.3"
proton-drive-rs = "0.1.3"
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -33,3 +33,4 @@ clap = { version = "4", features = ["derive"] }
rpassword = "7"
fuser = "0.17"
libc = "0.2"
+34
View File
@@ -119,6 +119,8 @@ enum Command {
/// Destination folder path, inside the mountpoint or relative to it.
parent: PathBuf,
},
/// Show the daemon's in-flight transfers (active uploads/downloads).
Transfers,
}
fn main() -> Result<()> {
@@ -151,6 +153,7 @@ fn main() -> Result<()> {
Command::Rm { path } => cmd_rm(path),
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
Command::Upload { file, parent } => cmd_upload(file, parent),
Command::Transfers => cmd_transfers(),
}
}
@@ -366,6 +369,37 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
Ok(())
}
fn cmd_transfers() -> Result<()> {
use pdfs_core::control::TransferDirection;
match control_request(CtlRequest::GetQueueStatus)? {
CtlResponse::Transfers { items } if items.is_empty() => println!("No active transfers."),
CtlResponse::Transfers { items } => {
for t in items {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
let pct = if t.bytes_total > 0 {
format!(
"{:.0}%",
100.0 * t.bytes_completed as f64 / t.bytes_total as f64
)
} else {
"—".to_string()
};
let speed = t.speed_bytes_sec as f64 / 1_048_576.0;
println!(
"{arrow} {:>4} {:>8} / {:>8} {speed:>6.2} MiB/s {}",
pct, t.bytes_completed, t.bytes_total, t.name
);
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_pins() -> Result<()> {
match control_request(CtlRequest::ListPins)? {
CtlResponse::Pins { pins } if pins.is_empty() => println!("No pinned files."),
+30
View File
@@ -89,6 +89,34 @@ pub enum Request {
/// persist it to config so the next mount keeps it. Replies with
/// [`Response::Ok`].
SetCacheBudget { bytes: u64 },
/// Snapshot the daemon's in-flight transfers (active uploads/downloads).
/// Replies with [`Response::Transfers`]. Cheap to poll: the daemon keeps the
/// registry in memory, so a front-end can render a live progress widget.
GetQueueStatus,
}
/// Which way an active transfer is moving, in a [`TransferItem`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferDirection {
Download,
Upload,
}
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TransferItem {
/// Node uid in `volume~link` form (empty for an upload whose uid isn't known
/// until the draft is sealed).
pub uid: String,
/// File name being transferred.
pub name: String,
pub direction: TransferDirection,
/// Bytes moved so far.
pub bytes_completed: u64,
/// Total bytes expected, or `0` when unknown (indeterminate progress).
pub bytes_total: u64,
/// Average throughput since the transfer began, bytes per second.
pub speed_bytes_sec: u64,
}
/// One entry in a [`Request::ListDir`] listing.
@@ -178,6 +206,8 @@ pub enum Response {
FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]).
SearchResults { hits: Vec<SearchHit> },
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
Transfers { items: Vec<TransferItem> },
/// The request failed.
Error { message: String },
}
+109 -19
View File
@@ -52,14 +52,19 @@ use pdfs_core::cache::{BLOCK_SIZE, ContentCache};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
DirEntry, PhotoItem, Request as CtlRequest, Response as CtlResponse, SearchHit,
TransferDirection,
};
use pdfs_core::db::{Db, StoredNode};
use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient,
ProtonPhotosClient, ThumbnailType,
};
mod transfers;
use tracing::{debug, error, info, warn};
use transfers::{CountingReader, CountingWriter, TransferRegistry};
/// Attribute/entry cache lifetime handed back to the kernel. Long because the
/// Phase 2 event poller actively invalidates changed inodes; without a remote
@@ -293,6 +298,9 @@ struct Core {
/// requests slice memory instead of re-fetching the whole timeline per page.
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`].
timeline: Arc<Mutex<Option<TimelineCache>>>,
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
/// across the FUSE session and the control-socket task.
transfers: Arc<TransferRegistry>,
}
/// The whole photos timeline plus when it was fetched, for TTL freshness.
@@ -681,6 +689,17 @@ impl Core {
error!(%uid, error = %e, "reopen scratch file failed");
Errno::EIO
})?;
let name = {
let st = self.state.lock().unwrap();
st.entries
.get(&ino)
.map(|e| e.node.name.clone())
.unwrap_or_default()
};
let guard = self
.transfers
.begin(name, uid.to_string(), TransferDirection::Upload, len);
let reader = CountingReader::new(reader, &guard);
self.rt
.block_on(self.client.upload_new_revision_from(
&uid,
@@ -693,6 +712,7 @@ impl Core {
error!(%uid, error = %e, "upload new revision failed");
Errno::EIO
})?;
drop(guard);
let now = now_secs();
{
let mut st = self.state.lock().unwrap();
@@ -719,6 +739,47 @@ impl Core {
Ok(())
}
/// Download a whole file's plaintext, registering the transfer so
/// `GetQueueStatus` can report its progress. `total` is the expected size
/// (`0` if unknown). Streams through [`download_file_to`] so each block ticks
/// the progress counter.
///
/// [`download_file_to`]: ProtonDriveClient::download_file_to
fn download_file_tracked(
&self,
uid: &NodeUid,
name: &str,
total: u64,
) -> std::result::Result<Vec<u8>, ProtonError> {
let guard = self
.transfers
.begin(name, uid.to_string(), TransferDirection::Download, total);
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
self.rt
.block_on(self.client.download_file_to(uid, &mut out))?;
Ok(out.into_inner())
}
/// Like [`download_file_tracked`] for a photo, streaming through the photos
/// client's [`download_photo_to`].
///
/// [`download_file_tracked`]: Core::download_file_tracked
/// [`download_photo_to`]: ProtonPhotosClient::download_photo_to
fn download_photo_tracked(
&self,
photos: &ProtonPhotosClient,
uid: &NodeUid,
name: &str,
total: u64,
) -> std::result::Result<Vec<u8>, ProtonError> {
let guard = self
.transfers
.begin(name, uid.to_string(), TransferDirection::Download, total);
let mut out = CountingWriter::new(Vec::with_capacity(total as usize), &guard);
self.rt.block_on(photos.download_photo_to(uid, &mut out))?;
Ok(out.into_inner())
}
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and
/// downloads every descendant file (selective sync). Returns a human message.
@@ -746,8 +807,7 @@ impl Core {
return Ok(format!("{name} ({n} files)"));
}
let bytes = self
.rt
.block_on(self.client.download_file(&uid))
.download_file_tracked(&uid, &name, size)
.map_err(|e| format!("download: {e}"))?;
self.cache
.store(&uid, mtime, size, &bytes)
@@ -763,7 +823,7 @@ impl Core {
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
/// fully discovered; the lock is dropped before each network download.
fn pin_subtree(&self, ino: u64) -> Result<usize, String> {
let mut files: Vec<(NodeUid, i64, u64)> = Vec::new();
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
self.ensure_children(dir)
@@ -777,6 +837,7 @@ impl Core {
} else {
files.push((
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
));
@@ -786,12 +847,12 @@ impl Core {
}
}
let mut count = 0;
for (uid, mtime, size) in files {
for (uid, name, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.rt.block_on(self.client.download_file(&uid)) {
match self.download_file_tracked(&uid, &name, size) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
@@ -1050,8 +1111,7 @@ impl Core {
return Ok(p);
}
let bytes = self
.rt
.block_on(photos.download_photo(uid))
.download_photo_tracked(&photos, uid, &node.name, size)
.map_err(|e| format!("download photo: {e}"))?;
self.cache
.store(uid, mtime, size, &bytes)
@@ -1067,20 +1127,23 @@ impl Core {
let (ino, uid) = self
.resolve_path(rel)
.map_err(|e| format!("resolve path: {e:?}"))?;
let (mtime, size) = {
let (name, mtime, size) = {
let st = self.state.lock().unwrap();
let e = st.entries.get(&ino).ok_or("node vanished")?;
if !e.node.is_file() {
return Err("not a regular file".into());
}
(e.node.modification_time, node_size(&e.node))
(
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
)
};
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
return Ok(p);
}
let bytes = self
.rt
.block_on(self.client.download_file(&uid))
.download_file_tracked(&uid, &name, size)
.map_err(|e| format!("download: {e}"))?;
self.cache
.store(&uid, mtime, size, &bytes)
@@ -1206,13 +1269,24 @@ impl Core {
.map_err(|e| format!("resolve path: {e:?}"))?;
self.ensure_children(pino)
.map_err(|e| format!("enumerate: {e:?}"))?;
let guard = self
.transfers
.begin(name, "", TransferDirection::Upload, bytes.len() as u64);
let reader = CountingReader::new(std::io::Cursor::new(bytes), &guard);
let new_uid = self
.rt
.block_on(
self.client
.upload_file(&parent_uid, name, media_type_for(name), bytes),
)
.block_on(self.client.upload_file_from(
&parent_uid,
name,
media_type_for(name),
reader,
bytes.len() as i64,
Vec::new(),
None,
false,
))
.map_err(|e| format!("upload: {e}"))?;
drop(guard);
let node = self
.fetch_node(&new_uid)
.map_err(|e| format!("fetch node: {e:?}"))?;
@@ -2322,10 +2396,22 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
capture_time,
..Default::default()
};
match core
.rt
.block_on(photos.upload_photo(&name, &media_type, &bytes, metadata))
{
let guard = core.transfers.begin(
name.clone(),
"",
TransferDirection::Upload,
bytes.len() as u64,
);
let reader = CountingReader::new(std::io::Cursor::new(&bytes), &guard);
match core.rt.block_on(photos.upload_photo_from(
&name,
&media_type,
reader,
bytes.len() as i64,
Vec::new(),
metadata,
false,
)) {
Ok(uid) => {
*core.timeline.lock().unwrap() = None;
CtlResponse::Ok {
@@ -2413,6 +2499,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
),
}
}
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
items: core.transfers.snapshot(),
},
Ok(CtlRequest::SetCacheBudget { bytes }) => {
core.cache.set_budget(bytes);
// Persist so the next mount keeps the new cap. Best-effort: a config
@@ -2539,6 +2628,7 @@ pub fn mount(
cache: Arc::new(cache),
db,
timeline: Arc::new(Mutex::new(None)),
transfers: TransferRegistry::new(),
};
let mut config = Config::default();
+169
View File
@@ -0,0 +1,169 @@
//! In-memory registry of in-flight transfers, behind [`Request::GetQueueStatus`].
//!
//! The daemon downloads and uploads whole files through the SDK's streaming
//! [`Read`]/[`Write`] variants. Wrapping that reader/writer in a
//! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a
//! per-transfer counter without the SDK knowing anything about progress. A
//! [`TransferGuard`] registers a transfer on creation and deregisters it on
//! drop, so the registry always reflects exactly what is in flight — even if a
//! transfer fails partway and unwinds.
//!
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use pdfs_core::control::{TransferDirection, TransferItem};
/// One registered transfer. `done` is bumped from the I/O wrapper without the
/// registry lock; the rest is immutable for the transfer's lifetime.
struct Entry {
name: String,
uid: String,
direction: TransferDirection,
total: u64,
done: AtomicU64,
started: Instant,
}
/// The set of transfers currently in flight. Cloned `Arc`-style across the FUSE
/// session and the control-socket task (both share one registry via [`Core`]).
#[derive(Default)]
pub struct TransferRegistry {
inner: Mutex<HashMap<u64, Arc<Entry>>>,
next: AtomicU64,
}
impl TransferRegistry {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
/// guard that tracks progress and deregisters the transfer when dropped.
pub fn begin(
self: &Arc<Self>,
name: impl Into<String>,
uid: impl Into<String>,
direction: TransferDirection,
total: u64,
) -> TransferGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(Entry {
name: name.into(),
uid: uid.into(),
direction,
total,
done: AtomicU64::new(0),
started: Instant::now(),
});
self.inner.lock().unwrap().insert(id, entry.clone());
TransferGuard {
reg: self.clone(),
id,
entry,
}
}
/// Snapshot every in-flight transfer for [`Response::Transfers`]. Speed is the
/// running average since the transfer began — simple, and stable enough for a
/// progress widget without per-tick sampling state.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn snapshot(&self) -> Vec<TransferItem> {
let map = self.inner.lock().unwrap();
map.values()
.map(|e| {
let done = e.done.load(Ordering::Relaxed);
let secs = e.started.elapsed().as_secs_f64();
let speed = if secs > 0.0 {
(done as f64 / secs) as u64
} else {
0
};
TransferItem {
uid: e.uid.clone(),
name: e.name.clone(),
direction: e.direction,
bytes_completed: done,
bytes_total: e.total,
speed_bytes_sec: speed,
}
})
.collect()
}
}
/// Lifetime handle for a registered transfer: tick progress with [`add`], and
/// the transfer leaves the registry when this drops.
///
/// [`add`]: TransferGuard::add
pub struct TransferGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<Entry>,
}
impl TransferGuard {
/// Record `n` more bytes moved.
pub fn add(&self, n: u64) {
self.entry.done.fetch_add(n, Ordering::Relaxed);
}
}
impl Drop for TransferGuard {
fn drop(&mut self) {
self.reg.inner.lock().unwrap().remove(&self.id);
}
}
/// A [`Write`] that tallies bytes written to a [`TransferGuard`] (download path).
pub struct CountingWriter<'a, W> {
inner: W,
guard: &'a TransferGuard,
}
impl<'a, W: Write> CountingWriter<'a, W> {
pub fn new(inner: W, guard: &'a TransferGuard) -> Self {
Self { inner, guard }
}
pub fn into_inner(self) -> W {
self.inner
}
}
impl<W: Write> Write for CountingWriter<'_, W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.guard.add(n as u64);
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
pub struct CountingReader<'a, R> {
inner: R,
guard: &'a TransferGuard,
}
impl<'a, R: Read> CountingReader<'a, R> {
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
Self { inner, guard }
}
}
impl<R: Read> Read for CountingReader<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
Ok(n)
}
}
+4
View File
@@ -14,6 +14,10 @@ path = "src/main.rs"
name = "pdfs-app"
path = "src/app.rs"
[[bin]]
name = "pdfs-prompt"
path = "src/prompt.rs"
[dependencies]
pdfs-core.workspace = true
tracing.workspace = true
+127 -1
View File
@@ -32,7 +32,9 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, SearchHit, send};
use pdfs_core::control::{
DirEntry, PhotoItem, Request, Response, SearchHit, TransferDirection, TransferItem, send,
};
use pdfs_core::service;
/// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -61,6 +63,15 @@ struct PinRow {
unpin: Option<gtk4::Button>,
}
/// One rendered transfer row in the Activity group: a name + speed line over a
/// progress bar. Retained so [`repaint_transfers`] can update the bar and label
/// in place each tick when the active set is unchanged, instead of rebuilding.
struct TransferRow {
row: adw::PreferencesRow,
label: gtk4::Label,
bar: gtk4::ProgressBar,
}
/// 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
/// closure share one instance.
@@ -81,6 +92,12 @@ struct Ui {
/// 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>,
/// Guards the [`Request::GetQueueStatus`] poll the same way: at most one
/// in-flight at a time so a wedged daemon can't stack worker threads.
transfers_inflight: Cell<bool>,
/// Activity group + its current rows, hidden when no transfer is in flight.
transfers_group: adw::PreferencesGroup,
transfer_rows: RefCell<Vec<TransferRow>>,
// Login page.
email: adw::EntryRow,
password: adw::PasswordEntryRow,
@@ -278,6 +295,9 @@ fn build_window(app: &adw::Application) {
login_status: login_widgets.3,
account_row: main_widgets.account_row.clone(),
mount_row: main_widgets.mount_row.clone(),
transfers_group: main_widgets.transfers_group.clone(),
transfer_rows: RefCell::new(Vec::new()),
transfers_inflight: Cell::new(false),
cache_bar: main_widgets.cache_bar.clone(),
cache_label: main_widgets.cache_label.clone(),
autostart_row: main_widgets.autostart_row.clone(),
@@ -441,6 +461,8 @@ fn build_login_page() -> (
struct MainWidgets {
account_row: adw::ActionRow,
mount_row: adw::ActionRow,
/// Live upload/download progress, populated by the refresh loop.
transfers_group: adw::PreferencesGroup,
cache_bar: gtk4::ProgressBar,
cache_label: gtk4::Label,
pins_group: adw::PreferencesGroup,
@@ -483,6 +505,14 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) {
.build();
mount_group.add(&mount_row);
// Activity group: live upload/download progress. Hidden until the refresh
// loop sees an in-flight transfer from `Request::GetQueueStatus`.
let transfers_group = adw::PreferencesGroup::builder()
.title("Activity")
.description("Files moving to and from Proton Drive.")
.visible(false)
.build();
// Storage group: a progress bar + "X of Y used" label, plus the cache-budget
// editor and a purge button. `budget_row`/`purge_button` are wired in
// `wire_settings`; the bar + label are repainted by the refresh loop.
@@ -575,6 +605,7 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) {
inner.set_margin_end(12);
inner.append(&account_group);
inner.append(&mount_group);
inner.append(&transfers_group);
inner.append(&storage_group);
inner.append(&system_group);
inner.append(&pins_group);
@@ -591,6 +622,7 @@ fn build_main_page() -> (gtk4::Widget, MainWidgets) {
MainWidgets {
account_row,
mount_row,
transfers_group,
cache_bar,
cache_label,
pins_group,
@@ -937,6 +969,100 @@ fn refresh(ui: &Rc<Ui>) {
}
refresh_status(ui);
refresh_transfers(ui);
}
/// Poll the daemon's in-flight transfers on a worker thread and repaint the
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
/// two cheap polls on the 2s tick don't gate each other. The group hides itself
/// when nothing is moving, so an idle account shows no Activity section.
fn refresh_transfers(ui: &Rc<Ui>) {
if ui.transfers_inflight.get() {
return;
}
ui.transfers_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.transfers_inflight.set(false);
match result {
Ok(Ok(Response::Transfers { items })) => repaint_transfers(&ui, &items),
// Daemon unreachable or odd reply: clear the section rather than
// leave stale progress bars frozen on screen.
_ => repaint_transfers(&ui, &[]),
}
});
}
/// Render the Activity group from `items`. Rebuilds the rows only when the set of
/// transfers changes (count differs); on the common steady tick it updates each
/// bar's fraction and the name/speed label in place, so progress animates without
/// flicker. Hides the whole group when nothing is in flight.
fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) {
if items.is_empty() {
if !ui.transfer_rows.borrow().is_empty() {
for tr in ui.transfer_rows.borrow_mut().drain(..) {
ui.transfers_group.remove(&tr.row);
}
}
ui.transfers_group.set_visible(false);
return;
}
ui.transfers_group.set_visible(true);
// Rebuild rows only when the count changes; otherwise reuse them in place.
if ui.transfer_rows.borrow().len() != items.len() {
for tr in ui.transfer_rows.borrow_mut().drain(..) {
ui.transfers_group.remove(&tr.row);
}
for _ in items {
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
row_box.set_margin_top(8);
row_box.set_margin_bottom(8);
let label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
label.add_css_class("dim-label");
let bar = gtk4::ProgressBar::new();
row_box.append(&label);
row_box.append(&bar);
let row = adw::PreferencesRow::builder()
.activatable(false)
.child(&row_box)
.build();
ui.transfers_group.add(&row);
ui.transfer_rows
.borrow_mut()
.push(TransferRow { row, label, bar });
}
}
for (item, tr) in items.iter().zip(ui.transfer_rows.borrow().iter()) {
let arrow = match item.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
if item.bytes_total == 0 {
// Unknown size: pulse instead of a fraction, and omit the percentage.
tr.bar.pulse();
tr.label.set_text(&format!(
"{arrow} {} — {} ({}/s)",
item.name,
human_bytes(item.bytes_completed),
human_bytes(item.speed_bytes_sec),
));
} else {
let fraction = (item.bytes_completed as f64 / item.bytes_total as f64).min(1.0);
tr.bar.set_fraction(fraction);
tr.label.set_text(&format!(
"{arrow} {} — {} of {} ({}/s)",
item.name,
human_bytes(item.bytes_completed),
human_bytes(item.bytes_total),
human_bytes(item.speed_bytes_sec),
));
}
}
}
/// Fetch mount status + cache stats from the daemon on a worker thread and repaint
+60 -2
View File
@@ -18,7 +18,7 @@ use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, send};
use pdfs_core::control::{Request, Response, TransferDirection, TransferItem, send};
use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu.
@@ -33,6 +33,42 @@ struct DriveState {
mounted: bool,
/// Mountpoint to act on (from the daemon when mounted, else the default).
mountpoint: PathBuf,
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
/// disabled menu line under the status line.
sync: String,
}
/// Summarise a transfer snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth.
fn sync_line(items: &[TransferItem]) -> String {
match items {
[] => String::new(),
[t] => {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
if t.bytes_total == 0 {
format!("{arrow} {}…", t.name)
} else {
let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100);
format!("{arrow} {} ({pct}%)", t.name)
}
}
_ => {
let down = items
.iter()
.filter(|t| t.direction == TransferDirection::Download)
.count();
let up = items.len() - down;
match (down, up) {
(d, 0) => format!("↓ {d} downloading"),
(0, u) => format!("↑ {u} uploading"),
(d, u) => format!("↓ {d} · ↑ {u}"),
}
}
}
}
struct DriveTray {
@@ -49,12 +85,18 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
mounted: true,
mountpoint: PathBuf::from(mountpoint),
// Same daemon is up, so a cheap follow-up poll gives the sync line.
sync: match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items }) => sync_line(&items),
_ => String::new(),
},
},
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => DriveState {
line: "Mount: unexpected daemon response".into(),
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
},
// No daemon: describe login state instead so the menu is still useful.
Err(_) => {
@@ -67,6 +109,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
line,
mounted: false,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
}
}
}
@@ -116,6 +159,21 @@ impl Tray for DriveTray {
..Default::default()
}
.into(),
];
// Live sync status, shown only while something is transferring.
if !self.state.sync.is_empty() {
items.push(
StandardItem {
label: self.state.sync.clone(),
enabled: false,
..Default::default()
}
.into(),
);
}
items.extend([
MenuItem::Separator,
StandardItem {
label: "Open Manager".into(),
@@ -123,7 +181,7 @@ impl Tray for DriveTray {
..Default::default()
}
.into(),
];
]);
if self.state.mounted {
items.push(
+716
View File
@@ -0,0 +1,716 @@
//! `pdfs-prompt` — a fast, Google Drive-style launcher prompt for searching
//! and opening files or folders in Proton Drive.
//!
//! Designed to be bound to a system shortcut (e.g. in Hyprland) for quick HUD search.
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc;
use std::time::Duration;
use adw::prelude::*;
use gtk4::glib;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, SearchHit, send};
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
const PROTON_PURPLE: &str = "#6d4aff";
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150);
/// Run one blocking control-socket round-trip on a worker thread, returning a
/// channel that yields the [`Response`] once.
fn spawn_request(
socket: PathBuf,
req: Request,
) -> async_channel::Receiver<Result<Response, String>> {
let (tx, rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let result = send(&socket, &req).map_err(|e| e.to_string());
let _ = tx.send_blocking(result);
});
rx
}
fn main() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_startup(|_| {
load_prompt_theme();
});
app.connect_activate(build_window);
app.run()
}
/// Override accent colors and style the launcher for a premium HUD look.
fn load_prompt_theme() {
let css = format!(
"@define-color accent_bg_color {PROTON_PURPLE};\n\
@define-color accent_color {PROTON_PURPLE};\n\
\n\
window.launcher-window {{\n\
background-color: transparent;\n\
}}\n\
\n\
.launcher-container {{\n\
background-color: @window_bg_color;\n\
border: 2px solid {PROTON_PURPLE};\n\
border-radius: 12px;\n\
padding: 12px;\n\
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);\n\
}}\n\
\n\
.launcher-header-box {{\n\
padding: 6px 12px;\n\
background-color: alpha(@view_bg_color, 0.4);\n\
border-radius: 8px;\n\
border: 1px solid alpha({PROTON_PURPLE}, 0.2);\n\
}}\n\
\n\
.launcher-entry {{\n\
font-size: 1.15rem;\n\
background: transparent;\n\
border: none;\n\
box-shadow: none;\n\
}}\n\
\n\
.launcher-listbox {{\n\
background: transparent;\n\
}}\n\
\n\
.launcher-row {{\n\
padding: 10px 14px;\n\
border-radius: 6px;\n\
margin: 2px 0px;\n\
transition: background-color 0.15s ease, color 0.15s ease;\n\
}}\n\
\n\
.launcher-row:hover {{\n\
background-color: alpha({PROTON_PURPLE}, 0.1);\n\
}}\n\
\n\
.launcher-row:selected {{\n\
background-color: {PROTON_PURPLE};\n\
color: white;\n\
}}\n\
\n\
.launcher-row:selected .dim-label {{\n\
color: alpha(white, 0.7);\n\
}}\n\
\n\
.launcher-footer {{\n\
padding: 6px 12px 0px 12px;\n\
font-size: 0.85rem;\n\
border-top: 1px solid alpha({PROTON_PURPLE}, 0.1);\n\
}}\n\
\n\
.dim-label {{\n\
color: @view_fg_color;\n\
opacity: 0.6;\n\
font-size: 0.85rem;\n\
}}\n\
\n\
.error-box {{\n\
padding: 24px;\n\
}}\n\
\n\
.error-title {{\n\
font-size: 1.25rem;\n\
font-weight: bold;\n\
margin-bottom: 8px;\n\
}}\n"
);
let provider = gtk4::CssProvider::new();
provider.load_from_string(&css);
if let Some(display) = gtk4::gdk::Display::default() {
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn build_window(app: &adw::Application) {
let dirs = match AppDirs::new() {
Ok(d) => d,
Err(e) => {
tracing::error!("cannot resolve app dirs: {e}");
return;
}
};
let socket = dirs.control_socket();
let default_mountpoint = dirs.default_mountpoint();
let window = adw::ApplicationWindow::builder()
.application(app)
.title("Proton Drive Launcher")
.default_width(650)
.default_height(450)
.decorated(false)
.build();
window.add_css_class("launcher-window");
// Prefer dark mode for high-tech HUD look
let style_manager = adw::StyleManager::default();
style_manager.set_color_scheme(adw::ColorScheme::PreferDark);
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
container.add_css_class("launcher-container");
window.set_content(Some(&container));
// Header box: search icon, entry field, and loading spinner
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header_box.add_css_class("launcher-header-box");
let search_icon = gtk4::Image::from_icon_name("system-search-symbolic");
header_box.append(&search_icon);
let entry = gtk4::Entry::builder()
.placeholder_text("Search Proton Drive...")
.hexpand(true)
.build();
entry.add_css_class("launcher-entry");
header_box.append(&entry);
let spinner = gtk4::Spinner::new();
spinner.set_visible(false);
header_box.append(&spinner);
container.append(&header_box);
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
container.append(&separator);
// View stack to transition between results list and warning page
let stack = adw::ViewStack::new();
container.append(&stack);
// Page 1: Scrolled window and listbox for results
let list_box = gtk4::ListBox::new();
list_box.add_css_class("launcher-listbox");
let scrolled_window = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.child(&list_box)
.build();
stack.add_named(&scrolled_window, Some("search"));
// Page 2: Error screen if daemon is offline
let error_box = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
error_box.add_css_class("error-box");
error_box.set_valign(gtk4::Align::Center);
error_box.set_halign(gtk4::Align::Center);
let error_icon = gtk4::Image::from_icon_name("dialog-warning-symbolic");
error_icon.set_pixel_size(48);
error_icon.add_css_class("brand-icon");
error_box.append(&error_icon);
let error_title = gtk4::Label::builder()
.label("Daemon Offline")
.justify(gtk4::Justification::Center)
.build();
error_title.add_css_class("error-title");
error_box.append(&error_title);
let error_desc = gtk4::Label::builder()
.label("Proton Drive mount daemon is not running.\nStart the daemon or run `pdfs mount` to connect.")
.justify(gtk4::Justification::Center)
.build();
error_desc.add_css_class("dim-label");
error_box.append(&error_desc);
let retry_button = gtk4::Button::builder()
.label("Retry Connection")
.halign(gtk4::Align::Center)
.build();
retry_button.add_css_class("suggested-action");
retry_button.add_css_class("pill");
error_box.append(&retry_button);
stack.add_named(&error_box, Some("error"));
// Footer box: status label
let footer_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
footer_box.add_css_class("launcher-footer");
let status_label = gtk4::Label::builder()
.label("Initialising...")
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.hexpand(true)
.build();
status_label.add_css_class("dim-label");
footer_box.append(&status_label);
container.append(&footer_box);
// Shared state variables
let hits = Rc::new(RefCell::new(Vec::<SearchHit>::new()));
let mountpoint = Rc::new(RefCell::new(default_mountpoint.display().to_string()));
let is_opening = Rc::new(Cell::new(false));
// Handle retry on connection failure
let stack_clone = stack.clone();
let socket_clone = socket.clone();
let mountpoint_clone = mountpoint.clone();
let status_label_clone = status_label.clone();
let spinner_clone = spinner.clone();
let list_box_clone = list_box.clone();
let hits_clone = hits.clone();
let entry_clone = entry.clone();
let check_connection = move || {
status_label_clone.set_text("Connecting to daemon...");
match send(&socket_clone, &Request::Status) {
Ok(Response::Status { mountpoint: mp, .. }) => {
*mountpoint_clone.borrow_mut() = mp;
stack_clone.set_visible_child_name("search");
entry_clone.set_sensitive(true);
load_initial_pins(
&list_box_clone,
&hits_clone,
&socket_clone,
&status_label_clone,
&spinner_clone,
);
true
}
_ => {
stack_clone.set_visible_child_name("error");
entry_clone.set_sensitive(false);
status_label_clone.set_text("Daemon offline.");
false
}
}
};
let check_conn = check_connection.clone();
retry_button.connect_clicked(move |_| {
check_conn();
});
window.connect_is_active_notify(move |w| {
if !w.is_active() {
w.close();
}
});
// Auto-focus search entry on window show
let entry_focus = entry.clone();
window.connect_map(move |_| {
entry_focus.grab_focus();
});
// Event key controller on entry field for keyboard shortcuts
let key_controller = gtk4::EventControllerKey::new();
let list_box_keys = list_box.clone();
let window_keys = window.clone();
let entry_keys = entry.clone();
let hits_keys = hits.clone();
let is_opening_keys = is_opening.clone();
key_controller.connect_key_pressed(move |_, key, _keycode, _state| {
if is_opening_keys.get() {
return glib::Propagation::Stop;
}
match key {
gtk4::gdk::Key::Escape => {
window_keys.close();
glib::Propagation::Stop
}
gtk4::gdk::Key::Down => {
let count = hits_keys.borrow().len();
if count > 0 {
let selected = list_box_keys.selected_row();
let next_idx = if let Some(row) = selected {
(row.index() + 1) % count as i32
} else {
0
};
if let Some(row) = list_box_keys.row_at_index(next_idx) {
list_box_keys.select_row(Some(&row));
row.grab_focus();
entry_keys.grab_focus();
}
}
glib::Propagation::Stop
}
gtk4::gdk::Key::Up => {
let count = hits_keys.borrow().len();
if count > 0 {
let selected = list_box_keys.selected_row();
let next_idx = if let Some(row) = selected {
(row.index() - 1 + count as i32) % count as i32
} else {
(count - 1) as i32
};
if let Some(row) = list_box_keys.row_at_index(next_idx) {
list_box_keys.select_row(Some(&row));
row.grab_focus();
entry_keys.grab_focus();
}
}
glib::Propagation::Stop
}
_ => glib::Propagation::Proceed,
}
});
entry.add_controller(key_controller);
// Entry activation (pressing Return/Enter)
let list_box_activate = list_box.clone();
let hits_activate = hits.clone();
let window_activate = window.clone();
let socket_activate = socket.clone();
let mountpoint_activate = mountpoint.clone();
let status_label_activate = status_label.clone();
let spinner_activate = spinner.clone();
let is_opening_activate = is_opening.clone();
entry.connect_activate(move |_| {
if is_opening_activate.get() {
return;
}
if let Some(row) = list_box_activate.selected_row() {
let idx = row.index() as usize;
let hits_borrow = hits_activate.borrow();
if idx < hits_borrow.len() {
let hit = &hits_borrow[idx];
open_hit(
&window_activate,
hit,
&socket_activate,
&mountpoint_activate,
&status_label_activate,
&spinner_activate,
is_opening_activate.clone(),
);
}
}
});
// List box row activation (mouse clicks / touch taps)
let hits_act = hits.clone();
let window_act = window.clone();
let socket_act = socket.clone();
let mountpoint_act = mountpoint.clone();
let status_label_act = status_label.clone();
let spinner_act = spinner.clone();
let is_opening_act = is_opening.clone();
list_box.connect_row_activated(move |_, row| {
if is_opening_act.get() {
return;
}
let idx = row.index() as usize;
let hits_borrow = hits_act.borrow();
if idx < hits_borrow.len() {
let hit = &hits_borrow[idx];
open_hit(
&window_act,
hit,
&socket_act,
&mountpoint_act,
&status_label_act,
&spinner_act,
is_opening_act.clone(),
);
}
});
// Debounced search text entry listener
let search_source = Rc::new(RefCell::new(None::<glib::SourceId>));
let search_source_changed = search_source.clone();
let list_box_search = list_box.clone();
let hits_search = hits.clone();
let socket_search = socket.clone();
let status_label_search = status_label.clone();
let spinner_search = spinner.clone();
entry.connect_changed(move |ent| {
if let Some(src) = search_source_changed.borrow_mut().take() {
src.remove();
}
let ent_clone = ent.clone();
let list_box_clone = list_box_search.clone();
let hits_clone = hits_search.clone();
let socket_clone = socket_search.clone();
let status_label_clone = status_label_search.clone();
let spinner_clone = spinner_search.clone();
let search_source_cb = search_source_changed.clone();
let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || {
search_source_cb.borrow_mut().take();
let query = ent_clone.text().trim().to_string();
if query.is_empty() {
load_initial_pins(
&list_box_clone,
&hits_clone,
&socket_clone,
&status_label_clone,
&spinner_clone,
);
} else {
run_prompt_search(
&list_box_clone,
&hits_clone,
&socket_clone,
&query,
&status_label_clone,
&spinner_clone,
);
}
});
*search_source_changed.borrow_mut() = Some(src);
});
// Perform the initial connection and load check
check_connection();
window.present();
}
/// Retrieve pinned files from the daemon and render them in the results list.
fn load_initial_pins(
list_box: &gtk4::ListBox,
hits: &Rc<RefCell<Vec<SearchHit>>>,
socket: &Path,
status_label: &gtk4::Label,
spinner: &gtk4::Spinner,
) {
status_label.set_text("Loading pinned files...");
spinner.set_visible(true);
spinner.start();
let rx = spawn_request(socket.to_path_buf(), Request::ListPins);
let list_box = list_box.clone();
let hits = hits.clone();
let status_label = status_label.clone();
let spinner = spinner.clone();
glib::spawn_future_local(async move {
let res = rx.recv().await;
spinner.stop();
spinner.set_visible(false);
match res {
Ok(Ok(Response::Pins { pins })) => {
let mapped_hits: Vec<SearchHit> = pins
.into_iter()
.map(|p| {
let path_obj = Path::new(&p.path);
let name = path_obj
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&p.path)
.to_string();
SearchHit {
name,
path: p.path,
is_dir: p.recursive,
size: 0,
modified: 0,
pinned: true,
uid: p.uid,
}
})
.collect();
repaint_prompt_results(&list_box, &hits, &mapped_hits);
if mapped_hits.is_empty() {
status_label.set_text("No pinned files. Type to search.");
} else {
status_label.set_text("Showing pinned files. Type to search.");
}
}
_ => {
status_label.set_text("Failed to load pinned files. Type to search.");
}
}
});
}
/// Run search on the daemon and update the results list.
fn run_prompt_search(
list_box: &gtk4::ListBox,
hits: &Rc<RefCell<Vec<SearchHit>>>,
socket: &Path,
query: &str,
status_label: &gtk4::Label,
spinner: &gtk4::Spinner,
) {
status_label.set_text("Searching...");
spinner.set_visible(true);
spinner.start();
let rx = spawn_request(
socket.to_path_buf(),
Request::Search {
query: query.to_string(),
limit: 30,
},
);
let list_box = list_box.clone();
let hits = hits.clone();
let status_label = status_label.clone();
let spinner = spinner.clone();
glib::spawn_future_local(async move {
let res = rx.recv().await;
spinner.stop();
spinner.set_visible(false);
match res {
Ok(Ok(Response::SearchResults { hits: search_hits })) => {
repaint_prompt_results(&list_box, &hits, &search_hits);
if search_hits.is_empty() {
status_label.set_text("No matches found.");
} else {
status_label.set_text(&format!("Found {} matches.", search_hits.len()));
}
}
Ok(Ok(Response::Error { message })) => {
status_label.set_text(&format!("Search error: {message}"));
}
_ => {
status_label.set_text("Search request failed.");
}
}
});
}
/// Render the provided search hits into the ListBox.
fn repaint_prompt_results(
list_box: &gtk4::ListBox,
hits: &Rc<RefCell<Vec<SearchHit>>>,
new_hits: &[SearchHit],
) {
// Clear all rows
while let Some(child) = list_box.first_child() {
list_box.remove(&child);
}
*hits.borrow_mut() = new_hits.to_vec();
for hit in new_hits {
let row = gtk4::ListBoxRow::new();
row.add_css_class("launcher-row");
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
let icon_name = if hit.is_dir {
"folder-symbolic"
} else {
"document-symbolic"
};
let icon = gtk4::Image::from_icon_name(icon_name);
row_box.append(&icon);
let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
text_box.set_hexpand(true);
let name_label = gtk4::Label::builder()
.label(&hit.name)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let path_label = gtk4::Label::builder()
.label(&hit.path)
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
path_label.add_css_class("dim-label");
text_box.append(&name_label);
text_box.append(&path_label);
row_box.append(&text_box);
row.set_child(Some(&row_box));
list_box.append(&row);
}
// Automatically select the first item
if !new_hits.is_empty()
&& let Some(row) = list_box.row_at_index(0) {
list_box.select_row(Some(&row));
}
}
/// Open the selected search hit. Directories open FUSE directly, files trigger cache download.
fn open_hit(
window: &adw::ApplicationWindow,
hit: &SearchHit,
socket: &Path,
mountpoint: &Rc<RefCell<String>>,
status_label: &gtk4::Label,
spinner: &gtk4::Spinner,
is_opening: Rc<Cell<bool>>,
) {
if hit.is_dir {
let mp = mountpoint.borrow();
let full_path = Path::new(&*mp).join(&hit.path);
tracing::info!("Opening directory: {}", full_path.display());
if let Err(e) = Command::new("xdg-open").arg(&full_path).spawn() {
tracing::error!("Failed to xdg-open directory: {e}");
}
window.close();
} else {
is_opening.set(true);
status_label.set_text(&format!("Downloading {}...", hit.name));
spinner.set_visible(true);
spinner.start();
let rx = spawn_request(
socket.to_path_buf(),
Request::OpenFile {
path: hit.path.clone(),
},
);
let window_clone = window.clone();
let status_label_clone = status_label.clone();
let spinner_clone = spinner.clone();
let is_opening_clone = is_opening.clone();
glib::spawn_future_local(async move {
let res = rx.recv().await;
spinner_clone.stop();
spinner_clone.set_visible(false);
is_opening_clone.set(false);
match res {
Ok(Ok(Response::FilePath { path })) => {
tracing::info!("Opening file: {}", path);
if let Err(e) = Command::new("xdg-open").arg(&path).spawn() {
tracing::error!("Failed to xdg-open file: {e}");
}
window_clone.close();
}
Ok(Ok(Response::Error { message })) => {
status_label_clone.set_text(&format!("Error: {message}"));
}
_ => {
status_label_clone.set_text("Error: Failed to communicate with daemon.");
}
}
});
}
}
+3 -1
View File
@@ -43,7 +43,8 @@ build() {
cargo build --release --frozen \
--bin pdfs \
--bin pdfs-tray \
--bin pdfs-app
--bin pdfs-app \
--bin pdfs-prompt
}
package() {
@@ -53,6 +54,7 @@ package() {
install -Dm755 "$rel/pdfs" "$pkgdir/usr/bin/pdfs"
install -Dm755 "$rel/pdfs-tray" "$pkgdir/usr/bin/pdfs-tray"
install -Dm755 "$rel/pdfs-app" "$pkgdir/usr/bin/pdfs-app"
install -Dm755 "$rel/pdfs-prompt" "$pkgdir/usr/bin/pdfs-prompt"
install -Dm644 "packaging/io.narl.proton-drive-linux.desktop" \
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"