diff --git a/crates/pdfs-core/src/config.rs b/crates/pdfs-core/src/config.rs index 704790b..26a582f 100644 --- a/crates/pdfs-core/src/config.rs +++ b/crates/pdfs-core/src/config.rs @@ -137,6 +137,11 @@ pub struct AppConfig { /// Defaulted for configs predating the field. #[serde(default)] pub files_view: FilesView, + /// The user hid the tray icon from its own menu. The tray exits at start + /// while this is set, and the app does not spawn it; Preferences turns it + /// back on. Defaulted for configs predating the field. + #[serde(default)] + pub tray_hidden: bool, } /// The My Files page's view choices, remembered between runs. @@ -208,6 +213,7 @@ impl Default for AppConfig { prompt: None, proton_accent: None, files_view: FilesView::default(), + tray_hidden: false, } } } @@ -561,12 +567,14 @@ mod tests { descending: true, folders_first: false, }, + tray_hidden: true, }; let json = serde_json::to_string(&config).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0"); assert_eq!(decoded.user_agent, "test-agent/1.0"); assert_eq!(decoded.proton_accent, Some(true)); + assert!(decoded.tray_hidden); assert_eq!(decoded.upload_limit, Some(500_000)); assert_eq!(decoded.download_limit, None); assert_eq!(decoded.files_view, config.files_view); @@ -652,6 +660,7 @@ mod tests { let json = r#"{"app_version":"v","user_agent":"u"}"#; let decoded: AppConfig = serde_json::from_str(json).unwrap(); assert!(decoded.ignore_patterns.is_none()); + assert!(!decoded.tray_hidden, "an old config keeps the tray icon"); assert_eq!( decoded.resolved_ignore_patterns().len(), DEFAULT_IGNORE_PATTERNS.len(), diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 8b5a854..34fb3a4 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod service; pub mod shell; pub mod syncignore; pub mod takeout; +pub mod tray; pub use access::{Access, access_for, perm_bits}; pub use error::{CoreError, CoreResult, Error, Result}; diff --git a/crates/pdfs-core/src/tray.rs b/crates/pdfs-core/src/tray.rs new file mode 100644 index 0000000..cf3e418 --- /dev/null +++ b/crates/pdfs-core/src/tray.rs @@ -0,0 +1,20 @@ +//! Talking to a running `pdfs-tray`. +//! +//! The tray binds [`AppDirs::tray_socket`] to keep a single instance. The same +//! socket takes one command, [`QUIT`], so the desktop app can take the icon +//! away when the user hides it or stops Proton Drive. + +use std::io::Write; +use std::os::unix::net::UnixStream; + +use crate::config::AppDirs; + +/// The line that asks the tray to exit. +pub const QUIT: &str = "quit"; + +/// Ask a running tray to exit. False when no tray answers. +pub fn quit(dirs: &AppDirs) -> bool { + UnixStream::connect(dirs.tray_socket()) + .and_then(|mut stream| stream.write_all(format!("{QUIT}\n").as_bytes())) + .is_ok() +} diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 8259a17..fd66162 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -196,7 +196,12 @@ fn main() -> glib::ExitCode { ) .init(); - let app = adw::Application::builder().application_id(APP_ID).build(); + // The tray launches the app with arguments ("--page locations", + // "--confirm-stop"), which a running instance must receive too. + let app = adw::Application::builder() + .application_id(APP_ID) + .flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE) + .build(); app.connect_startup(|_| { load_proton_theme(); if let Ok(dirs) = AppDirs::new() { @@ -205,14 +210,70 @@ fn main() -> glib::ExitCode { // Refresh the file manager's right-click pin/unpin scripts, so they always // match the installed `pdfs`. pdfs_core::shell::install_file_manager_scripts(); - spawn_tray(); + if !AppDirs::new().is_ok_and(|dirs| dirs.load_config().tray_hidden) { + spawn_tray(); + } + }); + app.connect_activate(present_window); + app.connect_command_line(|app, command| { + let args: Vec = command + .arguments() + .iter() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + app.activate(); + match parse_launch(&args) { + Launch::Window => {} + Launch::Page(page) => app.activate_action("show-page", Some(&page.to_variant())), + Launch::ConfirmStop => app.activate_action("confirm-stop", None), + } + glib::ExitCode::SUCCESS }); - app.connect_activate(build_window); app.run() } +/// What a launch of `pdfs-app` asks for, beyond showing the window. +#[derive(Debug, PartialEq, Eq)] +enum Launch { + Window, + /// `--page `: open on a stack page, such as "locations" for Sync. + Page(String), + /// `--confirm-stop`: ask whether to stop Proton Drive (from the tray). + ConfirmStop, +} + +fn parse_launch(args: &[String]) -> Launch { + let mut args = args.iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--confirm-stop" => return Launch::ConfirmStop, + "--page" => { + if let Some(page) = args.next() { + return Launch::Page(page.clone()); + } + } + other => { + if let Some(page) = other.strip_prefix("--page=") { + return Launch::Page(page.to_string()); + } + } + } + } + Launch::Window +} + +/// Raise the window if there is one, else build it. A second launch reaches +/// the running instance, which must not open a second window. +fn present_window(app: &adw::Application) { + match app.windows().first() { + Some(window) => window.present(), + None => build_window(app), + } +} + /// Spawn the tray icon process in the background. -fn spawn_tray() { +pub(crate) fn spawn_tray() { match Command::new("pdfs-tray").spawn() { Ok(_) => tracing::info!("spawned `pdfs-tray`"), Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"), @@ -382,6 +443,7 @@ fn build_window(app: &adw::Application) { download_limit_row: main_widgets.download_limit_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(), accent_row: main_widgets.accent_row.clone(), + tray_row: main_widgets.tray_row.clone(), settings_suppress: Cell::new(false), budget_source: RefCell::new(None), limit_source: RefCell::new(None), @@ -726,6 +788,7 @@ fn build_window(app: &adw::Application) { window.add_breakpoint(wide); install_shortcuts(&ui, &window); install_window_actions(&ui, &window); + install_launch_actions(&ui, app, &window); app.set_accels_for_action("win.preferences", &["comma"]); app.set_accels_for_action("win.shortcuts", &["question"]); @@ -1196,6 +1259,63 @@ fn install_window_actions(ui: &Rc, window: &adw::ApplicationWindow) { window.add_action(&about); } +/// Back the launch arguments (see [`Launch`]) with app actions, so a running +/// instance can act on them. +fn install_launch_actions(ui: &Rc, app: &adw::Application, window: &adw::ApplicationWindow) { + let show_page = gio::SimpleAction::new("show-page", Some(glib::VariantTy::STRING)); + let ui_page = ui.clone(); + show_page.connect_activate(move |_, param| { + let Some(page) = param.and_then(|p| p.str()) else { + return; + }; + // Signed out, the login page owns the window; unknown names are ignored. + if ui_page.session.borrow().is_some() && ui_page.stack.child_by_name(page).is_some() { + ui_page.stack.set_visible_child_name(page); + } + }); + app.add_action(&show_page); + + let confirm_stop = gio::SimpleAction::new("confirm-stop", None); + let win = window.clone(); + let app_stop = app.clone(); + let ui_stop = ui.clone(); + confirm_stop.connect_activate(move |_, _| { + let dialog = adw::AlertDialog::builder() + .heading("Stop Proton Drive?") + .body( + "The drive unmounts and nothing syncs until you connect again. \ + Proton Drive starts again at your next login.", + ) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("stop", "Stop"); + dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let app = app_stop.clone(); + let ui = ui_stop.clone(); + dialog.connect_response(Some("stop"), move |_, _| { + let app = app.clone(); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let stopped = gio::spawn_blocking(service::stop).await.unwrap_or(false); + if stopped { + pdfs_core::tray::quit(&ui.dirs); + app.quit(); + } else { + toast_error( + &ui, + "Couldn't stop Proton Drive", + "The mount service did not stop.", + ); + } + }); + }); + dialog.present(Some(&win)); + }); + app.add_action(&confirm_stop); +} + /// The About dialog's Troubleshooting text: what a bug report needs and what /// used to sit in a "Developer" group on the Settings page. fn debug_info(ui: &Rc) -> String { @@ -1507,6 +1627,26 @@ fn human_bytes(bytes: u64) -> String { mod tests { use super::*; + #[test] + fn the_tray_can_launch_the_app_on_a_page_or_at_the_stop_prompt() { + let args = |list: &[&str]| list.iter().map(|a| a.to_string()).collect::>(); + assert_eq!(parse_launch(&args(&[])), Launch::Window); + assert_eq!( + parse_launch(&args(&["--page", "locations"])), + Launch::Page("locations".into()) + ); + assert_eq!( + parse_launch(&args(&["--page=activity"])), + Launch::Page("activity".into()) + ); + assert_eq!( + parse_launch(&args(&["--confirm-stop"])), + Launch::ConfirmStop + ); + // A trailing --page with no name is no request at all. + assert_eq!(parse_launch(&args(&["--page"])), Launch::Window); + } + /// The full width a row occupies, gaps included. fn row_width(widths: &[i32]) -> i32 { widths.iter().sum::() + TILE_GAP * (widths.len() as i32 - 1) diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 8d10563..508e294 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -46,6 +46,8 @@ pub(crate) struct StatusState { pub(crate) mountpoint_row: adw::ActionRow, /// "Proton purple accent" toggle. Guarded by [`Self::settings_suppress`]. pub(crate) accent_row: adw::SwitchRow, + /// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`]. + pub(crate) tray_row: adw::SwitchRow, /// Set while a settings widget is being populated programmatically, so its /// change handler skips the IPC/systemd side effect. pub(crate) settings_suppress: Cell, @@ -135,6 +137,7 @@ pub(crate) struct MainWidgets { pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_button: gtk4::Button, pub(crate) accent_row: adw::SwitchRow, + pub(crate) tray_row: adw::SwitchRow, } /// Build the two surfaces that replaced the old Settings page: @@ -193,6 +196,11 @@ pub(crate) fn build_main_page() -> MainWidgets { .subtitle("Use the Proton brand color instead of the system accent color") .build(); appearance_group.add(&accent_row); + let tray_row = adw::SwitchRow::builder() + .title("Show tray icon") + .subtitle("Sync status and quick actions in the panel") + .build(); + appearance_group.add(&tray_row); let general = adw::PreferencesPage::builder() .title("General") @@ -362,6 +370,7 @@ pub(crate) fn build_main_page() -> MainWidgets { mountpoint_row, mountpoint_button, accent_row, + tray_row, } } @@ -420,6 +429,7 @@ pub(crate) fn wire_settings( ui.status .accent_row .set_active(config.proton_accent.unwrap_or(false)); + ui.status.tray_row.set_active(!config.tray_hidden); ui.status.settings_suppress.set(false); // Cache budget: a user edit applies the new soft cap on the daemon (which @@ -534,6 +544,27 @@ pub(crate) fn wire_settings( toast_error(&ui_accent, "Couldn't save the accent color", &e.to_string()); } }); + + // Tray icon: the tray reads the same flag at start, so hiding it here also + // keeps the login autostart from bringing it back. + let ui_tray = ui.clone(); + ui.status.tray_row.connect_active_notify(move |row| { + if ui_tray.status.settings_suppress.get() { + return; + } + let show = row.is_active(); + let mut config = ui_tray.dirs.load_config(); + config.tray_hidden = !show; + if let Err(e) = ui_tray.dirs.save_config(&config) { + toast_error(&ui_tray, "Couldn't save the tray setting", &e.to_string()); + return; + } + if show { + spawn_tray(); + } else { + pdfs_core::tray::quit(&ui_tray.dirs); + } + }); } /// Run a settings control-socket round-trip (budget / purge) on a worker thread, diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index a22a8cd..65be204 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -1,14 +1,16 @@ //! `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`]. +//! A StatusNotifierItem (SNI) tray icon whose icon and tooltip say what the +//! drive is doing (synced, syncing, paused, offline, needs attention), with a +//! menu to open the app or the folder, pause sync, connect, hide the icon and +//! stop Proton Drive. 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`]. Anything that needs a dialog opens `pdfs-app`. //! //! 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. +//! SNI item via the `ksni` crate. +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -16,7 +18,7 @@ use std::time::Duration; use anyhow::{Context, Result}; use ksni::blocking::TrayMethods; use ksni::menu::StandardItem; -use ksni::{MenuItem, Tray}; +use ksni::{MenuItem, Status, ToolTip, Tray}; use pdfs_core::auth; use pdfs_core::config::AppDirs; use pdfs_core::control::{ @@ -27,11 +29,45 @@ use pdfs_core::service; /// How often the tray re-polls the daemon to refresh its menu. const POLL_INTERVAL: Duration = Duration::from_secs(3); +/// The one word the icon says about the drive, derived from each poll. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + /// Mounted, online, nothing queued. + Synced, + /// Mounted with changes queued or bytes moving. + Syncing, + Paused, + /// Mounted but the API is unreachable: cached files only. + Offline, + /// Changes keep failing; the user has to look. + Attention, + /// Signed in, but no mount daemon is running. + Disconnected, + SignedOut, +} + +impl Phase { + /// A stock symbolic icon for the phase. Stock names follow the panel's + /// theme and colour, and need nothing installed beside the binary. + fn icon(self) -> &'static str { + match self { + Phase::Synced => "folder-remote-symbolic", + Phase::Syncing => "emblem-synchronizing-symbolic", + Phase::Paused => "media-playback-pause-symbolic", + Phase::Offline => "network-offline-symbolic", + Phase::Attention => "dialog-warning-symbolic", + Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic", + } + } +} + /// 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", …). + /// Human-readable first menu line ("Mounted at …", "Not running", …), also + /// the tooltip. line: String, + phase: Phase, /// Whether a mount daemon is currently serving the control socket. mounted: bool, /// Mountpoint to act on (from the daemon when mounted, else the default). @@ -41,6 +77,8 @@ struct DriveState { sync: String, /// Whether the user has sync paused; decides Pause vs Resume. paused: bool, + /// Queued changes that keep failing, for the "N issues" item. + failing: u64, } /// Summarise a work snapshot into one menu line, or empty when idle. A single @@ -100,71 +138,127 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { failing_ops, paused, .. - }) => DriveState { - line: match (online, pending_summary(pending_uploads, pending_changes)) { - _ if paused => "Sync paused".to_string(), - _ if failing_ops > 0 => match failing_ops { - 1 => "1 change needs attention".to_string(), - n => format!("{n} changes need attention"), - }, - (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, - paused, - mountpoint: PathBuf::from(mountpoint), + }) => { // Same daemon is up, so a cheap follow-up poll gives the sync line. - sync: match send(socket, &Request::GetQueueStatus) { + let sync = match send(socket, &Request::GetQueueStatus) { Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs), _ => String::new(), - }, - }, + }; + let queued = pending_summary(pending_uploads, pending_changes); + let phase = phase_of( + paused, + failing_ops, + online, + queued.is_some() || !sync.is_empty(), + ); + DriveState { + line: match (online, queued) { + _ if paused => "Sync paused".to_string(), + _ if failing_ops > 0 => match failing_ops { + 1 => "1 change needs attention".to_string(), + n => format!("{n} changes need attention"), + }, + (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}"), + }, + phase, + mounted: true, + paused, + mountpoint: PathBuf::from(mountpoint), + sync, + failing: failing_ops, + } + } // Socket answered but with something unexpected — treat as up but odd. Ok(_) => DriveState { line: "Mount: unexpected daemon response".into(), + phase: Phase::Attention, mounted: true, mountpoint: default_mountpoint.to_path_buf(), sync: String::new(), paused: false, + failing: 0, }, // 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}"), + let (line, phase) = match auth::load() { + Ok(s) => ( + format!("Logged in as {} — not mounted", s.username), + Phase::Disconnected, + ), + Err(pdfs_core::Error::NotLoggedIn) => ("Not signed in".into(), Phase::SignedOut), + Err(e) => (format!("Error: {e}"), Phase::Attention), }; DriveState { line, + phase, mounted: false, mountpoint: default_mountpoint.to_path_buf(), sync: String::new(), paused: false, + failing: 0, } } } } +/// The phase a running mount is in. A pause is the user's own doing and says +/// so first; failures outrank everything else the daemon reports. +fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase { + if paused { + Phase::Paused + } else if failing > 0 { + Phase::Attention + } else if !online { + Phase::Offline + } else if busy { + Phase::Syncing + } else { + Phase::Synced + } +} + +/// "3 issues — View": the menu item that opens the Sync page's queue. +fn issues_label(failing: u64) -> String { + match failing { + 1 => "1 issue — View".to_string(), + n => format!("{n} issues — View"), + } +} + 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 +/// Launch (or, since it's `SingleMainWindow`, raise) the desktop app with +/// `args`. The app binary lives next to the tray binary; fall back to the bare /// name so a PATH install still works. -fn open_manager() { +fn open_manager(args: &[&str]) { 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() { + if let Err(e) = Command::new(&exe).args(args).spawn() { tracing::error!("failed to launch {}: {e}", exe.display()); } } +/// Remember that the user hid the icon, so the next login's autostart and the +/// app both leave it away, then exit. +fn hide_tray() { + if let Ok(dirs) = AppDirs::new() { + let mut config = dirs.load_config(); + config.tray_hidden = true; + if let Err(e) = dirs.save_config(&config) { + tracing::warn!("hide tray icon: {e}"); + } + } + std::process::exit(0); +} + impl Tray for DriveTray { fn id(&self) -> String { "io.narl.proton-drive-linux".into() @@ -175,55 +269,83 @@ impl Tray for DriveTray { } fn icon_name(&self) -> String { - "folder-remote".into() + self.state.phase.icon().into() + } + + fn status(&self) -> Status { + match self.state.phase { + Phase::Attention => Status::NeedsAttention, + _ => Status::Active, + } + } + + fn tool_tip(&self) -> ToolTip { + let mut description = self.state.line.clone(); + if !self.state.sync.is_empty() { + description = format!("{description}\n{}", self.state.sync); + } + ToolTip { + title: "Proton Drive".into(), + description, + ..Default::default() + } + } + + /// A left click opens the app, as it does for most tray icons. + fn activate(&mut self, _x: i32, _y: i32) { + open_manager(&[]); } 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![ + let label = |text: &str| -> MenuItem { StandardItem { - label: self.state.line.clone(), + label: text.to_string(), 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, + .into() + }; + let item = |text: &str, run: fn(&mut Self)| -> MenuItem { StandardItem { - label: "Open Manager".into(), - activate: Box::new(|_: &mut Self| open_manager()), + label: text.to_string(), + activate: Box::new(run), ..Default::default() } - .into(), - ]); + .into() + }; + + let mut items: Vec> = vec![label(&self.state.line)]; + // Live sync status, shown only while something is transferring. + if !self.state.sync.is_empty() { + items.push(label(&self.state.sync)); + } + if self.state.failing > 0 { + items.push(item(&issues_label(self.state.failing), |_| { + open_manager(&["--page", "locations"]) + })); + } + items.push(MenuItem::Separator); + + match self.state.phase { + // Nothing to mount without a session; the app's sign-in page is the + // way in. + Phase::SignedOut => items.push(item("Sign In…", |_| open_manager(&[]))), + _ => items.push(item("Open Proton Drive", |_| open_manager(&[]))), + } if self.state.mounted { items.push( StandardItem { - label: "Open folder".into(), + label: "Open Folder".into(), activate: Box::new(move |_: &mut Self| open_folder(&open_mp)), ..Default::default() } .into(), ); // Pause holds uploads back but leaves the mount readable, so it - // sits apart from Disconnect, which takes the whole mount away. + // sits apart from Stop, which takes the whole mount away. let paused = self.state.paused; items.push( StandardItem { @@ -243,44 +365,25 @@ impl Tray for DriveTray { } .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 { + } else if self.state.phase == Phase::Disconnected { // 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(item("Connect", |this| { + service::enable_start(); + this.state.line = "Connecting…".into(); + })); } items.push(MenuItem::Separator); - items.push( - StandardItem { - label: "Quit".into(), - activate: Box::new(|_: &mut Self| std::process::exit(0)), - ..Default::default() - } - .into(), - ); + items.push(item("Hide Tray Icon", |_| hide_tray())); + if self.state.mounted { + // Stopping unmounts the drive, so the app asks first; the tray has no + // GTK of its own to ask with. + items.push(item("Stop Proton Drive…", |_| { + open_manager(&["--confirm-stop"]) + })); + } else { + items.push(item("Quit", |_| std::process::exit(0))); + } items } } @@ -293,6 +396,12 @@ fn main() -> Result<()> { .init(); let dirs = AppDirs::new().context("resolve app dirs")?; + // Hidden from its own menu: the session's autostart still runs the tray, and + // this is where that choice is kept. + if dirs.load_config().tray_hidden { + tracing::info!("tray icon is hidden in the config; exiting."); + return Ok(()); + } // Ensure only one instance of the tray runs. let tray_sock = dirs.tray_socket(); @@ -301,7 +410,7 @@ fn main() -> Result<()> { return Ok(()); } let _ = std::fs::remove_file(&tray_sock); - let _lock_socket = std::os::unix::net::UnixListener::bind(&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 @@ -310,6 +419,19 @@ fn main() -> Result<()> { tracing::warn!(error = %e, "could not restrict tray socket permissions"); } + // The app asks the tray to leave through its socket, when the user hides + // the icon from Preferences or stops Proton Drive. + std::thread::spawn(move || { + for stream in lock_socket.incoming().flatten() { + let mut line = String::new(); + if BufReader::new(stream).read_line(&mut line).is_ok() + && line.trim() == pdfs_core::tray::QUIT + { + std::process::exit(0); + } + } + }); + let socket = dirs.control_socket(); let default_mountpoint = dirs.default_mountpoint(); @@ -334,3 +456,23 @@ fn main() -> Result<()> { handle.update(move |t: &mut DriveTray| t.state = st); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_pause_outranks_failures_and_failures_outrank_the_rest() { + assert_eq!(phase_of(true, 3, false, true), Phase::Paused); + assert_eq!(phase_of(false, 3, false, true), Phase::Attention); + assert_eq!(phase_of(false, 0, false, true), Phase::Offline); + assert_eq!(phase_of(false, 0, true, true), Phase::Syncing); + assert_eq!(phase_of(false, 0, true, false), Phase::Synced); + } + + #[test] + fn the_issues_item_counts_in_the_singular_and_plural() { + assert_eq!(issues_label(1), "1 issue — View"); + assert_eq!(issues_label(4), "4 issues — View"); + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d599bd7..ede94b1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -35,6 +35,14 @@ Schema: **31** (unchanged). shared by every transfer and applied to running ones from their next block; downloads include reads through the mount. `pdfs sync limit --up 2M --down 0` does the same, and `pdfs sync limit` on its own prints the current caps. The caps are kept in `config.json` across restarts. +- **Tray states and menu.** The tray icon shows whether the drive is synced, syncing, paused, + offline or needs attention. It asks the panel for attention while changes keep failing, and + its tooltip gives the same line as the menu. A left click opens the app. The menu gains + "N issues — View", which opens the Sync page, and "Sign In…" when signed out. "Hide Tray Icon" + is remembered across logins (`tray_hidden` in `config.json`), and Preferences → Appearance + → Show tray icon brings the icon back. "Stop Proton Drive…" asks for confirmation in the app, + then stops the service and the tray. `pdfs-app` accepts `--page ` and `--confirm-stop`. + A second launch raises the running window instead of opening another one. ### Changed - **New app shell.** Every page has its own header bar; the sidebar ends in a footer with sync