mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,gui): tray states and menu
The tray icon follows the drive's phase (synced, syncing, paused, offline, needs attention) and reports NeedsAttention while changes keep failing. The menu gains an issues item that opens the Sync page, Sign In when signed out, Hide Tray Icon (persisted as tray_hidden) and Stop Proton Drive, which pdfs-app confirms via --confirm-stop. A second pdfs-app launch raises the running window.
This commit is contained in:
1 parent
4fa61a14c3
commit
f6136ce344
7 files changed
+451
-100
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<String> = 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 <name>`: 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", &["<Control>comma"]);
|
||||
app.set_accels_for_action("win.shortcuts", &["<Control>question"]);
|
||||
|
||||
@@ -1196,6 +1259,63 @@ fn install_window_actions(ui: &Rc<Ui>, 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<Ui>, 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<Ui>) -> 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::<Vec<_>>();
|
||||
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::<i32>() + TILE_GAP * (widths.len() as i32 - 1)
|
||||
|
||||
@@ -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<bool>,
|
||||
@@ -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,
|
||||
|
||||
+238
-96
@@ -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<MenuItem<Self>> {
|
||||
// Value captured into the action closure (which only gets `&mut Self`).
|
||||
let open_mp = self.state.mountpoint.clone();
|
||||
|
||||
let mut items: Vec<MenuItem<Self>> = vec![
|
||||
let label = |text: &str| -> MenuItem<Self> {
|
||||
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<Self> {
|
||||
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<MenuItem<Self>> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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 <name>` 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
|
||||
|
||||
Reference in new issue
Block a user