From 9a907d87e59ce4c4d5406ba7fd02a58a4f94de60 Mon Sep 17 00:00:00 2001 From: CRT Date: Thu, 22 Oct 2026 05:59:41 +0800 Subject: [PATCH] Desktop: tray icon, start at logon hidden (autostart --hidden, window state never restores visible), closing hides to the tray, background signed auto-update at start and every 30 min with restart --- CHANGELOG.md | 4 + apps/desktop/src-tauri/Cargo.lock | 113 +++++++++++++++++++++++-- apps/desktop/src-tauri/Cargo.toml | 3 +- apps/desktop/src-tauri/src/lib.rs | 82 +++++++++++++++++- apps/desktop/src-tauri/tauri.conf.json | 3 +- docs/desktop.md | 8 +- 6 files changed, 200 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7816ee..4687765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **Desktop: tray app that starts at logon and updates itself.** First run registers a logon entry (`--hidden`): the app + starts silently in the tray; closing the window keeps it there, the tray menu quits. A signed update is checked a + minute after start and every 30 minutes, installed silently, and the app restarts as it was. + - **App Privacy console plan follows today's page** (verified 2026-10-22): on an app published before, Edit opens the Data Collection question first and the type list ends in Continue; each type's last step is Publish (it goes live, no draft). Publish stays the owner's unless bypass mode is on. diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 957e60a..4b7b3f9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -227,6 +227,20 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf" +dependencies = [ + "dirs 6.0.0", + "os_info", + "smappservice-rs", + "thiserror 2.0.21", + "windows-registry", + "windows-result", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -855,6 +869,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs" version = "7.0.0" @@ -2446,6 +2469,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify-rust" version = "4.18.2" @@ -2724,6 +2759,30 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -2818,6 +2877,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix 0.31.3", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3926,6 +4000,18 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "smappservice-rs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-service-management", + "thiserror 2.0.21", +] + [[package]] name = "socket2" version = "0.6.5" @@ -4025,6 +4111,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-notification", @@ -4250,7 +4337,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4300,7 +4387,7 @@ checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4370,6 +4457,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.6.1" @@ -4532,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 7.0.0", "flate2", "futures-util", "http", @@ -5018,7 +5119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 7.0.0", "libappindicator", "muda", "objc2", @@ -5841,7 +5942,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -5955,7 +6056,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.8", "serde", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index bb0cad6..6ef6990 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -19,7 +19,7 @@ path = "src/main.rs" tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2.12", features = [] } +tauri = { version = "2.12", features = ["tray-icon"] } # Spawns the storebound sidecar from Rust only; no capability grants the plugin's JS API. tauri-plugin-shell = "2" tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } @@ -31,6 +31,7 @@ tauri-plugin-dialog = "2" tauri-plugin-notification = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-autostart = "2" # Store credentials: macOS Keychain, Windows Credential Manager, Secret Service on Linux. keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } serde = { version = "1", features = ["derive"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c16ea24..dd9272d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -21,7 +21,10 @@ include!("commands_list.rs"); use std::sync::Mutex; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent}; +use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; +use tauri_plugin_updater::UpdaterExt; use tauri_plugin_deep_link::DeepLinkExt; /// WebKitGTK's DMABUF renderer leaves the window blank on some Linux GPU / driver / VM combinations. Turn it off unless @@ -34,6 +37,60 @@ pub fn linux_webkit_env() { } } +/// Started by the logon entry (`--hidden`): the app runs in the tray and shows no window. +fn started_hidden() -> bool { + std::env::args().any(|a| a == "--hidden") +} + +/// Tray icon: a click shows the window; the menu has Open and Quit (the only real quit on Windows and Linux, where +/// closing the window keeps the app running in the tray). +fn tray(app: &tauri::App) -> tauri::Result<()> { + let open = MenuItem::with_id(app, "tray-open", "Open storebound", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "tray-quit", "Quit storebound", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&open, &PredefinedMenuItem::separator(app)?, &quit])?; + let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false); + if let Some(icon) = app.default_window_icon() { + b = b.icon(icon.clone()); + } + b.on_menu_event(|app, e| match e.id().as_ref() { + "tray-open" => show_main(app), + "tray-quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|t, e| { + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = e { + show_main(t.app_handle()); + } + }) + .build(app)?; + Ok(()) +} + +/// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is +/// downloaded, installed and the app restarts with the same arguments (`--hidden` stays hidden). Errors are logged. +fn auto_update(app: AppHandle) { + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + loop { + match app.updater() { + Ok(u) => match u.check().await { + Ok(Some(update)) => { + logs::host("info", &format!("auto-update: {} available, installing", update.version)); + match update.download_and_install(|_, _| {}, || {}).await { + Ok(()) => app.restart(), + Err(e) => logs::host("warn", &format!("auto-update: install failed: {e}")), + } + } + Ok(None) => {} + Err(e) => logs::host("warn", &format!("auto-update: check failed: {e}")), + }, + Err(e) => logs::host("warn", &format!("auto-update: no updater: {e}")), + } + tokio::time::sleep(std::time::Duration::from_secs(30 * 60)).await; + } + }); +} + fn show_main(app: &AppHandle) { if let Some(w) = app.get_webview_window(policy::MAIN) { let _ = w.unminimize(); @@ -135,7 +192,9 @@ pub fn run() { // single-instance first: a second start (or a storebound:// link) focuses the running app instead .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app))) .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_window_state::Builder::new().build()) + // never restore "visible": a --hidden start would otherwise pop the window up + .plugin(tauri_plugin_window_state::Builder::new().with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build()) + .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"]))) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -178,7 +237,20 @@ pub fn run() { if let Err(e) = commands::sync_machine(&state) { logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message)); } + // start at logon, in the tray: set once (first run); the owner can turn it off in the OS afterwards + let marker = state.data_dir.join("autostart-set"); + if !marker.exists() { + match app.autolaunch().enable() { + Ok(()) => { let _ = std::fs::write(&marker, "1"); logs::host("info", "autostart at logon enabled (--hidden, tray)"); } + Err(e) => logs::host("warn", &format!("autostart not enabled: {e}")), + } + } app.manage(state); + tray(app)?; + if !started_hidden() { + show_main(app.handle()); + } + auto_update(app.handle().clone()); #[cfg(target_os = "macos")] app.set_menu(menu(app.handle())?)?; #[cfg(not(target_os = "macos"))] @@ -202,9 +274,9 @@ pub fn run() { Ok(()) }) .on_window_event(|w, e| { - // macOS: the close button hides the window (the app stays in the Dock and the Window menu); - // Cmd+Q and the app menu's Quit still quit. Elsewhere closing the window quits. - if cfg!(target_os = "macos") && w.label() == policy::MAIN { + // the close button hides the window: the app keeps running in the tray (macOS: also the Dock); the tray's + // Quit (macOS: Cmd+Q) really quits + if w.label() == policy::MAIN { if let WindowEvent::CloseRequested { api, .. } = e { api.prevent_close(); let _ = w.hide(); @@ -267,6 +339,8 @@ pub fn run() { app.run(|app, e| match e { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main(app), + // no window left is not a reason to quit (tray app); an explicit exit or the updater's restart carries a code + RunEvent::ExitRequested { code: None, api, .. } => api.prevent_exit(), RunEvent::Exit => { if let Some(s) = app.try_state::() { s.sidecar.shutdown_now(); diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 614a48e..51f3128 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ "minWidth": 880, "minHeight": 560, "resizable": true, - "center": true + "center": true, + "visible": false } ], "security": { diff --git a/docs/desktop.md b/docs/desktop.md index 795c2c3..9de0347 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -482,8 +482,14 @@ The CLI and the app are one product: one version number, one set of store accoun - Releases: [desktop-release.md](desktop-release.md) (version authority, privacy gate, macOS signing and notarization, Forgejo builds for Linux and Windows, storebound.dev hosting, publish). - **Updates**: Settings > Updates checks storebound.dev (stable `/desktop/latest.json` or beta - `/desktop/beta/latest.json`) only when asked; Install shows a native dialog, then the updater downloads the bundle, + `/desktop/beta/latest.json`) when asked; Install shows a native dialog, then the updater downloads the bundle, checks its minisign signature against `plugins.updater.pubkey` and restarts (`update_check`, `update_install`). + In the background the app also checks the stable channel a minute after start and every 30 minutes, installs a + signed update silently and restarts with the same arguments (`auto_update` in `src-tauri/src/lib.rs`). +- **Tray and start at logon**: the first run registers a logon entry (`tauri-plugin-autostart`, `--hidden`): the app + starts silently in the tray, without a window. Closing the window keeps it running in the tray; the tray icon + opens it, the tray menu's Quit (macOS: Cmd+Q) quits. Turn the logon entry off in the OS (Windows: Settings > Apps > + Startup). Window state never restores "visible", so a `--hidden` start stays hidden. - **`storebound://open?path=`** (sent by `storebound desktop` in an app folder, which first puts the folder on the machine's list): the host accepts one plain absolute path (`policy::deep_link_open_path`), syncs the list, selects that app folder when it is on it, otherwise asks in a native dialog and adds it