//! `pdfs-tray` — system-tray front-end for the Proton Drive Linux client. //! //! Slice 1 of the GUI phase: a StatusNotifierItem (SNI) tray icon with a menu //! showing login/mount status and offering mount, unmount, open-folder and quit. //! It talks to the running mount daemon over the same control socket the CLI //! uses, so the tray is a thin presentation layer over [`pdfs_core::control`]. //! //! GTK4 has no tray widget (GNOME dropped the systray), so the tray itself is an //! SNI item via the `ksni` crate; the GTK4/libadwaita settings window arrives in //! a later slice. use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use anyhow::{Context, Result}; use ksni::menu::StandardItem; use ksni::{MenuItem, Tray, TrayService}; use pdfs_core::auth; use pdfs_core::config::AppDirs; use pdfs_core::control::{ JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send, }; use pdfs_core::service; /// How often the tray re-polls the daemon to refresh its menu. const POLL_INTERVAL: Duration = Duration::from_secs(3); /// Snapshot of what the tray knows about the daemon, recomputed each poll. #[derive(Clone)] struct DriveState { /// Human-readable first menu line ("Mounted at …", "Not running", …). line: String, /// Whether a mount daemon is currently serving the control socket. mounted: bool, /// Mountpoint to act on (from the daemon when mounted, else the default). mountpoint: PathBuf, /// One-line sync summary of in-flight transfers, empty when idle. Shown as a /// disabled menu line under the status line. sync: String, } /// Summarise a work snapshot into one menu line, or empty when idle. A single /// transfer names the file and its percentage; several collapse to counts so the /// menu stays one line regardless of queue depth. With nothing moving bytes, a /// running job speaks for itself — a scan or an index rebuild is still "busy", /// and the tray saying nothing there reads as "finished". fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String { match items { [] => match jobs.first() { Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total), Some(j) => format!("{}…", j.title), None => String::new(), }, [t] => { let arrow = match t.direction { TransferDirection::Download => "↓", TransferDirection::Upload => "↑", }; if t.bytes_total == 0 { format!("{arrow} {}…", t.name) } else { let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100); format!("{arrow} {} ({pct}%)", t.name) } } _ => { let down = items .iter() .filter(|t| t.direction == TransferDirection::Download) .count(); let up = items.len() - down; match (down, up) { (d, 0) => format!("↓ {d} downloading"), (0, u) => format!("↑ {u} uploading"), (d, u) => format!("↓ {d} · ↑ {u}"), } } } } struct DriveTray { state: DriveState, } /// Ask the daemon for its status, falling back to the stored session so the /// menu can still say "Logged in …" / "Not logged in" when no mount is running. fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { match send(socket, &Request::Status) { Ok(Response::Status { mountpoint, pinned, online, pending_uploads, pending_changes, .. }) => DriveState { line: match (online, pending_summary(pending_uploads, pending_changes)) { (true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"), (true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"), (false, None) => format!("Offline — cached files only ({pinned} pinned)"), (false, Some(q)) => format!("Offline — {q}"), }, mounted: true, mountpoint: PathBuf::from(mountpoint), // Same daemon is up, so a cheap follow-up poll gives the sync line. sync: match send(socket, &Request::GetQueueStatus) { Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs), _ => String::new(), }, }, // Socket answered but with something unexpected — treat as up but odd. Ok(_) => DriveState { line: "Mount: unexpected daemon response".into(), mounted: true, mountpoint: default_mountpoint.to_path_buf(), sync: String::new(), }, // No daemon: describe login state instead so the menu is still useful. Err(_) => { let line = match auth::load() { Ok(s) => format!("Logged in as {} — not mounted", s.username), Err(pdfs_core::Error::NotLoggedIn) => "Not logged in".into(), Err(e) => format!("Error: {e}"), }; DriveState { line, mounted: false, mountpoint: default_mountpoint.to_path_buf(), sync: String::new(), } } } } fn open_folder(mountpoint: &Path) { pdfs_core::opener::open_default(mountpoint, true); } /// Launch (or, since it's `SingleMainWindow`, raise) the settings/management /// window. The app binary lives next to the tray binary; fall back to the bare /// name so a PATH install still works. fn open_manager() { let exe = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.join("pdfs-app"))) .filter(|p| p.exists()) .unwrap_or_else(|| PathBuf::from("pdfs-app")); if let Err(e) = Command::new(&exe).spawn() { tracing::error!("failed to launch {}: {e}", exe.display()); } } impl Tray for DriveTray { fn id(&self) -> String { "io.narl.proton-drive-linux".into() } fn title(&self) -> String { "Proton Drive".into() } fn icon_name(&self) -> String { "folder-remote".into() } fn menu(&self) -> Vec> { // Value captured into the action closure (which only gets `&mut Self`). let open_mp = self.state.mountpoint.clone(); let mut items: Vec> = vec![ StandardItem { label: self.state.line.clone(), enabled: false, ..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(), activate: Box::new(|_: &mut Self| open_manager()), ..Default::default() } .into(), ]); if self.state.mounted { items.push( StandardItem { label: "Open folder".into(), activate: Box::new(move |_: &mut Self| open_folder(&open_mp)), ..Default::default() } .into(), ); // Stop the systemd service (clean SIGTERM → lazy unmount). The next // login or reboot brings it back; this is a deliberate disconnect. items.push( StandardItem { label: "Disconnect".into(), activate: Box::new(move |this: &mut Self| { service::stop(); this.state.mounted = false; this.state.line = "Disconnecting…".into(); }), ..Default::default() } .into(), ); } else { // Enable+start the service so it mounts now and on future logins. items.push( StandardItem { label: "Connect".into(), activate: Box::new(|this: &mut Self| { service::enable_start(); this.state.line = "Connecting…".into(); }), ..Default::default() } .into(), ); } items.push(MenuItem::Separator); items.push( StandardItem { label: "Quit".into(), activate: Box::new(|_: &mut Self| std::process::exit(0)), ..Default::default() } .into(), ); items } } fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); let dirs = AppDirs::new().context("resolve app dirs")?; // Ensure only one instance of the tray runs. let tray_sock = dirs.tray_socket(); if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() { tracing::info!("Another instance of pdfs-tray is already running; exiting."); return Ok(()); } let _ = std::fs::remove_file(&tray_sock); let _lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock) .context("failed to bind tray single-instance socket")?; // Owner-only like the control socket (B6). This one only guards single // instancing, so a failure is logged rather than fatal — but there is no // reason to leave it reachable by other local users either. if let Err(e) = pdfs_core::config::restrict_socket(&tray_sock) { tracing::warn!(error = %e, "could not restrict tray socket permissions"); } let socket = dirs.control_socket(); let default_mountpoint = dirs.default_mountpoint(); let tray = DriveTray { state: poll_state(&socket, &default_mountpoint), }; let service = TrayService::new(tray); let handle = service.handle(); std::thread::spawn(move || { if let Err(e) = service.run() { tracing::error!("tray service stopped: {e:?}"); } }); // Poll the daemon forever, pushing each fresh snapshot into the tray so the // menu reflects mount/login changes made elsewhere (e.g. via the CLI). loop { std::thread::sleep(POLL_INTERVAL); let st = poll_state(&socket, &default_mountpoint); handle.update(move |t: &mut DriveTray| t.state = st); } }