From 60a72e6947725d88c23c9ebb4bb517694ac24c27 Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 11:15:35 +0800 Subject: [PATCH] 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) --- apps/desktop/card.html | 14 ++ apps/desktop/src-tauri/build.rs | 2 +- apps/desktop/src-tauri/capabilities/card.json | 13 ++ apps/desktop/src-tauri/capabilities/main.json | 1 + apps/desktop/src-tauri/src/card.rs | 161 ++++++++++++++++++ apps/desktop/src-tauri/src/commands.rs | 68 ++++++++ apps/desktop/src-tauri/src/commands_list.rs | 4 + apps/desktop/src-tauri/src/lib.rs | 27 ++- apps/desktop/src-tauri/src/policy.rs | 13 ++ apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src-tauri/tests/card.rs | 77 +++++++++ apps/desktop/src-tauri/tests/security.rs | 37 +++- apps/desktop/src/card/Card.tsx | 156 +++++++++++++++++ apps/desktop/src/card/bridge.tsx | 66 +++++++ apps/desktop/src/card/card-api.ts | 25 +++ apps/desktop/src/card/card.css | 58 +++++++ apps/desktop/src/card/fixture.ts | 25 +++ apps/desktop/src/card/main.tsx | 53 ++++++ apps/desktop/src/card/model.ts | 86 ++++++++++ apps/desktop/src/card/text.ts | 103 +++++++++++ apps/desktop/src/main.tsx | 3 +- apps/desktop/tests/card-model.test.ts | 67 ++++++++ apps/desktop/vite.config.ts | 2 + 23 files changed, 1052 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/card.html create mode 100644 apps/desktop/src-tauri/capabilities/card.json create mode 100644 apps/desktop/src-tauri/src/card.rs create mode 100644 apps/desktop/src-tauri/tests/card.rs create mode 100644 apps/desktop/src/card/Card.tsx create mode 100644 apps/desktop/src/card/bridge.tsx create mode 100644 apps/desktop/src/card/card-api.ts create mode 100644 apps/desktop/src/card/card.css create mode 100644 apps/desktop/src/card/fixture.ts create mode 100644 apps/desktop/src/card/main.tsx create mode 100644 apps/desktop/src/card/model.ts create mode 100644 apps/desktop/src/card/text.ts create mode 100644 apps/desktop/tests/card-model.test.ts diff --git a/apps/desktop/card.html b/apps/desktop/card.html new file mode 100644 index 0000000..effa772 --- /dev/null +++ b/apps/desktop/card.html @@ -0,0 +1,14 @@ + + + + + + + + storebound + + +
+ + + diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index a76dc9d..673bde2 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -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"); } diff --git a/apps/desktop/src-tauri/capabilities/card.json b/apps/desktop/src-tauri/capabilities/card.json new file mode 100644 index 0000000..aaae544 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/card.json @@ -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" + ] +} diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json index cd3e8f6..159856d 100644 --- a/apps/desktop/src-tauri/capabilities/main.json +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -45,6 +45,7 @@ "allow-data-reveal", "allow-notify", "allow-tray-update", + "allow-card-snapshot", "allow-app-reset", "allow-update-check", "allow-update-install", diff --git a/apps/desktop/src-tauri/src/card.rs b/apps/desktop/src-tauri/src/card.rs new file mode 100644 index 0000000..0e60f4c --- /dev/null +++ b/apps/desktop/src-tauri/src/card.rs @@ -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> = 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 { + SNAPSHOT.lock().ok().and_then(|s| s.clone()) +} + +// ---- show / hide ---- + +static HIDDEN_AT: Mutex> = 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(app: &AppHandle) -> 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(app: &AppHandle, 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(app: &AppHandle) { + if let Some(w) = app.get_webview_window(LABEL) { + let _ = w.hide(); + note_hidden(); + } + } +} + +#[cfg(desktop)] +pub use window::{create, hide, toggle}; diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index aabda11..b45303b 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -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> { 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> { + 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) -> 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] diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 4c2b4cf..1daea05 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -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"]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4b405a1..a5dd222 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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::(scale); + let s = rect.size.to_physical::(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"); diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index 0aab7ed..8d6cfff 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -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 { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 30fb506..405e40e 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -50,7 +50,7 @@ "form-action": "'none'" }, "freezePrototype": true, - "capabilities": ["main-window"] + "capabilities": ["main-window", "card-window"] } }, "bundle": { diff --git a/apps/desktop/src-tauri/tests/card.rs b/apps/desktop/src-tauri/tests/card.rs new file mode 100644 index 0000000..c1ac947 --- /dev/null +++ b/apps/desktop/src-tauri/tests/card.rs @@ -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); +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 9b780bd..1d31b45 100644 --- a/apps/desktop/src-tauri/tests/security.rs +++ b/apps/desktop/src-tauri/tests/security.rs @@ -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 = 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 = 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 = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + let mut want: BTreeSet = 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::>()); + assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).map(|s| s.to_string()).collect::>()); // 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(); diff --git a/apps/desktop/src/card/Card.tsx b/apps/desktop/src/card/Card.tsx new file mode 100644 index 0000000..f7b3c80 --- /dev/null +++ b/apps/desktop/src/card/Card.tsx @@ -0,0 +1,156 @@ +// The tray card: store status at a glance next to the tray icon. It only draws the main window's snapshot (card_get / +// `card-snapshot`) and sends its buttons back (card_action); Refresh is the main window's own Refresh all. +import { ArrowsClockwise, ArrowSquareOut, BellSlash, Bell, DotsThree, Power, Star, WarningOctagon, Pulse, AppWindow } from '@phosphor-icons/react'; +import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; +import type { CardApp, CardRelease, CardSnapshot, CardStore } from './model'; +import { ago, cardT, type CardKey, type CardLang } from './text'; + +export interface CardHost { + get: () => Promise; + onSnapshot: (fn: (s: CardSnapshot) => void) => () => void; + onShown: (fn: () => void) => () => void; + action: (action: 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready', arg?: string) => void; +} + +const route = (app: string, tab: 'releases' | 'reviews' | 'health') => JSON.stringify({ screen: 'app', appId: app, tab }); + +export function Card({ host, lang }: { host: CardHost; lang: CardLang }) { + const T = useCallback((k: CardKey, v?: Record) => cardT(lang, k, v), [lang]); + const [s, setS] = useState(null); + const [menu, setMenu] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const [shown, setShown] = useState(0); + const root = useRef(null); + + useEffect(() => { + host.get().then(x => { if (x) setS(x); }).catch(() => {}); + const a = host.onSnapshot(setS); + const b = host.onShown(() => { setNow(Date.now()); setMenu(false); setShown(n => n + 1); }); + host.action('ready'); + const tm = setInterval(() => setNow(Date.now()), 30_000); + return () => { a(); b(); clearInterval(tm); }; + }, [host]); + // every show: fade in, focus the first control (keyboard users start at Refresh) + useEffect(() => { (root.current?.querySelector('button:not([disabled])') ?? root.current)?.focus(); }, [shown, !!s]); + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); if (menu) setMenu(false); else host.action('hide'); return; } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const all = [...(root.current?.querySelectorAll('button:not([disabled])') ?? [])]; + const i = all.indexOf(document.activeElement as HTMLElement); + const n = all[(i + (e.key === 'ArrowDown' ? 1 : -1) + all.length) % all.length]; + if (n) { e.preventDefault(); n.focus(); } + } + }; + + const store = (st: CardStore) => T(st === 'ios' ? 'appStore' : 'playStore'); + const phase = (r: CardRelease) => (r.phase === 'rollout' ? (r.fraction != null && r.fraction < 1 ? T('rolloutPct', { pct: Math.round(r.fraction * 100) }) : T('rollout')) : T(r.phase)); + const link = (key: string): CardApp | undefined => s?.links.find(l => l.key === key); + const busy = !!s?.busy; + const calm = s && !s.rejections.length && !s.releases.length && !s.health.length && !s.reviews.low.length; + + return ( +
+
+
+ {T('title')} + {s?.muted && {T('muted')}}{T('lastRead', { ago: ago(lang, s?.at ?? null, now) })} +
+ + + {menu && ( +
+ + +
+ +
+ )} +
+ +
+ {!s &&

{T('noData')}

} + {s && calm &&

{s.at ? T('calm') : T('noData')}

} + + {!!s?.rejections.length && ( +
+ {s.rejections.map(r => ( + host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })} + line={<>{T('rejected')} · {store(r.store)}{r.version ? ` ${r.version}` : ''}} sub={r.reason} + consoles={consoles(link(r.app), r.store, host, T)} /> + ))} +
+ )} + + {!!s?.releases.length && ( +
+ {s.releases.map(r => ( + host.action('route', route(r.app, 'releases'))} openLabel={T('openApp', { name: r.name })} + line={<>{phase(r)} · {store(r.store)}{r.version ? ` ${r.version}` : ''}} + bar={r.phase === 'rollout' && r.fraction != null && r.fraction < 1 ? r.fraction : null} + consoles={consoles(link(r.app), r.store, host, T)} /> + ))} +
+ )} + + {!!s?.health.length && ( +
+ {s.health.map(h => ( + host.action('route', route(h.app, 'health'))} openLabel={T('openApp', { name: h.name })} + line={<>{h.kinds.length > 1 ? T('crashAnr') : T(h.kinds[0]!)}} consoles={consoles(link(h.app), 'android', host, T)} /> + ))} +
+ )} + + {s && s.at && ( +
+ {s.reviews.low.map(r => ( + + ))} +
+ )} +
+
+ ); +} + +function consoles(l: CardApp | undefined, st: CardStore, host: CardHost, T: (k: CardKey) => string): ReactNode { + const url = st === 'ios' ? l?.asc : l?.play; + if (!url) return null; + return ; +} + +function Section({ title, tone, aside, children }: { title: string; tone?: 'bad' | 'warn'; aside?: string; children: ReactNode }) { + return ( +
+

{title}{aside && {aside}}

+ {children} +
+ ); +} + +function Row({ hue, name, line, sub, tone, bar, onOpen, openLabel, consoles }: { hue: number; name: string; line: ReactNode; sub?: string | null; tone: string; bar?: number | null; onOpen: () => void; openLabel: string; consoles: ReactNode }) { + return ( +
+ + {consoles} +
+ ); +} + +const initials = (n: string) => n.split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]!.toUpperCase()).join('') || '?'; diff --git a/apps/desktop/src/card/bridge.tsx b/apps/desktop/src/card/bridge.tsx new file mode 100644 index 0000000..4c375c5 --- /dev/null +++ b/apps/desktop/src/card/bridge.tsx @@ -0,0 +1,66 @@ +// Main window side of the tray card: turns what this window already knows (live releases, the last cached reviews and +// health per app, the shared read state of Refresh all, mute) into a CardSnapshot and hands it to the host +// (card_snapshot), which keeps it for the card. No reads of its own: reviews and health come from the cache that the +// window's reads fill, and the card's Refresh arrives as the tray's `tray-action` read (store.tsx), i.e. Refresh all. +import { useEffect, useMemo, useRef, useState } from 'react'; +import { api } from '../api'; +import type { HealthSnapshot, Review } from '../lib/model'; +import { hueOf } from '../lib/model'; +import { useData, useReadState } from '../store'; +import { playUrlFor } from '../ui/app-menu'; +import { cardApi, inTauri as hasHost } from './card-api'; +import { cardSnapshot, type CardInput } from './model'; + +const inTauri = hasHost(); +const ascUrl = (id: string | null | undefined) => (id ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(id)}/distribution` : 'https://appstoreconnect.apple.com/apps'); + +export function TrayCardBridge() { + const d = useData(); + const all = useReadState({ tags: ['all'] }); + const [extra, setExtra] = useState>({}); + const [ask, setAsk] = useState(0); + const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]); + + // the card opened (or a read pass ended): the last cached reviews and health of every app + useEffect(() => { + if (!inTauri) return; + return cardApi.onRequest(() => setAsk(n => n + 1)); + }, []); + const wasBusy = useRef(all.busy); + useEffect(() => { if (wasBusy.current && !all.busy) setAsk(n => n + 1); wasBusy.current = all.busy; }, [all.busy]); + useEffect(() => { + if (!inTauri || !d.ready) return; + let live = true; + Promise.all(visible.map(async a => { + const [r, h] = await Promise.all([ + api.cached<{ data?: Review[] }>('reviews.list', a.target, { days: 30 }).catch(() => null), + api.cached<{ data?: HealthSnapshot }>('health', a.target, {}).catch(() => null), + ]); + return [a.key, { reviews: Array.isArray(r?.value.data) ? r.value.data : null, health: (h?.value.data as HealthSnapshot | undefined) ?? null }] as const; + })).then(rows => { if (live) setExtra(Object.fromEntries(rows)); }).catch(() => {}); + return () => { live = false; }; + }, [ask, d.ready, visible]); + + const snap = useMemo(() => { + const inputs: CardInput[] = visible.map(a => { + const st = d.liveFor(a) ?? d.statusFor(a); + const ios = st?.app?.platforms.ios ?? (a.ios ? { ascAppId: a.ios.app.id } : null); + return { + key: a.key, name: a.name, hue: hueOf(a.name), status: st, + reviews: extra[a.key]?.reviews ?? null, health: extra[a.key]?.health ?? null, + asc: ios ? ascUrl(ios.ascAppId) : null, play: st?.app?.platforms.android || a.android ? playUrlFor(d, a) : null, + }; + }); + return cardSnapshot(inputs, { at: d.live?.value.at ?? d.portfolio?.at ?? null, busy: all.busy, muted: d.muteUntil > Date.now() }); + }, [visible, d, extra, all.busy]); + + const last = useRef(''); + useEffect(() => { + if (!inTauri || !d.ready) return; + const json = JSON.stringify(snap); + if (json === last.current) return; + last.current = json; + cardApi.push(snap).catch(() => {}); + }, [snap, d.ready]); + return null; +} diff --git a/apps/desktop/src/card/card-api.ts b/apps/desktop/src/card/card-api.ts new file mode 100644 index 0000000..6a66afc --- /dev/null +++ b/apps/desktop/src/card/card-api.ts @@ -0,0 +1,25 @@ +// The tray card's host calls (the card page's counterpart of src/api.ts, kept apart so the card bundle stays small). +// Main window: card_snapshot + the `card-request` event. Card window: card_get, card_action and its two events. +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import type { CardSnapshot } from './model'; + +export type CardAction = 'hide' | 'open' | 'route' | 'refresh' | 'mute' | 'quit' | 'external' | 'ready'; + +export const inTauri = (): boolean => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; + +function sub(ev: string, fn: (p: T) => void): () => void { + const un = listen(ev, e => fn(e.payload)); + return () => { un.then(f => f()).catch(() => {}); }; +} + +export const cardApi = { + /** Main window: hands the host the card's snapshot (the host forwards it as `card-snapshot`). */ + push: (snapshot: CardSnapshot) => invoke('card_snapshot', { snapshot }), + /** Main window: the card opened; refresh what is kept per app. */ + onRequest: (fn: () => void) => sub('card-request', () => fn()), + get: () => invoke('card_get'), + onSnapshot: (fn: (s: CardSnapshot) => void) => sub('card-snapshot', fn), + onShown: (fn: () => void) => sub('card-shown', () => fn()), + action: (action: CardAction, arg?: string) => { invoke('card_action', { action, arg: arg ?? null }).catch(() => {}); }, +}; diff --git a/apps/desktop/src/card/card.css b/apps/desktop/src/card/card.css new file mode 100644 index 0000000..827fe12 --- /dev/null +++ b/apps/desktop/src/card/card.css @@ -0,0 +1,58 @@ +/* The tray card (card.html): v2 language at 360 x 480, rounded, its own surface; fades in over 120 ms on every show. */ +html:has(body.tc-page), body.tc-page { margin: 0; height: 100%; background: transparent; overflow: hidden; } +body.tc-page { font: var(--ds-t-body)/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); } +body.tc-page #card { height: 100%; } +body.tc-page.tc-in .tc { animation: tc-in 120ms ease-out; } +:root[data-motion="reduce"] body.tc-page.tc-in .tc { animation: none; } +@keyframes tc-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + +.tc { box-sizing: border-box; height: 100%; max-height: 480px; display: flex; flex-direction: column; border-radius: var(--ds-r-lg); + border: 1px solid var(--ds-stroke); background: radial-gradient(120% 60% at 100% 0%, color-mix(in srgb, var(--accent-solid) 10%, transparent), transparent 60%), var(--ds-raised); overflow: hidden; outline: none; } +.tc-head { position: relative; display: flex; align-items: center; gap: 8px; padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--ds-stroke); } +.tc-title { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.tc-title strong { font-size: var(--ds-t-h3); font-weight: var(--ds-w-title); letter-spacing: -.01em; } +.tc-meta { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: var(--ds-t-micro); } +.tc-muted { display: inline-flex; align-items: center; gap: 3px; color: var(--warn-ink); background: var(--warn-bg); border-radius: var(--ds-r-pill); padding: 0 6px; } +.tc button { font: inherit; color: inherit; cursor: pointer; } +.tc button:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +.tc-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: var(--ds-r-pill); border: 0; font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); } +.tc .tc-primary { background: var(--ds-grad); color: #fff; box-shadow: var(--ds-e1); } +.tc-primary[disabled] { opacity: .7; cursor: default; } +.tc-icon { display: grid; place-items: center; width: 30px; height: 30px; border-radius: var(--ds-r-pill); border: 1px solid var(--ds-stroke); background: var(--ds-surface); } +.tc-icon:hover, .tc-icon[aria-expanded="true"] { background: var(--hover); } +.tc-menu { position: absolute; z-index: 3; right: 12px; top: 50px; min-width: 230px; padding: 5px; border-radius: var(--ds-r); background: var(--ds-raised); border: 1px solid var(--ds-stroke); box-shadow: var(--ds-e3); } +.tc-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: 0; background: none; border-radius: var(--ds-r-xs); text-align: left; font-size: var(--ds-t-meta); } +.tc-menu button:hover, .tc-menu button:focus-visible { background: var(--hover); outline: none; } +.tc-menu hr { border: 0; border-top: 1px solid var(--ds-stroke); margin: 4px 2px; } + +.tc-body { flex: 1; overflow-y: auto; padding: 6px 10px 12px; scrollbar-width: thin; } +.tc-empty { margin: 28px 16px; text-align: center; color: var(--muted); } +.tc-sec { margin-top: 8px; } +.tc-sec h2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin: 6px 6px 4px; font-size: var(--ds-t-micro); font-weight: var(--ds-w-strong); text-transform: uppercase; letter-spacing: .07em; color: var(--muted); } +.tc-sec h2 em { font-style: normal; text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--faint); } +.tc-sec.tc-bad { background: var(--bad-bg); border: 1px solid color-mix(in srgb, var(--bad-solid) 30%, transparent); border-radius: var(--ds-r); padding: 2px 4px 4px; } +.tc-sec.tc-bad h2 { color: var(--bad-ink); } + +.tc-row { display: flex; align-items: center; gap: 2px; border-radius: var(--ds-r-sm); } +.tc-row:hover { background: color-mix(in srgb, var(--hover) 80%, transparent); } +.tc-open { flex: 1; min-width: 0; display: flex; gap: 10px; align-items: flex-start; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); } +.tc-ic { --tint: hsl(var(--tint-h) var(--ds-tint-s) var(--ds-tint-l)); flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; font-size: 10px; font-weight: 700; color: #fff; background: linear-gradient(135deg, var(--tint), hsl(calc(var(--tint-h) + 30) var(--ds-tint-s) 45%)); } +.tc-txt { min-width: 0; display: flex; flex-direction: column; gap: 1px; flex: 1; } +.tc-name { font-weight: var(--ds-w-strong); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tc-line { display: inline-flex; align-items: center; gap: 4px; font-size: var(--ds-t-meta); color: var(--ink-2); } +.tone-bad .tc-line { color: var(--bad-ink); font-weight: var(--ds-w-strong); } +.tone-ok .tc-line { color: var(--ok-ink); } +.tone-info .tc-line { color: var(--info-ink); } +.tone-warn .tc-line { color: var(--warn-ink); } +.tone-accent .tc-line { color: var(--accent); } +.tc-sub { font-size: var(--ds-t-micro); color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.tc-bar { margin-top: 4px; height: 4px; border-radius: 4px; background: var(--ds-inset); overflow: hidden; } +.tc-bar i { display: block; height: 100%; background: var(--ds-grad); border-radius: 4px; } +.tc-ext { flex: none; display: grid; place-items: center; width: 28px; height: 28px; margin-right: 4px; border: 0; background: none; color: var(--muted); border-radius: var(--ds-r-xs); } +.tc-ext:hover { background: var(--hover); color: var(--ink); } + +.tc-review { display: grid; grid-template-columns: auto 1fr; gap: 1px 8px; width: 100%; padding: 7px 6px; border: 0; background: none; text-align: left; border-radius: var(--ds-r-sm); } +.tc-review:hover { background: var(--hover); } +.tc-stars { display: inline-flex; gap: 1px; align-items: center; color: var(--warn-solid); } +.tc-rname { font-weight: var(--ds-w-strong); font-size: var(--ds-t-meta); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tc-rtext { grid-column: 1 / -1; font-size: var(--ds-t-meta); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/apps/desktop/src/card/fixture.ts b/apps/desktop/src/card/fixture.ts new file mode 100644 index 0000000..937ac3e --- /dev/null +++ b/apps/desktop/src/card/fixture.ts @@ -0,0 +1,25 @@ +// Dev only: a card snapshot for the plain browser (no Tauri host), so the card can be looked at in `vite` alone. +import type { CardSnapshot } from './model'; + +const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString(); + +export const CARD_FIXTURE: CardSnapshot = { + at: ago(4), busy: false, muted: false, apps: 6, + rejections: [{ app: 'a1', name: 'Prism Puddle', hue: 280, store: 'ios', version: '2.4.0', reason: 'Guideline 2.1 - Performance - App Completeness: the app crashed on launch on iPad Air (5th generation).' }], + releases: [ + { app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'ios', version: '1.8.2', phase: 'review', fraction: null }, + { app: 'a3', name: 'Moss Runner', hue: 30, store: 'ios', version: '3.0.0', phase: 'approved', fraction: null }, + { app: 'a2', name: 'Tidy Tiles', hue: 160, store: 'android', version: '1.8.1', phase: 'rollout', fraction: 0.2 }, + ], + reviews: { recent: 14, low: [ + { app: 'a2', name: 'Tidy Tiles', store: 'play', rating: 1, text: 'Lost my progress · After the update every level is locked again.', date: ago(90) }, + { app: 'a3', name: 'Moss Runner', store: 'apple', rating: 2, text: 'Too many ads between runs', date: ago(600) }, + ] }, + health: [{ app: 'a4', name: 'Sample Blocks', hue: 210, kinds: ['crash'] }], + links: [ + { key: 'a1', name: 'Prism Puddle', hue: 280, asc: 'https://appstoreconnect.apple.com/apps', play: null }, + { key: 'a2', name: 'Tidy Tiles', hue: 160, asc: 'https://appstoreconnect.apple.com/apps', play: 'https://play.google.com/console' }, + { key: 'a3', name: 'Moss Runner', hue: 30, asc: 'https://appstoreconnect.apple.com/apps', play: null }, + { key: 'a4', name: 'Sample Blocks', hue: 210, asc: null, play: 'https://play.google.com/console' }, + ], +}; diff --git a/apps/desktop/src/card/main.tsx b/apps/desktop/src/card/main.tsx new file mode 100644 index 0000000..c0f3790 --- /dev/null +++ b/apps/desktop/src/card/main.tsx @@ -0,0 +1,53 @@ +// The tray card window's entry (card.html): theme and language from the same prefs as the main window, the host +// through Tauri (or a fixture in a plain dev browser). +import { createRoot } from 'react-dom/client'; +import { applyPrefs, loadPrefs, resolveLang } from '../prefs'; +import { Card, type CardHost } from './Card'; +import { cardApi, inTauri } from './card-api'; +import type { CardSnapshot } from './model'; +import '../styles.css'; +import '../design.css'; +import './card.css'; + +const sys = () => (typeof navigator !== 'undefined' ? navigator.language : 'en'); + +function prefs() { + const p = loadPrefs(); + if (import.meta.env.DEV) { + const q = new URLSearchParams(location.search); + const th = q.get('theme'), lg = q.get('lang'); + if (th === 'light' || th === 'dark') p.theme = th; + if (lg === 'en' || lg === 'tr') p.lang = lg; + } + applyPrefs(p, document.documentElement, sys()); + return resolveLang(p.lang, sys()); +} + +async function devHost(): Promise { + const { CARD_FIXTURE } = await import('./fixture'); + const q = new URLSearchParams(location.search); + const snap: CardSnapshot = q.get('empty') + ? { ...CARD_FIXTURE, rejections: [], releases: [], health: [], reviews: { recent: 0, low: [] } } + : { ...CARD_FIXTURE, busy: !!q.get('busy') }; + return { get: async () => snap, onSnapshot: () => () => {}, onShown: () => () => {}, action: (a, arg) => console.info('card action', a, arg ?? '') }; +} + +const tauriHost: CardHost = cardApi; + +void (async () => { + let lang = prefs(); + const host = inTauri() ? tauriHost : await devHost(); + const root = createRoot(document.getElementById('card')!); + const render = () => root.render(); + render(); + const relang = () => { const l = prefs(); if (l !== lang) { lang = l; render(); } }; + // theme or language changed in the main window (same origin: shared localStorage) + window.addEventListener('storage', relang); + host.onShown(() => { + relang(); + document.body.classList.remove('tc-in'); + void document.body.offsetWidth; + document.body.classList.add('tc-in'); + }); + document.body.classList.add('tc-in'); +})(); diff --git a/apps/desktop/src/card/model.ts b/apps/desktop/src/card/model.ts new file mode 100644 index 0000000..6b59c49 --- /dev/null +++ b/apps/desktop/src/card/model.ts @@ -0,0 +1,86 @@ +// The tray card's data: what the main window already knows, boiled down to the few rows the card shows. Pure (the +// main window's bridge calls it, tests/card-model.test.ts pins it); the card itself only draws a CardSnapshot. +import type { AppStatus } from '../api'; +import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from '../lib/model'; + +export type CardStore = 'ios' | 'android'; +/** A release that moves or waits: in review, rolling out (with its share), approved and waiting for release, halted. */ +export interface CardRelease { app: string; name: string; hue: number; store: CardStore; version: string | null; phase: 'review' | 'rollout' | 'approved' | 'halted'; fraction: number | null } +export interface CardRejection { app: string; name: string; hue: number; store: CardStore; version: string | null; reason: string | null } +export interface CardLowReview { app: string; name: string; store: 'apple' | 'play'; rating: number; text: string; date: string } +export interface CardHealth { app: string; name: string; hue: number; kinds: ('crash' | 'anr')[] } +export interface CardApp { key: string; name: string; hue: number; asc: string | null; play: string | null } +export interface CardSnapshot { + /** Last store read of the main window (ISO), null before any. */ + at: string | null; + /** The main window's Refresh all is running (its shared read state). */ + busy: boolean; + muted: boolean; + apps: number; + rejections: CardRejection[]; + releases: CardRelease[]; + reviews: { recent: number; low: CardLowReview[] }; + health: CardHealth[]; + /** Apps with something on the card, for the quick actions (console links). */ + links: CardApp[]; +} + +export interface CardInput { + key: string; name: string; hue: number; status: AppStatus | undefined; + reviews?: Review[] | null; health?: HealthSnapshot | null; + asc?: string | null; play?: string | null; +} + +export const RECENT_DAYS = 7; +export const LOW_DAYS = 14; +export const MAX_ROWS = 6; +export const MAX_LOW = 3; + +const DAY = 86_400_000; +const one = (s: string, max: number): string => { const x = s.replace(/\s+/g, ' ').trim(); return x.length > max ? `${x.slice(0, max - 1)}…` : x; }; + +/** Crash / ANR rates over the Play bad behavior threshold right now. */ +export function healthAlerts(h: HealthSnapshot | null | undefined): ('crash' | 'anr')[] { + if (!h?.play || isError(h.play)) return []; + const out: ('crash' | 'anr')[] = []; + if (healthSeries(h.play.crash, 'userPerceivedCrashRate', PLAY_THRESHOLDS.crash).tone === 'bad') out.push('crash'); + if (healthSeries(h.play.anr, 'userPerceivedAnrRate', PLAY_THRESHOLDS.anr).tone === 'bad') out.push('anr'); + return out; +} + +export function cardSnapshot(apps: CardInput[], o: { at: string | null; busy: boolean; muted: boolean; now?: number }): CardSnapshot { + const now = o.now ?? Date.now(); + const rejections: CardRejection[] = [], releases: CardRelease[] = [], low: CardLowReview[] = [], health: CardHealth[] = []; + const touched = new Set(); + let recent = 0; + for (const a of apps) { + for (const r of a.status?.release.all ?? []) { + if (r.phase === 'rejected') { rejections.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, reason: r.reason ? one(r.reason, 140) : null }); touched.add(a.key); } + else if (r.phase === 'review' || r.phase === 'sent' || r.phase === 'rollout' || r.phase === 'approved' || r.phase === 'halted') { + releases.push({ app: a.key, name: a.name, hue: a.hue, store: r.store, version: r.version, phase: r.phase === 'sent' ? 'review' : r.phase, fraction: r.phase === 'rollout' ? r.fraction : null }); + touched.add(a.key); + } + } + for (const rv of a.reviews ?? []) { + const age = now - Date.parse(rv.date); + if (!(age > -DAY)) continue; // unreadable date, or far in the future + if (age <= RECENT_DAYS * DAY) recent++; + if (rv.rating <= 2 && age <= LOW_DAYS * DAY) { + low.push({ app: a.key, name: a.name, store: rv.store, rating: rv.rating, text: one([rv.title, rv.body].filter(Boolean).join(' · '), 110), date: rv.date }); + touched.add(a.key); + } + } + const kinds = healthAlerts(a.health); + if (kinds.length) { health.push({ app: a.key, name: a.name, hue: a.hue, kinds }); touched.add(a.key); } + } + // halted first (it needs the owner), then review, waiting for release, rolling out + const order = { halted: 0, review: 1, approved: 2, rollout: 3 } as const; + releases.sort((x, y) => order[x.phase] - order[y.phase] || x.name.localeCompare(y.name)); + low.sort((x, y) => y.date.localeCompare(x.date)); + return { + at: o.at, busy: o.busy, muted: o.muted, apps: apps.length, + rejections: rejections.slice(0, MAX_ROWS), releases: releases.slice(0, MAX_ROWS), + reviews: { recent, low: low.slice(0, MAX_LOW) }, health: health.slice(0, MAX_ROWS), + links: apps.filter(a => touched.has(a.key)).map(a => ({ key: a.key, name: a.name, hue: a.hue, asc: a.asc ?? null, play: a.play ?? null })), + }; +} diff --git a/apps/desktop/src/card/text.ts b/apps/desktop/src/card/text.ts new file mode 100644 index 0000000..5c163ab --- /dev/null +++ b/apps/desktop/src/card/text.ts @@ -0,0 +1,103 @@ +// The tray card's words in every language the app has (en, tr; same keys, tr typed against en). Kept apart from the +// main catalogs so the card bundle stays small. +export const en = { + title: 'Store status', + rejected: 'Rejected', + rejections: 'Needs you', + moving: 'Releases on the move', + review: 'In review', + rollout: 'Rolling out', + rolloutPct: 'Rolling out {pct}%', + approved: 'Waiting for release', + halted: 'Rollout halted', + reviews: 'Reviews', + recent: '{n} new in the last 7 days', + recentNone: 'No new reviews in the last 7 days', + health: 'Crash and ANR alerts', + crash: 'Crash rate over the bad behavior threshold', + anr: 'ANR rate over the bad behavior threshold', + crashAnr: 'Crash and ANR rates over the bad behavior threshold', + calm: 'All quiet: nothing in review, rolling out or waiting on you.', + noData: 'No store read yet. Refresh reads every app.', + lastRead: 'Read {ago}', + never: 'never', + justNow: 'just now', + min: '{n} min ago', + hour: '{n} h ago', + day: '{n} d ago', + refresh: 'Refresh', + refreshing: 'Reading…', + openMain: 'Open storebound', + more: 'More', + mute: 'Mute notifications for 1 hour', + unmute: 'Unmute notifications', + muted: 'Muted', + quit: 'Quit storebound', + openApp: 'Open {name}', + asc: 'App Store Connect', + play: 'Play Console', + appStore: 'App Store', + playStore: 'Play', + stars: '{n} of 5 stars', +}; +export type CardKey = keyof typeof en; + +export const tr: Record = { + title: 'Mağaza durumu', + rejected: 'Reddedildi', + rejections: 'Seni bekliyor', + moving: 'İlerleyen sürümler', + review: 'İncelemede', + rollout: 'Yayılıyor', + rolloutPct: '%{pct} yayılıyor', + approved: 'Yayın bekliyor', + halted: 'Yayılım durduruldu', + reviews: 'Yorumlar', + recent: 'Son 7 günde {n} yeni', + recentNone: 'Son 7 günde yeni yorum yok', + health: 'Çökme ve ANR uyarıları', + crash: 'Çökme oranı kötü davranış eşiğinin üstünde', + anr: 'ANR oranı kötü davranış eşiğinin üstünde', + crashAnr: 'Çökme ve ANR oranları kötü davranış eşiğinin üstünde', + calm: 'Her şey sakin: incelemede, yayılan ya da seni bekleyen bir şey yok.', + noData: 'Henüz mağaza okunmadı. Yenile her uygulamayı okur.', + lastRead: '{ago} okundu', + never: 'hiç', + justNow: 'az önce', + min: '{n} dk önce', + hour: '{n} sa önce', + day: '{n} gün önce', + refresh: 'Yenile', + refreshing: 'Okunuyor…', + openMain: 'storebound\'u aç', + more: 'Diğer', + mute: 'Bildirimleri 1 saat sustur', + unmute: 'Bildirimleri aç', + muted: 'Sessiz', + quit: 'storebound\'dan çık', + openApp: '{name} uygulamasını aç', + asc: 'App Store Connect', + play: 'Play Console', + appStore: 'App Store', + playStore: 'Play', + stars: '5 üzerinden {n} yıldız', +}; + +export const CARD_TEXT = { en, tr } as const; +export type CardLang = keyof typeof CARD_TEXT; + +export function cardT(lang: CardLang, key: CardKey, vars?: Record): string { + const s = CARD_TEXT[lang][key] ?? en[key]; + return vars ? s.replace(/\{(\w+)\}/g, (m, k: string) => (vars[k] === undefined ? m : String(vars[k]))) : s; +} + +/** "3 min ago" style age of the last read. */ +export function ago(lang: CardLang, at: string | null, now: number): string { + if (!at) return cardT(lang, 'never'); + const m = Math.floor((now - Date.parse(at)) / 60_000); + if (!Number.isFinite(m)) return cardT(lang, 'never'); + if (m < 1) return cardT(lang, 'justNow'); + if (m < 60) return cardT(lang, 'min', { n: m }); + if (m < 48 * 60) return cardT(lang, 'hour', { n: Math.floor(m / 60) }); + return cardT(lang, 'day', { n: Math.floor(m / 1440) }); +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 697bcb7..a78cda8 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -7,6 +7,7 @@ import { applyPrefs, loadPrefs, type Prefs, resolveLang, savePrefs } from './pre import { PrefsContext } from './prefs-ctx'; import { installGlobalHandlers, report } from './lib/crash'; import { DataProvider } from './store'; +import { TrayCardBridge } from './card/bridge'; import { ErrorBoundary } from './ui/ErrorBoundary'; import { ContextMenuProvider } from './ui/ContextMenu'; import { ExportProvider } from './ui/Exports'; @@ -37,7 +38,7 @@ function Root() { update({ ...prefs, theme: dark ? 'light' : 'dark' }); }, [prefs, update]); // a language change re-renders the whole tree in the new language - return ; + return ; } installGlobalHandlers(); diff --git a/apps/desktop/tests/card-model.test.ts b/apps/desktop/tests/card-model.test.ts new file mode 100644 index 0000000..4d28d55 --- /dev/null +++ b/apps/desktop/tests/card-model.test.ts @@ -0,0 +1,67 @@ +// The tray card's data mapping: rejections first, moving releases with rollout share, recent and low reviews, Play +// crash / ANR alerts, console links only for apps on the card; and the card words in every language. +import { expect, test } from 'bun:test'; +import type { AppStatus } from '../src/api'; +import { cardSnapshot, healthAlerts, type CardInput } from '../src/card/model'; +import { ago, CARD_TEXT, cardT } from '../src/card/text'; + +const NOW = Date.parse('2026-10-20T12:00:00Z'); +const daysAgo = (n: number) => new Date(NOW - n * 86_400_000).toISOString(); + +function status(all: { store: 'ios' | 'android'; phase: string; version?: string; fraction?: number | null; reason?: string }[]): AppStatus { + return { release: { source: 'live', current: null, next: null, all: all.map(r => ({ store: r.store, version: r.version ?? '1.0', build: null, state: 'X', phase: r.phase, fraction: r.fraction ?? null, track: null, reason: r.reason ?? null })) } } as unknown as AppStatus; +} + +const rows = (rate: number) => [{ date: '2026-10-19', dims: {}, values: { userPerceivedCrashRate: rate, userPerceivedCrashRate28dUserWeighted: rate } }]; + +test('maps releases, rejections, reviews and health', () => { + const apps: CardInput[] = [ + { key: 'a', name: 'Alpha', hue: 10, status: status([{ store: 'ios', phase: 'rejected', reason: 'Guideline 2.1\nincomplete' }, { store: 'android', phase: 'rollout', fraction: 0.2 }]), asc: 'https://appstoreconnect.apple.com/apps/1/distribution', play: 'https://play.google.com/console' }, + { key: 'b', name: 'Beta', hue: 20, status: status([{ store: 'ios', phase: 'sent' }, { store: 'android', phase: 'live' }]), + reviews: [ + { store: 'play', id: '1', rating: 1, title: 'Bad', body: 'Crashes', date: daysAgo(1) }, + { store: 'apple', id: '2', rating: 5, body: 'Great', date: daysAgo(2) }, + { store: 'apple', id: '3', rating: 2, body: 'Old and low', date: daysAgo(20) }, + ] }, + { key: 'c', name: 'Gamma', hue: 30, status: status([{ store: 'ios', phase: 'approved' }, { store: 'android', phase: 'halted' }]), + health: { date: '2026-10-19', play: { window: { start: '', end: '', days: 28 }, crash: rows(0.05), anr: [] } } as never }, + { key: 'd', name: 'Delta', hue: 40, status: status([{ store: 'ios', phase: 'live' }]) }, + ]; + const s = cardSnapshot(apps, { at: daysAgo(0), busy: true, muted: false, now: NOW }); + expect(s.busy).toBe(true); + expect(s.apps).toBe(4); + expect(s.rejections).toEqual([{ app: 'a', name: 'Alpha', hue: 10, store: 'ios', version: '1.0', reason: 'Guideline 2.1 incomplete' }]); + expect(s.releases.map(r => `${r.app}:${r.store}:${r.phase}:${r.fraction}`)).toEqual(['c:android:halted:null', 'b:ios:review:null', 'c:ios:approved:null', 'a:android:rollout:0.2']); + expect(s.reviews.recent).toBe(2); + expect(s.reviews.low).toEqual([{ app: 'b', name: 'Beta', store: 'play', rating: 1, text: 'Bad · Crashes', date: daysAgo(1) }]); + expect(s.health).toEqual([{ app: 'c', name: 'Gamma', hue: 30, kinds: ['crash'] }]); + expect(s.links.map(l => l.key)).toEqual(['a', 'b', 'c']); + expect(s.links[0]!.asc).toContain('appstoreconnect.apple.com'); +}); + +test('nothing read: an empty, calm snapshot', () => { + const s = cardSnapshot([{ key: 'x', name: 'X', hue: 0, status: undefined }], { at: null, busy: false, muted: true, now: NOW }); + expect(s).toMatchObject({ at: null, muted: true, rejections: [], releases: [], health: [], reviews: { recent: 0, low: [] }, links: [] }); +}); + +test('health errors and healthy rates raise no alert', () => { + expect(healthAlerts(null)).toEqual([]); + expect(healthAlerts({ date: '', play: { error: 'no access' } } as never)).toEqual([]); + expect(healthAlerts({ date: '', play: { window: { start: '', end: '', days: 28 }, crash: rows(0.001), anr: [] } } as never)).toEqual([]); +}); + +test('card words: every language has every key, placeholders match', () => { + const ph = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort().join(','); + for (const [lang, cat] of Object.entries(CARD_TEXT)) { + for (const [k, v] of Object.entries(CARD_TEXT.en)) { + const got = (cat as Record)[k]; + expect(got, `${lang}.${k}`).toBeTruthy(); + expect(ph(got!), `${lang}.${k}`).toBe(ph(v)); + } + } + expect(cardT('tr', 'rolloutPct', { pct: 20 })).toBe('%20 yayılıyor'); + expect(ago('en', null, NOW)).toBe('never'); + expect(ago('en', daysAgo(0), NOW)).toBe('just now'); + expect(ago('en', new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 min ago'); + expect(ago('tr', new Date(NOW - 3 * 3600_000).toISOString(), NOW)).toBe('3 sa önce'); +}); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index ebc611c..ba701d6 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -16,5 +16,7 @@ export default defineConfig({ // no inline scripts or styles: the CSP is script-src 'self', style-src 'self' assetsInlineLimit: 0, cssCodeSplit: false, + // two pages: the main window and the tray card (pre-created hidden by src-tauri/src/card.rs) + rollupOptions: { input: { main: 'index.html', card: 'card.html' } }, }, });