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

This commit is contained in:
CRTkafa 2026-10-22 05:59:41 +08:00
parent 9f40a9ea14
commit 9a907d87e5
6 changed files with 200 additions and 13 deletions

View file

@ -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.

View file

@ -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",

View file

@ -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"] }

View file

@ -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<R: Runtime>(app: &AppHandle<R>) {
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::<commands::AppState>() {
s.sidecar.shutdown_now();

View file

@ -20,7 +20,8 @@
"minWidth": 880,
"minHeight": 560,
"resizable": true,
"center": true
"center": true,
"visible": false
}
],
"security": {

View file

@ -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=<absolute folder>`** (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