Desktop: tray mini card on left click (store status at a glance next to the tray icon; Refresh runs the main window's Refresh all)

This commit is contained in:
CRTkafa 2026-10-23 11:15:35 +08:00
parent c03938f9dc
commit 60a72e6947
23 changed files with 1052 additions and 11 deletions

View file

@ -5,7 +5,7 @@ include!("src/commands_list.rs");
fn main() {
tauri_build::try_build(
tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(COMMANDS)),
.app_manifest(tauri_build::AppManifest::new().commands(Box::leak([COMMANDS, CARD_COMMANDS].concat().into_boxed_slice()))),
)
.expect("tauri build script failed");
}

View file

@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "card-window",
"description": "The tray card (a small window next to the tray icon) with the app's own bundled card page only. It may listen to app events (the main window's snapshot) and call the two card commands: read the last snapshot and send a button (hide, open the main window or a route in it, refresh and mute through the main window, open a store console, quit). It never reads the stores or reaches the sidecar; every command re-checks the window label and page origin in Rust.",
"windows": ["card"],
"local": true,
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"allow-card-get",
"allow-card-action"
]
}

View file

@ -45,6 +45,7 @@
"allow-data-reveal",
"allow-notify",
"allow-tray-update",
"allow-card-snapshot",
"allow-app-reset",
"allow-update-check",
"allow-update-install",

View file

@ -0,0 +1,161 @@
//! The tray card: a small frameless, always-on-top window that a left click on the tray icon shows next to the icon.
//!
//! It is created hidden at start (`create`) and only moved and shown on a click (`toggle`), so it opens instantly. The
//! card never reads the stores: the main window pushes a snapshot of what it already knows (`card_snapshot`, kept here
//! and forwarded as `card-snapshot`), and the card's buttons come back through `card_action`, which reuses the tray
//! menu's paths (`tray-action` read / mute-toggle, `tray-route`), so Refresh is the main window's own Refresh all.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde_json::Value;
pub const LABEL: &str = "card";
/// Logical size of the card.
pub const WIDTH: f64 = 360.0;
pub const HEIGHT: f64 = 480.0;
/// Gap between the card and the tray icon / screen edge (logical px).
pub const GAP: f64 = 8.0;
/// A snapshot larger than this is refused (the card shows a handful of rows).
pub const MAX_SNAPSHOT: usize = 64 * 1024;
/// A rectangle in physical pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
impl Rect {
fn cx(&self) -> f64 { self.x + self.w / 2.0 }
fn cy(&self) -> f64 { self.y + self.h / 2.0 }
}
fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
if hi < lo { lo } else { v.clamp(lo, hi) }
}
/// Top-left corner of the card (physical px) for a tray icon at `tray`, inside `work` (the monitor's work area, without
/// taskbar / menu bar / Dock). The card sits on the side of the icon that faces the screen: above a bottom taskbar,
/// below the macOS menu bar, beside a left or right taskbar; centred on the icon along the bar, and always inside the
/// work area.
pub fn place(tray: Rect, work: Rect, card_w: f64, card_h: f64, gap: f64) -> (f64, f64) {
let (cx, cy) = (tray.cx(), tray.cy());
let (right, bottom) = (work.x + work.w, work.y + work.h);
let (x, y) = if cy >= bottom {
(cx - card_w / 2.0, tray.y - card_h - gap)
} else if cy <= work.y {
(cx - card_w / 2.0, tray.y + tray.h + gap)
} else if cx <= work.x {
(tray.x + tray.w + gap, cy - card_h / 2.0)
} else if cx >= right {
(tray.x - card_w - gap, cy - card_h / 2.0)
} else if cy > work.y + work.h / 2.0 {
// icon inside the work area (auto-hidden taskbar, overflow flyout): open towards the middle
(cx - card_w / 2.0, tray.y - card_h - gap)
} else {
(cx - card_w / 2.0, tray.y + tray.h + gap)
};
(clamp(x, work.x + gap, right - card_w - gap).round(), clamp(y, work.y + gap, bottom - card_h - gap).round())
}
/// Whether a card action word is one the card may send.
pub fn valid_action(a: &str) -> bool {
matches!(a, "hide" | "open" | "route" | "refresh" | "mute" | "quit" | "external" | "ready")
}
// ---- the last snapshot ----
static SNAPSHOT: Mutex<Option<Value>> = Mutex::new(None);
/// Keeps the main window's snapshot (an object, at most MAX_SNAPSHOT bytes). False when refused.
pub fn set_snapshot(v: Value) -> bool {
if !v.is_object() || v.to_string().len() > MAX_SNAPSHOT {
return false;
}
if let Ok(mut s) = SNAPSHOT.lock() {
*s = Some(v);
}
true
}
pub fn snapshot() -> Option<Value> {
SNAPSHOT.lock().ok().and_then(|s| s.clone())
}
// ---- show / hide ----
static HIDDEN_AT: Mutex<Option<Instant>> = Mutex::new(None);
/// The card lost focus or was closed: remembered so that the click on the tray icon that caused the blur does not
/// open it again at once (a second click closes it).
pub fn note_hidden() {
if let Ok(mut h) = HIDDEN_AT.lock() {
*h = Some(Instant::now());
}
}
fn just_hidden() -> bool {
HIDDEN_AT.lock().ok().and_then(|h| *h).is_some_and(|t| t.elapsed() < Duration::from_millis(300))
}
#[cfg(desktop)]
mod window {
use super::*;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewUrl, WebviewWindowBuilder};
/// The hidden card window, made once at start.
pub fn create<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
let b = WebviewWindowBuilder::new(app, LABEL, WebviewUrl::App("card.html".into()))
.title("storebound")
.inner_size(WIDTH, HEIGHT)
.resizable(false)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(false)
.focused(false)
.shadow(true);
#[cfg(not(target_os = "macos"))]
let b = b.transparent(true);
b.build()?;
Ok(())
}
/// A left click on the tray icon: shows the card next to the icon, or hides it when it is open.
pub fn toggle<R: Runtime>(app: &AppHandle<R>, tray: Rect) {
let Some(w) = app.get_webview_window(LABEL) else { return };
if w.is_visible().unwrap_or(false) {
let _ = w.hide();
note_hidden();
return;
}
if just_hidden() {
return;
}
let monitor = app.monitor_from_point(tray.x + tray.w / 2.0, tray.y + tray.h / 2.0).ok().flatten().or_else(|| w.primary_monitor().ok().flatten());
if let Some(m) = monitor {
let s = m.scale_factor();
let wa = m.work_area();
let work = Rect { x: wa.position.x as f64, y: wa.position.y as f64, w: wa.size.width as f64, h: wa.size.height as f64 };
let (x, y) = place(tray, work, WIDTH * s, HEIGHT * s, GAP * s);
let _ = w.set_position(PhysicalPosition::new(x as i32, y as i32));
}
let _ = w.show();
let _ = w.set_focus();
let _ = app.emit_to(LABEL, "card-shown", ());
// the main window refreshes the parts the card shows that it keeps per app (reviews, health from the cache)
let _ = app.emit_to(crate::policy::MAIN, "card-request", ());
}
pub fn hide<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window(LABEL) {
let _ = w.hide();
note_hidden();
}
}
}
#[cfg(desktop)]
pub use window::{create, hide, toggle};

View file

@ -44,6 +44,11 @@ fn guard(w: &WebviewWindow) -> Res<()> {
policy::require_main(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
}
fn guard_card(w: &WebviewWindow) -> Res<()> {
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
}
fn saved(s: &AppState) -> Res<std::sync::MutexGuard<'_, Saved>> {
s.saved.lock().map_err(|_| Fail::host("state poisoned"))
}
@ -1304,6 +1309,69 @@ pub fn tray_update(window: WebviewWindow, app: AppHandle, summary: crate::tray::
crate::tray::apply(&app, &summary).map_err(|e| Fail::host(e.to_string()))
}
/// The main window's snapshot for the tray card (what it already read: releases, reviews, health, last read, busy).
/// Kept in the host so the card shows it the moment it opens; forwarded to the card as `card-snapshot`.
#[tauri::command]
pub fn card_snapshot(window: WebviewWindow, app: AppHandle, snapshot: Value) -> Res<()> {
guard(&window)?;
if !crate::card::set_snapshot(snapshot.clone()) {
return Err(Fail::host("card snapshot refused"));
}
let _ = app.emit_to(crate::card::LABEL, "card-snapshot", snapshot);
Ok(())
}
/// The card: the last snapshot of the main window (null before the first one).
#[tauri::command]
pub fn card_get(window: WebviewWindow) -> Res<Option<Value>> {
guard_card(&window)?;
Ok(crate::card::snapshot())
}
/// The card's buttons. `refresh` and `mute` go to the main window as the tray menu's `tray-action` (its own Refresh
/// all and mute), `route` shows the main window on a route (`tray-route`), `external` opens a store console.
#[tauri::command]
pub fn card_action(window: WebviewWindow, app: AppHandle, action: String, arg: Option<String>) -> Res<()> {
guard_card(&window)?;
if !crate::card::valid_action(&action) {
return Err(Fail::host("unknown card action"));
}
let main = || {
if let Some(w) = app.get_webview_window(policy::MAIN) {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
};
match action.as_str() {
"hide" => crate::card::hide(&app),
"ready" => { let _ = app.emit_to(policy::MAIN, "card-request", ()); }
"open" => { crate::card::hide(&app); main(); }
"refresh" => { let _ = app.emit_to(policy::MAIN, "tray-action", "read"); }
"mute" => { let _ = app.emit_to(policy::MAIN, "tray-action", "mute-toggle"); }
"quit" => app.exit(0),
"route" => {
let r = arg.unwrap_or_default();
if !crate::tray::valid_route(&r) {
return Err(Fail::host("bad route"));
}
crate::card::hide(&app);
main();
let _ = app.emit_to(policy::MAIN, "tray-route", r);
}
"external" => {
let u = url::Url::parse(arg.as_deref().unwrap_or_default()).map_err(|_| Fail::host("not a URL"))?;
if !policy::external_allowed(&u) {
return Err(Fail::host("only the store consoles open from here"));
}
crate::card::hide(&app);
app.opener().open_url(u.as_str(), None::<&str>).map_err(|e| Fail::host(e.to_string()))?;
}
_ => {}
}
Ok(())
}
/// Forgets everything after a native confirmation: store keys (keychain), studios, accounts, app folders (the folders
/// themselves stay), overrides, settings and saved reads. Downloaded tools stay. True when it was reset.
#[tauri::command]

View file

@ -37,6 +37,7 @@ pub const COMMANDS: &[&str] = &[
"data_reveal",
"notify",
"tray_update",
"card_snapshot",
"app_reset",
"update_check",
"update_install",
@ -52,3 +53,6 @@ pub const COMMANDS: &[&str] = &[
"ai_fix_run",
"ai_fix_undo",
];
/// The tray card window's commands (capabilities/card.json grants them to the `card` window only).
pub const CARD_COMMANDS: &[&str] = &["card_get", "card_action"];

View file

@ -3,6 +3,7 @@
pub mod accounts;
pub mod ai_fix;
pub mod cache;
pub mod card;
pub mod commands;
pub mod confirm;
pub mod downloads;
@ -66,8 +67,17 @@ fn tray(app: &tauri::App) -> tauri::Result<()> {
None => {}
})
.on_tray_icon_event(|t, e| {
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = e {
show_main(t.app_handle());
// left click: the tray card next to the icon (right click keeps the native menu)
if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, rect, .. } = e {
let app = t.app_handle();
if app.get_webview_window(card::LABEL).is_none() {
show_main(app);
return;
}
let scale = app.get_webview_window(policy::MAIN).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0);
let p = rect.position.to_physical::<f64>(scale);
let s = rect.size.to_physical::<f64>(scale);
card::toggle(app, card::Rect { x: p.x, y: p.y, w: s.width, h: s.height });
}
})
.build(app)?;
@ -281,6 +291,9 @@ pub fn run() {
}
app.manage(state);
tray(app)?;
if let Err(e) = card::create(app.handle()) {
logs::host("warn", &format!("tray card not created: {e}"));
}
if !started_hidden() {
show_main(app.handle());
}
@ -310,6 +323,13 @@ pub fn run() {
.on_window_event(|w, e| {
// 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() == card::LABEL {
match e {
WindowEvent::Focused(false) => { let _ = w.hide(); card::note_hidden(); }
WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let _ = w.hide(); card::note_hidden(); }
_ => {}
}
}
if w.label() == policy::MAIN {
if let WindowEvent::CloseRequested { api, .. } = e {
api.prevent_close();
@ -354,6 +374,7 @@ pub fn run() {
commands::data_reveal,
commands::notify,
commands::tray_update,
commands::card_snapshot,
commands::app_reset,
commands::update_check,
commands::update_install,
@ -368,6 +389,8 @@ pub fn run() {
commands::ai_fix_info,
commands::ai_fix_run,
commands::ai_fix_undo,
commands::card_get,
commands::card_action,
])
.build(tauri::generate_context!())
.expect("storebound desktop failed to start");

View file

@ -43,6 +43,19 @@ pub fn require_main(label: &str, url: &Url, dev: bool) -> Result<(), String> {
Ok(())
}
/// The tray card window (card.rs): it may call only the card commands (`card_get`, `card_action`).
pub const CARD: &str = "card";
pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> {
if label != CARD {
return Err(format!("not allowed from window '{label}'"));
}
if !is_local_origin(url, dev) {
return Err("not allowed from this page".into());
}
Ok(())
}
/// Method kinds the sidecar announces in its `session.hello` descriptors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {

View file

@ -50,7 +50,7 @@
"form-action": "'none'"
},
"freezePrototype": true,
"capabilities": ["main-window"]
"capabilities": ["main-window", "card-window"]
}
},
"bundle": {

View file

@ -0,0 +1,77 @@
//! The tray card's placement next to the tray icon, always inside the monitor's work area.
use storebound_desktop::card::*;
const W: f64 = 360.0;
const H: f64 = 480.0;
fn r(x: f64, y: f64, w: f64, h: f64) -> Rect { Rect { x, y, w, h } }
fn inside(p: (f64, f64), work: Rect) {
assert!(p.0 >= work.x && p.0 + W <= work.x + work.w, "x {p:?} outside {work:?}");
assert!(p.1 >= work.y && p.1 + H <= work.y + work.h, "y {p:?} outside {work:?}");
}
#[test]
fn windows_bottom_taskbar_opens_above_the_icon() {
let work = r(0.0, 0.0, 1920.0, 1032.0);
let p = place(r(1700.0, 1040.0, 24.0, 32.0), work, W, H, 8.0);
assert_eq!(p, (1532.0, 1032.0 - H - 8.0));
inside(p, work);
}
#[test]
fn icon_in_the_corner_is_clamped_into_the_work_area() {
let work = r(0.0, 0.0, 1920.0, 1032.0);
let p = place(r(1900.0, 1040.0, 24.0, 32.0), work, W, H, 8.0);
assert_eq!(p.0, 1920.0 - W - 8.0);
inside(p, work);
}
#[test]
fn macos_menu_bar_opens_below_the_icon() {
let work = r(0.0, 25.0, 1440.0, 875.0);
let p = place(r(1200.0, 0.0, 22.0, 24.0), work, W, H, 8.0);
assert_eq!(p.1, 33.0);
inside(p, work);
}
#[test]
fn side_taskbars_open_beside_the_icon() {
let work = r(60.0, 0.0, 1860.0, 1080.0);
let left = place(r(10.0, 900.0, 40.0, 40.0), work, W, H, 8.0);
assert_eq!(left.0, 68.0);
inside(left, work);
let work = r(0.0, 0.0, 1860.0, 1080.0);
let right = place(r(1870.0, 100.0, 40.0, 40.0), work, W, H, 8.0);
assert_eq!(right.0, 1860.0 - W - 8.0);
inside(right, work);
}
#[test]
fn second_monitor_with_negative_origin() {
let work = r(-2560.0, 0.0, 2560.0, 1400.0);
let p = place(r(-200.0, 1410.0, 24.0, 30.0), work, W, H, 8.0);
inside(p, work);
}
#[test]
fn icon_inside_the_work_area_and_a_tiny_screen() {
let work = r(0.0, 0.0, 1920.0, 1080.0);
inside(place(r(900.0, 1000.0, 24.0, 24.0), work, W, H, 8.0), work);
inside(place(r(900.0, 20.0, 24.0, 24.0), work, W, H, 8.0), work);
// smaller than the card: top-left of the work area, no panic
let tiny = r(0.0, 0.0, 300.0, 300.0);
assert_eq!(place(r(100.0, 310.0, 20.0, 20.0), tiny, W, H, 8.0), (8.0, 8.0));
}
#[test]
fn actions_and_snapshots_are_checked() {
for a in ["hide", "open", "route", "refresh", "mute", "quit", "external", "ready"] {
assert!(valid_action(a));
}
assert!(!valid_action("sidecar_call"));
assert!(!set_snapshot(serde_json::json!([1, 2])));
assert!(!set_snapshot(serde_json::json!({ "x": "y".repeat(MAX_SNAPSHOT) })));
assert!(set_snapshot(serde_json::json!({ "busy": false })));
assert_eq!(snapshot().unwrap()["busy"], false);
}

View file

@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use storebound_desktop::policy::*;
use storebound_desktop::COMMANDS;
use storebound_desktop::{CARD_COMMANDS, COMMANDS};
use url::Url;
fn root() -> PathBuf {
@ -24,9 +24,10 @@ fn u(s: &str) -> Url {
#[test]
fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
let dir = root().join("capabilities");
let files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(files.len(), 1, "one capability file expected, found {files:?}");
let cap = read_json(&files[0]);
let mut files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
files.sort();
assert_eq!(files.len(), 2, "main and card capability files expected, found {files:?}");
let cap = read_json(&dir.join("main.json"));
assert_eq!(cap["identifier"], "main-window");
assert_eq!(cap["windows"], json!(["main"]));
assert!(cap.get("webviews").is_none(), "no webview-scoped grants");
@ -46,6 +47,30 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
}
}
#[test]
fn the_card_window_gets_only_the_card_commands() {
let cap = read_json(&root().join("capabilities/card.json"));
assert_eq!(cap["identifier"], "card-window");
assert_eq!(cap["windows"], json!(["card"]));
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
assert_ne!(cap["local"], json!(false));
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
let mut want: BTreeSet<String> = CARD_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
want.insert("core:event:allow-listen".into());
want.insert("core:event:allow-unlisten".into());
assert_eq!(got, want);
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
for c in CARD_COMMANDS {
let at = cmds.find(&format!("pub fn {c}(")).unwrap_or_else(|| panic!("{c} not in commands.rs"));
let body = &cmds[at..];
let open = body.find('{').unwrap();
assert!(body[open + 1..].trim_start().starts_with("guard_card(&window)?;"), "{c} must start with guard_card(&window)?");
}
assert!(require_card("card", &u("tauri://localhost/card.html"), false).is_ok());
assert!(require_card("main", &u("tauri://localhost/"), false).is_err());
assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err());
}
#[test]
fn every_registered_command_is_in_the_list_and_nothing_else() {
let lib = fs::read_to_string(root().join("src/lib.rs")).unwrap();
@ -56,7 +81,7 @@ fn every_registered_command_is_in_the_list_and_nothing_else() {
.map(|s| s.trim().trim_start_matches("commands::").to_string())
.filter(|s| !s.is_empty())
.collect();
assert_eq!(registered, COMMANDS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
// every command checks the caller first
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
for c in COMMANDS {
@ -84,7 +109,7 @@ fn csp_is_strict_and_the_config_has_no_backdoors() {
assert!(!v.contains("unsafe-eval") && !v.contains("unsafe-inline") && !v.contains('*'), "{k}: {v}");
assert!(!v.contains("https:") && !v.contains("http://") || v == "ipc: http://ipc.localhost", "{k}: no remote sources: {v}");
}
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window"]));
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window"]));
assert_eq!(conf["app"]["security"]["freezePrototype"], json!(true));
assert!(conf["app"].get("withGlobalTauri").is_none() || conf["app"]["withGlobalTauri"] == json!(false));
let windows = conf["app"]["windows"].as_array().unwrap();