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

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