+
+
+
+
+ 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