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:
parent
c03938f9dc
commit
60a72e6947
23 changed files with 1052 additions and 11 deletions
161
apps/desktop/src-tauri/src/card.rs
Normal file
161
apps/desktop/src-tauri/src/card.rs
Normal 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};
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue