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 ## 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 - **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, 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. 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 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]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@ -855,6 +869,15 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]] [[package]]
name = "dirs" name = "dirs"
version = "7.0.0" version = "7.0.0"
@ -2446,6 +2469,18 @@ dependencies = [
"memoffset", "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]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.18.2" version = "4.18.2"
@ -2724,6 +2759,30 @@ dependencies = [
"objc2-foundation", "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]] [[package]]
name = "objc2-ui-kit" name = "objc2-ui-kit"
version = "0.3.2" version = "0.3.2"
@ -2818,6 +2877,21 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "os_pipe" name = "os_pipe"
version = "1.2.3" version = "1.2.3"
@ -3926,6 +4000,18 @@ version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" 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]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.5" version = "0.6.5"
@ -4025,6 +4111,7 @@ dependencies = [
"sha2", "sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-notification", "tauri-plugin-notification",
@ -4250,7 +4337,7 @@ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
"cookie", "cookie",
"dirs", "dirs 7.0.0",
"dunce", "dunce",
"embed_plist", "embed_plist",
"getrandom 0.3.4", "getrandom 0.3.4",
@ -4300,7 +4387,7 @@ checksum = "59563ca5b331fd97f27ee9672ec8d36d33bb3c9e2c9f6710a1a5687256052e45"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cargo_toml", "cargo_toml",
"dirs", "dirs 7.0.0",
"glob", "glob",
"heck 0.5.0", "heck 0.5.0",
"json-patch", "json-patch",
@ -4370,6 +4457,20 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.6.1" version = "2.6.1"
@ -4532,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489" checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs 7.0.0",
"flate2", "flate2",
"futures-util", "futures-util",
"http", "http",
@ -5018,7 +5119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
dependencies = [ dependencies = [
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 7.0.0",
"libappindicator", "libappindicator",
"muda", "muda",
"objc2", "objc2",
@ -5841,7 +5942,7 @@ dependencies = [
"block2", "block2",
"cookie", "cookie",
"crossbeam-channel", "crossbeam-channel",
"dirs", "dirs 7.0.0",
"dom_query", "dom_query",
"dpi", "dpi",
"dunce", "dunce",
@ -5955,7 +6056,7 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"hex", "hex",
"nix", "nix 0.29.0",
"ordered-stream", "ordered-stream",
"rand 0.8.8", "rand 0.8.8",
"serde", "serde",

View file

@ -19,7 +19,7 @@ path = "src/main.rs"
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [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. # Spawns the storebound sidecar from Rust only; no capability grants the plugin's JS API.
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
@ -31,6 +31,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-autostart = "2"
# Store credentials: macOS Keychain, Windows Credential Manager, Secret Service on Linux. # Store credentials: macOS Keychain, Windows Credential Manager, Secret Service on Linux.
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View file

@ -21,7 +21,10 @@ include!("commands_list.rs");
use std::sync::Mutex; use std::sync::Mutex;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu, WINDOW_SUBMENU_ID}; 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::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent};
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_updater::UpdaterExt;
use tauri_plugin_deep_link::DeepLinkExt; 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 /// 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>) { fn show_main<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window(policy::MAIN) { if let Some(w) = app.get_webview_window(policy::MAIN) {
let _ = w.unminimize(); 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 // 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_single_instance::init(|app, _argv, _cwd| show_main(app)))
.plugin(tauri_plugin_deep_link::init()) .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_shell::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@ -178,7 +237,20 @@ pub fn run() {
if let Err(e) = commands::sync_machine(&state) { if let Err(e) = commands::sync_machine(&state) {
logs::host("warn", &format!("accounts / app folders not synced at start: {}", e.message)); 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); app.manage(state);
tray(app)?;
if !started_hidden() {
show_main(app.handle());
}
auto_update(app.handle().clone());
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
app.set_menu(menu(app.handle())?)?; app.set_menu(menu(app.handle())?)?;
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@ -202,9 +274,9 @@ pub fn run() {
Ok(()) Ok(())
}) })
.on_window_event(|w, e| { .on_window_event(|w, e| {
// macOS: the close button hides the window (the app stays in the Dock and the Window menu); // the close button hides the window: the app keeps running in the tray (macOS: also the Dock); the tray's
// Cmd+Q and the app menu's Quit still quit. Elsewhere closing the window quits. // Quit (macOS: Cmd+Q) really quits
if cfg!(target_os = "macos") && w.label() == policy::MAIN { if w.label() == policy::MAIN {
if let WindowEvent::CloseRequested { api, .. } = e { if let WindowEvent::CloseRequested { api, .. } = e {
api.prevent_close(); api.prevent_close();
let _ = w.hide(); let _ = w.hide();
@ -267,6 +339,8 @@ pub fn run() {
app.run(|app, e| match e { app.run(|app, e| match e {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => show_main(app), 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 => { RunEvent::Exit => {
if let Some(s) = app.try_state::<commands::AppState>() { if let Some(s) = app.try_state::<commands::AppState>() {
s.sidecar.shutdown_now(); s.sidecar.shutdown_now();

View file

@ -20,7 +20,8 @@
"minWidth": 880, "minWidth": 880,
"minHeight": 560, "minHeight": 560,
"resizable": true, "resizable": true,
"center": true "center": true,
"visible": false
} }
], ],
"security": { "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 - 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). notarization, Forgejo builds for Linux and Windows, storebound.dev hosting, publish).
- **Updates**: Settings > Updates checks storebound.dev (stable `/desktop/latest.json` or beta - **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`). 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 - **`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 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 the list, selects that app folder when it is on it, otherwise asks in a native dialog and adds it