desktop: tray menu panels and pinnable context menus

The tray's right-click menu is drawn by the app: one tree from tray.rs
(stable submenu keys), one frameless always-on-top menu-N window per
panel rendered by tray-menu.html with the design system, placed inside
the monitor's work area. A pure panel book keeps transient and pinned
panels apart; pinned panels (pin button, Ctrl+click, middle click) are
draggable, step aside from each other and are saved in state.json
(menuPins) and restored at start. tray_update pushes the tree to the
open panels, which update in place; closed panels are destroyed. The
native menu, rendered from the same tree, stays as the fallback and on
Linux. Only menu-* windows get the menu_* commands (menu.json).

In-app context menus get the same behaviour through a pure menu stack
(ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels
survive the next menu, are draggable and re-read their entries from
live sources so data changes update them in place.

Also drops a stray diff3 marker line from CHANGELOG.md.
This commit is contained in:
CRTkafa 2026-10-24 13:44:08 +08:00
parent 445ab1adbc
commit 5236f002f1
35 changed files with 2308 additions and 123 deletions

View file

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

View file

@ -0,0 +1,21 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "menu-window",
"description": "The tray menu panels (menu-1, menu-2...: small frameless windows the app opens on a right click on the tray icon, one per open or pinned submenu) with the app's own bundled tray-menu page only. They may listen to app events (the live menu tree) and call the menu commands: read their panel, report their size, open, pin and close panels, pick a row, which runs the same tray action as the native menu, and start a drag of their own window (a pinned panel's header). They never read the stores or reach the sidecar; every command re-checks the window label and page origin in Rust.",
"windows": ["menu-*"],
"local": true,
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten",
"core:window:allow-start-dragging",
"allow-menu-state",
"allow-menu-ready",
"allow-menu-open-sub",
"allow-menu-pin-sub",
"allow-menu-close-subs",
"allow-menu-close",
"allow-menu-dismiss",
"allow-menu-pin",
"allow-menu-select"
]
}

View file

@ -49,6 +49,11 @@ fn guard_card(w: &WebviewWindow) -> Res<()> {
policy::require_card(w.label(), &url, cfg!(debug_assertions)).map_err(Fail::host)
}
fn guard_menu(w: &WebviewWindow) -> Res<()> {
let url = w.url().map_err(|e| Fail::host(e.to_string()))?;
policy::require_menu(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"))
}
@ -1665,3 +1670,79 @@ pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<Strin
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
}
// ---- the tray menu panels (menu_panel.rs; only `menu-*` windows) ----
/// A panel: what it shows (the whole tree and its path into it).
#[tauri::command]
pub fn menu_state(window: WebviewWindow) -> Res<Option<crate::menu_panel::PanelInfo>> {
guard_menu(&window)?;
Ok(crate::menu_panel::panel_state(&window))
}
/// A panel measured itself (CSS px): sized, placed inside the work area and shown.
#[tauri::command]
pub fn menu_ready(window: WebviewWindow, width: f64, height: f64) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::ready(&window, width, height);
Ok(())
}
/// Opens a submenu beside this panel at the row whose top is `top` (CSS px).
#[tauri::command]
pub fn menu_open_sub(window: WebviewWindow, key: String, top: f64, focus: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::open_sub(&window, &key, top, focus, false);
Ok(())
}
/// Ctrl / middle click on a submenu row: opens it as its own pinned panel.
#[tauri::command]
pub fn menu_pin_sub(window: WebviewWindow, key: String, top: f64) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::open_sub(&window, &key, top, false, true);
Ok(())
}
/// The pointer moved to a plain row: this panel's submenus close.
#[tauri::command]
pub fn menu_close_subs(window: WebviewWindow) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::close_subs(&window);
Ok(())
}
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
#[tauri::command]
pub fn menu_close(window: WebviewWindow, refocus_parent: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::close(&window, refocus_parent);
Ok(())
}
/// Esc on the root menu: every transient panel closes.
#[tauri::command]
pub fn menu_dismiss(window: WebviewWindow) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::dismiss(window.app_handle());
Ok(())
}
/// Pins or unpins this panel.
#[tauri::command]
pub fn menu_pin(window: WebviewWindow, pinned: bool) -> Res<()> {
guard_menu(&window)?;
crate::menu_panel::pin(&window, pinned);
Ok(())
}
/// A pick: the same action as the native tray menu (an id tray.rs knows; anything else is refused).
#[tauri::command]
pub fn menu_select(window: WebviewWindow, id: String) -> Res<()> {
guard_menu(&window)?;
if id.len() > 300 || crate::tray::action_of(&id).is_none() {
return Err(Fail::host("unknown menu item"));
}
crate::menu_panel::select(&window, id);
Ok(())
}

View file

@ -56,3 +56,6 @@ pub const COMMANDS: &[&str] = &[
/// 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"];
/// The tray menu panels' commands (capabilities/menu.json grants them to the `menu-*` windows only).
pub const MENU_COMMANDS: &[&str] = &["menu_state", "menu_ready", "menu_open_sub", "menu_pin_sub", "menu_close_subs", "menu_close", "menu_dismiss", "menu_pin", "menu_select"];

View file

@ -8,6 +8,7 @@ pub mod commands;
pub mod confirm;
pub mod downloads;
pub mod logs;
pub mod menu_panel;
pub mod policy;
pub mod profiles;
pub mod registry;
@ -43,17 +44,53 @@ fn started_hidden() -> bool {
std::env::args().any(|a| a == "--hidden")
}
/// Tray icon: a click shows the window; the menu (tray.rs) opens the window, reads the stores, lists the apps with their
/// state, mutes notifications, checks for updates, opens Settings and quits (the only real quit on Windows and Linux,
/// where closing the window keeps the app running in the tray). The window refines tooltip, menu and icon after every
/// read (`tray_update`).
/// Tray icon: a left click shows the tray card (card.rs), a right click the app's own menu panels (menu_panel.rs, with
/// the tree from tray.rs: open the window, read the stores, the apps with their state, mute, updates, Settings and Quit,
/// the only real quit on Windows and Linux, where closing the window keeps the app running in the tray). The window
/// refines tooltip, menu and icon after every read (`tray_update`). Linux trays send no clicks: the native menu,
/// rendered from the same tree, stays on the icon there (and anywhere the panels fail).
fn tray(app: &tauri::App) -> tauri::Result<()> {
let menu = tray::initial_menu(app.handle())?;
let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").menu(&menu).show_menu_on_left_click(false);
let handle = app.handle();
let panels = handle.state::<menu_panel::MenuPanels>();
if let Ok(mut n) = panels.nodes.lock() {
*n = tray::initial_nodes();
}
let mut b = TrayIconBuilder::with_id("main").tooltip("storebound").show_menu_on_left_click(false);
if cfg!(target_os = "linux") {
panels.native.store(true, std::sync::atomic::Ordering::Relaxed);
b = b.menu(&tray::native_menu(handle, &tray::initial_nodes())?);
}
if let Some(icon) = app.default_window_icon() {
b = b.icon(icon.clone());
}
b.on_menu_event(|app, e| match tray::action_of(e.id().as_ref()) {
b.on_menu_event(|app, e| dispatch_menu(app, e.id().as_ref()))
.on_tray_icon_event(|t, e| {
let TrayIconEvent::Click { button, button_state: MouseButtonState::Up, rect, position, .. } = e else { return };
let app = t.app_handle();
match button {
// right click: the menu panels at the click point
MouseButton::Right => menu_panel::open_root(app, position.x, position.y),
// left click: the tray card next to the icon
MouseButton::Left => {
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)?;
Ok(())
}
/// A tray menu pick (a menu panel row or the native fallback menu): tray.rs maps the id to what it does.
pub fn dispatch_menu<R: Runtime>(app: &AppHandle<R>, id: &str) {
match tray::action_of(id) {
Some(tray::Action::Open) => show_main(app),
Some(tray::Action::Quit) => app.exit(0),
Some(tray::Action::Event(word)) => {
@ -64,23 +101,7 @@ fn tray(app: &tauri::App) -> tauri::Result<()> {
let _ = app.emit_to(policy::MAIN, "tray-route", route);
}
None => {}
})
.on_tray_icon_event(|t, e| {
// 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)?;
Ok(())
}
}
/// Silent self-update: at start (after a minute) and every 30 minutes, a signed update from storebound.dev is
@ -282,7 +303,7 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| show_main(app)))
.plugin(tauri_plugin_deep_link::init())
// never restore "visible": a --hidden start would otherwise pop the window up
.plugin(tauri_plugin_window_state::Builder::new().with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build())
.plugin(tauri_plugin_window_state::Builder::new().with_filter(|label| !label.starts_with(menu_panel::LABEL_PREFIX)).with_state_flags(tauri_plugin_window_state::StateFlags::all() & !tauri_plugin_window_state::StateFlags::VISIBLE).build())
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec!["--hidden"])))
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
@ -337,7 +358,10 @@ pub fn run() {
Err(e) => logs::host("warn", &format!("autostart not set: {e}")),
}
app.manage(state);
app.manage(menu_panel::MenuPanels::default());
tray(app)?;
// pinned tray menu panels of the last session come back
menu_panel::restore(app.handle());
if let Err(e) = card::create(app.handle()) {
logs::host("warn", &format!("tray card not created: {e}"));
}
@ -438,6 +462,15 @@ pub fn run() {
commands::ai_fix_undo,
commands::card_get,
commands::card_action,
commands::menu_state,
commands::menu_ready,
commands::menu_open_sub,
commands::menu_pin_sub,
commands::menu_close_subs,
commands::menu_close,
commands::menu_dismiss,
commands::menu_pin,
commands::menu_select,
])
.build(tauri::generate_context!())
.expect("storebound desktop failed to start");

View file

@ -0,0 +1,744 @@
//! The tray's right-click menu, drawn by the app itself instead of the OS menu (tray-menu.html, src/traymenu/).
//!
//! - One tree: tray.rs builds the menu as `Entry`s, turned here into `Node`s. Every submenu has a stable key (its label
//! without the live part), so an open or pinned panel finds its submenu again after the values change.
//! - One window per panel: the root menu and each submenu are their own frameless, always-on-top `menu-N` windows. The
//! page measures itself and calls `menu_ready`; Rust sizes, places (inside the monitor's work area) and shows it.
//! - The panel book (`Book`, pure, tests/menu_panel.rs) records which panels are open, which are pinned and whose child
//! each one is. Transient panels close together (focus left all of them, Esc, a pick); pinned ones stay, can be
//! dragged, step aside so they never cover each other and come back after a restart (`menuPins` in state.json).
//! - Live: every `tray_update` pushes the tree to the open panels (`menu-model`), which update in place. Nothing polls.
//! - Idle: a closed panel is destroyed, never hidden.
//! - Fallback: when a panel window cannot be built or never reports ready, the native tray menu (rendered from the same
//! tree) comes back. Linux always uses it (its tray sends no click events).
//!
//! The top half is pure and covered by tests/menu_panel.rs.
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::tray::Entry;
pub const LABEL_PREFIX: &str = "menu-";
pub const PAGE: &str = "tray-menu.html";
pub const MODEL_EVENT: &str = "menu-model";
pub const PANEL_EVENT: &str = "menu-panel";
/// Gap to the work area's edges (physical px at scale 1).
pub const GAP: f64 = 8.0;
/// The estimated size of a panel before it measured itself (logical px).
pub const EST: (f64, f64) = (280.0, 220.0);
// ---------------------------------------------------------------- model
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Node {
Item { id: String, label: String, enabled: bool },
Sep,
Sub { key: String, label: String, items: Vec<Node> },
}
/// A submenu's stable key: its label without status glyphs and without the live part after ':' / '·' / '('
/// ("Apps (3)" -> "apps", "Apps · 2 need you" -> "apps"). Pinned panels are found again by these keys.
pub fn sub_key(label: &str) -> String {
let head = label.split([':', '\u{00B7}', '(']).next().unwrap_or("");
let mut out = String::new();
for c in head.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
} else if (c == ' ' || c == '-' || c == '_') && !out.is_empty() && !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_end_matches('-').to_string();
if out.is_empty() { "sub".into() } else { out }
}
/// Sibling submenus with the same key get "-2", "-3"...
pub fn unique_keys(mut nodes: Vec<Node>) -> Vec<Node> {
let mut seen: BTreeMap<String, u32> = BTreeMap::new();
for n in nodes.iter_mut() {
if let Node::Sub { key, .. } = n {
let count = seen.entry(key.clone()).or_insert(0);
*count += 1;
if *count > 1 {
*key = format!("{key}-{count}");
}
}
}
nodes
}
/// The tray menu entries as panel nodes. A submenu keeps the fixed key tray.rs gives it (so a translated label never
/// changes it), else the key comes from its label.
pub fn from_entries(entries: &[Entry]) -> Vec<Node> {
let nodes = entries
.iter()
.map(|e| match e {
Entry::Item { id, label, enabled } => Node::Item { id: id.clone(), label: label.clone(), enabled: *enabled },
Entry::Separator => Node::Sep,
Entry::Sub { key, label, items } => Node::Sub { key: if key.is_empty() { sub_key(label) } else { sub_key(key) }, label: label.clone(), items: from_entries(items) },
})
.collect();
unique_keys(nodes)
}
/// The items of the submenu at `path` (empty path: the root) and its label.
pub fn find<'a>(nodes: &'a [Node], path: &[String]) -> Option<(&'a [Node], Option<&'a str>)> {
let mut current = nodes;
let mut label = None;
for key in path {
let (items, l) = current.iter().find_map(|n| match n {
Node::Sub { key: k, label: l, items } if k == key => Some((items.as_slice(), l.as_str())),
_ => None,
})?;
current = items;
label = Some(l);
}
Some((current, label))
}
// ---------------------------------------------------------------- placement
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Anchor {
/// The tray click point (physical).
Point { x: f64, y: f64 },
/// Beside a parent panel: its left and right edge and the row's top (physical).
Beside { left: f64, right: f64, top: f64 },
/// A remembered top-left (pinned panels).
At { x: f64, y: f64 },
}
/// x, y, width, height (physical px).
pub type Rect = (f64, f64, f64, f64);
/// Keeps a panel of size w x h fully inside the work area (with `GAP`).
pub fn clamp_into(x: f64, y: f64, w: f64, h: f64, work: Rect) -> (f64, f64) {
let (wx, wy, ww, wh) = work;
(x.clamp(wx + GAP, (wx + ww - w - GAP).max(wx + GAP)), y.clamp(wy + GAP, (wy + wh - h - GAP).max(wy + GAP)))
}
/// Where a panel of `size` goes for `anchor`, inside the work area: the root opens away from the taskbar / menu bar the
/// click is near, a submenu beside its parent (flipped left at the screen edge), a pinned panel at its spot.
pub fn place_menu(anchor: Anchor, size: (f64, f64), work: Rect) -> (f64, f64) {
let (w, h) = size;
let (wx, wy, ww, wh) = work;
let (x, y) = match anchor {
Anchor::Point { x, y } => (if x + w > wx + ww { x - w } else { x }, if y + h > wy + wh { y - h } else { y }),
Anchor::Beside { left, right, top } => (if right + w <= wx + ww - GAP { right - 2.0 } else { left - w + 2.0 }, top - 5.0),
Anchor::At { x, y } => (x, y),
};
clamp_into(x, y, w, h, work)
}
pub fn overlaps(a: Rect, b: Rect) -> bool {
a.0 < b.0 + b.2 && b.0 < a.0 + a.2 && a.1 < b.1 + b.3 && b.1 < a.1 + a.3
}
/// A spot for a newly pinned panel that covers no other pinned panel: below whatever it overlaps, then the next column.
/// Gives up after a while (a screen full of pins) and returns the clamped wish.
pub fn free_spot(want: Rect, others: &[Rect], work: Rect) -> (f64, f64) {
let (mut x, mut y, w, h) = want;
for _ in 0..64 {
let Some(hit) = others.iter().find(|o| overlaps((x, y, w, h), **o)) else {
return clamp_into(x, y, w, h, work);
};
y = hit.1 + hit.3 + GAP;
if y + h > work.1 + work.3 {
y = work.1 + GAP;
x += w + GAP;
if x + w > work.0 + work.2 {
x = work.0 + GAP;
}
}
}
clamp_into(want.0, want.1, w, h, work)
}
// ---------------------------------------------------------------- panel book
#[derive(Clone, Debug, PartialEq)]
pub struct Panel {
pub path: Vec<String>,
pub pinned: bool,
pub parent: Option<String>,
pub anchor: Anchor,
/// Measured and shown once.
pub shown: bool,
/// Physical top-left once shown (kept for pins).
pub pos: Option<(f64, f64)>,
/// Opened from the keyboard: takes focus and starts on its first row.
pub focus: bool,
}
/// A pinned panel as state.json keeps it (`menuPins`).
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct PinRecord {
pub path: Vec<String>,
pub x: i32,
pub y: i32,
}
/// Which panels are open, which are pinned, who is whose child.
#[derive(Default, Debug)]
pub struct Book {
pub panels: BTreeMap<String, Panel>,
next: u32,
}
impl Book {
fn add(&mut self, path: Vec<String>, pinned: bool, parent: Option<String>, anchor: Anchor) -> String {
self.next += 1;
let label = format!("{LABEL_PREFIX}{}", self.next);
self.panels.insert(label.clone(), Panel { path, pinned, parent, anchor, shown: false, pos: None, focus: false });
label
}
fn remove_all(&mut self, labels: &[String]) {
for l in labels {
self.panels.remove(l);
}
}
pub fn transient(&self) -> Vec<String> {
self.panels.iter().filter(|(_, p)| !p.pinned).map(|(l, _)| l.clone()).collect()
}
/// Transient panels below `label` (children first).
pub fn descendants(&self, label: &str) -> Vec<String> {
let mut out = Vec::new();
let mut frontier = vec![label.to_string()];
while let Some(l) = frontier.pop() {
for (child, p) in &self.panels {
if p.parent.as_deref() == Some(l.as_str()) && !p.pinned && !out.contains(child) {
out.push(child.clone());
frontier.push(child.clone());
}
}
}
out
}
/// A fresh root menu: every transient panel closes, pinned ones stay. Returns the new label and the closed ones.
pub fn open_root(&mut self, anchor: Anchor) -> (String, Vec<String>) {
let closes = self.transient();
self.remove_all(&closes);
(self.add(vec![], false, None, anchor), closes)
}
/// Opens submenu `key` of `parent`; its other transient children close. None when that submenu already is the open
/// child, or the parent is gone. `pinned`: torn off as its own pinned panel (one pinned copy per submenu).
pub fn open_sub(&mut self, parent: &str, key: &str, anchor: Anchor, pinned: bool) -> Option<(String, Vec<String>)> {
let mut path = self.panels.get(parent)?.path.clone();
path.push(key.to_string());
if !pinned && self.panels.values().any(|p| p.parent.as_deref() == Some(parent) && !p.pinned && p.path == path) {
return None;
}
let mut closes = self.descendants(parent);
self.remove_all(&closes);
if pinned {
let old: Vec<String> = self.panels.iter().filter(|(_, p)| p.pinned && p.path == path).map(|(l, _)| l.clone()).collect();
for l in old {
closes.extend(self.close(&l));
}
return Some((self.add(path, true, None, anchor), closes));
}
Some((self.add(path, false, Some(parent.to_string()), anchor), closes))
}
/// Closes `label` and its transient children.
pub fn close(&mut self, label: &str) -> Vec<String> {
if !self.panels.contains_key(label) {
return vec![];
}
let mut out = self.descendants(label);
out.push(label.to_string());
self.remove_all(&out);
out
}
/// Every transient panel closes (Esc on the root, a pick, focus left the menus).
pub fn dismiss(&mut self) -> Vec<String> {
let t = self.transient();
self.remove_all(&t);
t
}
/// Pin or unpin. Pinning detaches the panel from its chain: the transient panels that are not its own children
/// close (returned). A second pinned copy of the same submenu closes too.
pub fn pin(&mut self, label: &str, on: bool) -> Vec<String> {
let Some(p) = self.panels.get(label) else { return vec![] };
let path = p.path.clone();
if !on {
if let Some(p) = self.panels.get_mut(label) {
p.pinned = false;
}
return vec![];
}
let mut closes: Vec<String> = self.panels.iter().filter(|(l, p)| p.pinned && p.path == path && l.as_str() != label).map(|(l, _)| l.clone()).collect();
if let Some(p) = self.panels.get_mut(label) {
p.pinned = true;
p.parent = None;
}
let mine = self.descendants(label);
closes.extend(self.transient().into_iter().filter(|l| l != label && !mine.contains(l)));
self.remove_all(&closes);
closes
}
/// The tree changed: transient panels whose submenu is gone close (with their children). Pinned ones stay and show
/// that their submenu is gone until it comes back (an app that is read again) or they are closed.
pub fn prune(&mut self, nodes: &[Node]) -> Vec<String> {
let gone: Vec<String> = self.panels.iter().filter(|(_, p)| !p.pinned && find(nodes, &p.path).is_none()).map(|(l, _)| l.clone()).collect();
let mut out = Vec::new();
for l in gone {
if self.panels.contains_key(&l) {
out.extend(self.close(&l));
}
}
out
}
/// The pinned panels to save (only placed ones; in label order, so the order is stable).
pub fn pins(&self) -> Vec<PinRecord> {
self.panels
.values()
.filter(|p| p.pinned)
.filter_map(|p| p.pos.map(|(x, y)| PinRecord { path: p.path.clone(), x: x.round() as i32, y: y.round() as i32 }))
.collect()
}
/// Recreates pinned panels from a saved list (duplicates and more than `MAX_PINS` dropped).
pub fn restore(&mut self, pins: &[PinRecord]) -> Vec<String> {
let mut out = Vec::new();
for r in pins.iter().take(MAX_PINS) {
if self.panels.values().any(|p| p.pinned && p.path == r.path) || r.path.len() > 8 {
continue;
}
let (x, y) = (r.x as f64, r.y as f64);
let l = self.add(r.path.clone(), true, None, Anchor::At { x, y });
if let Some(p) = self.panels.get_mut(&l) {
p.pos = Some((x, y));
}
out.push(l);
}
out
}
}
/// At most this many pinned panels are restored after a restart.
pub const MAX_PINS: usize = 12;
/// A panel label the app made (`menu-` and a number), for the window / command checks.
pub fn is_menu_label(label: &str) -> bool {
label.strip_prefix(LABEL_PREFIX).is_some_and(|n| !n.is_empty() && n.len() <= 9 && n.chars().all(|c| c.is_ascii_digit()))
}
// ---------------------------------------------------------------- runtime
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PanelInfo {
pub label: String,
pub path: Vec<String>,
pub pinned: bool,
pub has_parent: bool,
pub focus_first: bool,
pub nodes: Vec<Node>,
}
/// The panels' shared state (managed by the app).
#[derive(Default)]
pub struct MenuPanels {
pub book: Mutex<Book>,
pub nodes: Mutex<Vec<Node>>,
/// The native menu took over (a panel failed, or Linux).
pub native: AtomicBool,
/// Bumped on every move of a pinned panel; the save runs once the moves stop.
moves: AtomicU64,
}
#[cfg(desktop)]
mod runtime {
use super::*;
use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
fn state(app: &AppHandle) -> tauri::State<'_, MenuPanels> {
app.state::<MenuPanels>()
}
fn book<R>(app: &AppHandle, f: impl FnOnce(&mut Book) -> R) -> R {
let s = state(app);
let mut b = s.book.lock().unwrap_or_else(|e| e.into_inner());
f(&mut b)
}
fn nodes(app: &AppHandle) -> Vec<Node> {
state(app).nodes.lock().map(|n| n.clone()).unwrap_or_default()
}
pub fn native_fallback(app: &AppHandle) -> bool {
state(app).native.load(Ordering::Relaxed)
}
/// Puts the native menu (from the current tree) on the tray: the fallback, and Linux.
pub fn set_native(app: &AppHandle) {
let Some(tray) = app.tray_by_id("main") else { return };
match crate::tray::native_menu(app, &nodes(app)) {
Ok(m) => {
let _ = tray.set_menu(Some(m));
}
Err(e) => crate::logs::host("warn", &format!("native tray menu not built: {e}")),
}
}
/// Writes the pinned panels into state.json (`menuPins`).
fn save_pins(app: &AppHandle) {
let pins = book(app, |b| b.pins());
let Some(s) = app.try_state::<crate::commands::AppState>() else { return };
let Ok(mut st) = s.saved.lock() else { return };
if st.menu_pins == pins {
return;
}
st.menu_pins = pins;
if let Err(e) = crate::store::save(&s.data_dir, &st) {
crate::logs::host("warn", &format!("menu pins not saved: {e}"));
}
}
/// Stores the latest tree and pushes it to every open panel (they update in place). Panels of a submenu that is gone
/// close when transient. Cheap when nothing changed or no panel is open.
pub fn set_nodes(app: &AppHandle, next: Vec<Node>) {
let changed = {
let s = state(app);
let mut g = s.nodes.lock().unwrap_or_else(|e| e.into_inner());
let changed = *g != next;
*g = next.clone();
changed
};
if !changed {
return;
}
let (closes, open) = book(app, |b| (b.prune(&next), b.panels.keys().cloned().collect::<Vec<_>>()));
destroy(app, &closes);
for l in open {
let _ = app.emit_to(l.as_str(), MODEL_EVENT, &next);
}
if native_fallback(app) {
set_native(app);
}
}
fn destroy(app: &AppHandle, labels: &[String]) {
for l in labels {
if let Some(w) = app.get_webview_window(l) {
let _ = w.destroy();
}
}
}
fn build_window(app: &AppHandle, label: &str) -> tauri::Result<WebviewWindow> {
let b = WebviewWindowBuilder::new(app, label, WebviewUrl::App(PAGE.into()))
.title("storebound")
.inner_size(EST.0, EST.1)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.resizable(false)
.shadow(true)
.visible(false)
.focused(false);
#[cfg(not(target_os = "macos"))]
let b = b.transparent(true);
let window = b.build()?;
#[cfg(target_os = "macos")]
let _ = window.set_visible_on_all_workspaces(true);
let handle = app.clone();
let me = label.to_string();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Focused(false) => {
let handle = handle.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(160));
close_transient_unless_focused(&handle);
});
}
tauri::WindowEvent::Moved(pos) => {
let pinned = book(&handle, |b| match b.panels.get_mut(&me) {
Some(p) if p.shown => {
p.pos = Some((pos.x as f64, pos.y as f64));
p.pinned
}
_ => false,
});
if pinned {
// a drag sends many moves: save once they stop
let n = state(&handle).moves.fetch_add(1, Ordering::Relaxed) + 1;
let handle = handle.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(400));
if state(&handle).moves.load(Ordering::Relaxed) == n {
save_pins(&handle);
}
});
}
}
_ => {}
});
Ok(window)
}
/// Focus left a menu window: every transient panel closes unless one of them has focus now.
fn close_transient_unless_focused(app: &AppHandle) {
let transient = book(app, |b| b.transient());
if transient.iter().any(|l| app.get_webview_window(l).and_then(|w| w.is_focused().ok()).unwrap_or(false)) {
return;
}
book(app, |b| {
for l in &transient {
b.panels.remove(l);
}
});
destroy(app, &transient);
}
/// The panels cannot be shown: the native menu goes on the tray and opens once now.
fn native_popup(app: &AppHandle) {
if !state(app).native.swap(true, Ordering::Relaxed) {
crate::logs::host("warn", "tray menu panels unavailable: the native menu is used");
}
set_native(app);
let handle = app.clone();
let _ = app.run_on_main_thread(move || {
if let (Some(w), Ok(menu)) = (handle.get_webview_window(crate::policy::MAIN), crate::tray::native_menu(&handle, &nodes(&handle))) {
let _ = w.popup_menu(&menu);
}
});
}
/// A right click on the tray icon: a fresh root menu at the click point (physical px).
pub fn open_root(app: &AppHandle, x: f64, y: f64) {
if native_fallback(app) {
return; // the native menu is on the tray and opens by itself
}
let (label, closes) = book(app, |b| b.open_root(Anchor::Point { x, y }));
destroy(app, &closes);
let handle = app.clone();
std::thread::spawn(move || {
if let Err(e) = build_window(&handle, &label) {
crate::logs::host("warn", &format!("tray menu panel not built: {e}"));
book(&handle, |b| b.panels.remove(&label));
native_popup(&handle);
return;
}
std::thread::sleep(Duration::from_millis(2500));
if book(&handle, |b| b.panels.get(&label).map(|p| p.shown)) == Some(false) {
crate::logs::host("warn", "tray menu panel never reported ready");
let closes = book(&handle, |b| b.close(&label));
destroy(&handle, &closes);
native_popup(&handle);
}
});
}
/// Brings back the pinned panels of the last session (state.json `menuPins`).
pub fn restore(app: &AppHandle) {
let pins = app.try_state::<crate::commands::AppState>().and_then(|s| s.saved.lock().ok().map(|st| st.menu_pins.clone())).unwrap_or_default();
if pins.is_empty() || native_fallback(app) {
return;
}
let labels = book(app, |b| b.restore(&pins));
let handle = app.clone();
std::thread::spawn(move || {
for l in labels {
if build_window(&handle, &l).is_err() {
book(&handle, |b| b.panels.remove(&l));
}
}
});
}
fn work_area(app: &AppHandle, x: f64, y: f64) -> Option<Rect> {
let m = app.monitor_from_point(x, y).ok().flatten().or_else(|| app.primary_monitor().ok().flatten())?;
let w = m.work_area();
Some((w.position.x as f64, w.position.y as f64, w.size.width as f64, w.size.height as f64))
}
fn anchor_point(a: Anchor) -> (f64, f64) {
match a {
Anchor::Point { x, y } | Anchor::At { x, y } => (x, y),
Anchor::Beside { right, top, .. } => (right, top),
}
}
fn outer_rect(w: &WebviewWindow) -> Option<Rect> {
let p = w.outer_position().ok()?;
let s = w.outer_size().ok()?;
Some((p.x as f64, p.y as f64, s.width as f64, s.height as f64))
}
fn pinned_rects(app: &AppHandle, skip: &str) -> Vec<Rect> {
let labels: Vec<String> = book(app, |b| b.panels.iter().filter(|(l, p)| p.pinned && l.as_str() != skip).map(|(l, _)| l.clone()).collect());
labels.iter().filter_map(|l| app.get_webview_window(l).and_then(|w| outer_rect(&w))).collect()
}
// ---- what the panel page calls (commands.rs checks the window first) ----
pub fn panel_state(window: &WebviewWindow) -> Option<PanelInfo> {
let app = window.app_handle();
let p = book(app, |b| b.panels.get(window.label()).cloned())?;
Some(PanelInfo { label: window.label().into(), path: p.path, pinned: p.pinned, has_parent: p.parent.is_some(), focus_first: p.focus, nodes: nodes(app) })
}
/// The panel measured itself (CSS px): size it, place it inside the work area, show it the first time.
pub fn ready(window: &WebviewWindow, width: f64, height: f64) {
let app = window.app_handle().clone();
let Some(panel) = book(&app, |b| b.panels.get(window.label()).cloned()) else { return };
let scale = window.scale_factor().unwrap_or(1.0);
let size = ((width.clamp(120.0, 600.0) * scale).ceil(), (height.clamp(40.0, 900.0) * scale).ceil());
let (ax, ay) = panel.pos.unwrap_or_else(|| anchor_point(panel.anchor));
let Some(work) = work_area(&app, ax, ay) else { return };
let (x, y) = if panel.shown {
let (x, y, _, _) = outer_rect(window).unwrap_or((ax, ay, 0.0, 0.0));
clamp_into(x, y, size.0, size.1, work)
} else if let Some((x, y)) = panel.pos {
clamp_into(x, y, size.0, size.1, work)
} else {
place_menu(panel.anchor, size, work)
};
let _ = window.set_size(PhysicalSize::new(size.0 as u32, size.1 as u32));
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
if !panel.shown {
book(&app, |b| {
if let Some(p) = b.panels.get_mut(window.label()) {
p.shown = true;
p.pos = Some((x, y));
}
});
let _ = window.show();
if panel.focus || (panel.parent.is_none() && !panel.pinned) {
let _ = window.set_focus();
}
if panel.pinned {
save_pins(&app);
}
}
}
/// Opens submenu `key` beside this panel at the row whose top is `top` (CSS px). `pinned`: torn off (Ctrl / middle
/// click), placed so it covers no other pinned panel.
pub fn open_sub(window: &WebviewWindow, key: &str, top: f64, focus: bool, pinned: bool) {
let app = window.app_handle().clone();
let Some((wx, wy, ww, _)) = outer_rect(window) else { return };
let scale = window.scale_factor().unwrap_or(1.0);
let anchor = Anchor::Beside { left: wx, right: wx + ww, top: wy + top.max(0.0) * scale };
let Some((label, closes)) = book(&app, |b| b.open_sub(window.label(), key, anchor, pinned)) else { return };
destroy(&app, &closes);
if pinned {
let others = pinned_rects(&app, &label);
if let Some(work) = work_area(&app, wx, wy) {
let est = (EST.0 * scale, EST.1 * scale);
let (x, y) = place_menu(anchor, est, work);
let spot = free_spot((x, y, est.0, est.1), &others, work);
book(&app, |b| {
if let Some(p) = b.panels.get_mut(&label) {
p.pos = Some(spot);
}
});
}
}
book(&app, |b| {
if let Some(p) = b.panels.get_mut(&label) {
p.focus = focus;
}
});
let handle = app.clone();
std::thread::spawn(move || {
if build_window(&handle, &label).is_err() {
book(&handle, |b| b.panels.remove(&label));
}
});
}
/// The pointer moved to a plain row: this panel's transient submenus close.
pub fn close_subs(window: &WebviewWindow) {
let app = window.app_handle().clone();
let closes = book(&app, |b| {
let d = b.descendants(window.label());
for l in &d {
b.panels.remove(l);
}
d
});
destroy(&app, &closes);
}
/// Closes this panel (Left / Esc in a submenu, X on a pinned one).
pub fn close(window: &WebviewWindow, refocus_parent: bool) {
let app = window.app_handle().clone();
let (closes, parent, was_pinned) = book(&app, |b| {
let p = b.panels.get(window.label()).cloned();
(b.close(window.label()), p.as_ref().and_then(|p| p.parent.clone()), p.is_some_and(|p| p.pinned))
});
if refocus_parent {
if let Some(w) = parent.and_then(|p| app.get_webview_window(&p)) {
let _ = w.set_focus();
let _ = app.emit_to(w.label(), PANEL_EVENT, "focus");
}
}
destroy(&app, &closes);
if was_pinned {
save_pins(&app);
}
}
/// Esc on the root menu: every transient panel closes.
pub fn dismiss(app: &AppHandle) {
let closes = book(app, |b| b.dismiss());
destroy(app, &closes);
}
/// Pin or unpin this panel. Pinning steps it aside from the other pinned panels.
pub fn pin(window: &WebviewWindow, on: bool) {
let app = window.app_handle().clone();
let label = window.label().to_string();
let closes = book(&app, |b| b.pin(&label, on));
destroy(&app, &closes);
if on {
let others = pinned_rects(&app, &label);
if let Some(me) = outer_rect(window) {
if let Some(work) = work_area(&app, me.0, me.1) {
let (x, y) = free_spot(me, &others, work);
if (x, y) != (me.0, me.1) {
let _ = window.set_position(PhysicalPosition::new(x as i32, y as i32));
}
book(&app, |b| {
if let Some(p) = b.panels.get_mut(&label) {
p.pos = Some((x, y));
}
});
}
}
}
let _ = app.emit_to(label.as_str(), PANEL_EVENT, if on { "pinned" } else { "unpinned" });
save_pins(&app);
}
/// A pick: the same action as the native menu. Transient panels close; pinned ones stay and update in place.
pub fn select(window: &WebviewWindow, id: String) {
let app = window.app_handle().clone();
let pinned = book(&app, |b| b.panels.get(window.label()).map(|p| p.pinned).unwrap_or(false));
if !pinned {
dismiss(&app);
}
let handle = app.clone();
let _ = app.run_on_main_thread(move || crate::dispatch_menu(&handle, &id));
}
}
#[cfg(desktop)]
pub use runtime::*;

View file

@ -56,6 +56,17 @@ pub fn require_card(label: &str, url: &Url, dev: bool) -> Result<(), String> {
Ok(())
}
/// The tray menu panels (`menu-1`, `menu-2`... menu_panel.rs): they may call only the menu commands.
pub fn require_menu(label: &str, url: &Url, dev: bool) -> Result<(), String> {
if !crate::menu_panel::is_menu_label(label) {
return Err(format!("not allowed from window '{label}'"));
}
if !is_local_origin(url, dev) {
return Err("not allowed from this page".into());
}
Ok(())
}
/// Method kinds the sidecar announces in its `session.hello` descriptors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {

View file

@ -45,11 +45,14 @@ pub struct State {
pub overrides: Overrides,
#[serde(default)]
pub settings: Settings,
/// Pinned tray menu panels and where they sit (menu_panel.rs); restored at start.
#[serde(default, rename = "menuPins", skip_serializing_if = "Vec::is_empty")]
pub menu_pins: Vec<crate::menu_panel::PinRecord>,
}
impl Default for State {
fn default() -> State {
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default() }
State { version: VERSION, projects: vec![], accounts: vec![], studios: vec![], overrides: Overrides::default(), settings: Settings::default(), menu_pins: vec![] }
}
}
@ -121,6 +124,7 @@ pub fn migrate(mut v: Value) -> (State, u32, Vec<String>) {
studios: list(&o, "studios", &mut notes),
overrides: one(&o, "overrides", &mut notes),
settings: one(&o, "settings", &mut notes),
menu_pins: list(&o, "menuPins", &mut notes),
};
// two entries with one id (hand-edited or merged files): the first one wins
let mut seen = std::collections::HashSet::new();

View file

@ -1,7 +1,7 @@
//! The tray: tooltip, attention icon, context menu and the "where to go" memory of the last notification.
//!
//! The window pushes a small summary after every store read (`tray_update`); this module turns it into a menu model
//! (pure, tested in tests/tray.rs), rebuilds the menu, sets the tooltip and swaps the icon for one with a red dot while
//! (pure, tested in tests/tray.rs), pushes it to the menu panels (menu_panel.rs), sets the tooltip and swaps the icon for one with a red dot while
//! something needs the owner. Menu clicks come back as events to the window: `tray-route` (a route as JSON) and
//! `tray-action` (`read`, `mute-toggle`).
//!
@ -14,7 +14,9 @@ use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::Value;
use tauri::image::Image;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::menu::{IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::menu_panel::{from_entries, Node};
use tauri::{AppHandle, Runtime};
pub const ID_OPEN: &str = "tray-open";
@ -88,7 +90,8 @@ pub struct TraySummary {
pub enum Entry {
Item { id: String, label: String, enabled: bool },
Separator,
Sub { label: String, items: Vec<Entry> },
/// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language.
Sub { key: String, label: String, items: Vec<Entry> },
}
fn clean(s: &str, max: usize) -> String {
@ -132,7 +135,7 @@ pub fn menu_model(s: &TraySummary) -> Vec<Entry> {
item(ID_OPEN, &l.open),
item(ID_READ, &l.read_now),
Entry::Separator,
Entry::Sub { label: clean(&l.apps, 40), items: apps },
Entry::Sub { key: "apps".into(), label: clean(&l.apps, 40), items: apps },
Entry::Separator,
item(ID_MUTE, if s.muted { &l.unmute } else { &l.mute }),
item(ID_UPDATE, &l.update),
@ -233,40 +236,45 @@ pub fn take_pending() -> Option<String> {
static LAST: Mutex<Option<String>> = Mutex::new(None);
fn build_menu<R: Runtime>(app: &AppHandle<R>, model: &[Entry]) -> tauri::Result<Menu<R>> {
let menu = Menu::new(app)?;
for e in model {
match e {
Entry::Item { id, label, enabled } => menu.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?,
Entry::Separator => menu.append(&PredefinedMenuItem::separator(app)?)?,
Entry::Sub { label, items } => {
let sub = Submenu::new(app, label, true)?;
for it in items {
if let Entry::Item { id, label, enabled } = it {
sub.append(&MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)?;
}
}
menu.append(&sub)?;
/// The native menu items for a panel tree (the fallback, and Linux), nested to any depth.
fn native_items<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Vec<Box<dyn IsMenuItem<R>>>> {
let mut out: Vec<Box<dyn IsMenuItem<R>>> = Vec::new();
for n in nodes {
match n {
Node::Item { id, label, enabled } => out.push(Box::new(MenuItem::with_id(app, id, label, *enabled, None::<&str>)?)),
Node::Sep => out.push(Box::new(PredefinedMenuItem::separator(app)?)),
Node::Sub { label, items, .. } => {
let children = native_items(app, items)?;
let refs: Vec<&dyn IsMenuItem<R>> = children.iter().map(|b| b.as_ref()).collect();
out.push(Box::new(Submenu::with_items(app, label, true, &refs)?));
}
}
}
Ok(menu)
Ok(out)
}
/// The menu shown before the window has sent anything (English words).
pub fn initial_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
build_menu(app, &menu_model(&TraySummary::default()))
/// The native tray menu, rendered from the same tree as the panels.
pub fn native_menu<R: Runtime>(app: &AppHandle<R>, nodes: &[Node]) -> tauri::Result<Menu<R>> {
let items = native_items(app, nodes)?;
let refs: Vec<&dyn IsMenuItem<R>> = items.iter().map(|b| b.as_ref()).collect();
Menu::with_items(app, &refs)
}
/// Applies a summary: tooltip, menu and icon of the tray with id `main`. Skips the work when nothing changed.
pub fn apply<R: Runtime>(app: &AppHandle<R>, s: &TraySummary) -> tauri::Result<()> {
/// The tree before the window has sent anything (English words).
pub fn initial_nodes() -> Vec<Node> {
from_entries(&menu_model(&TraySummary::default()))
}
/// Applies a summary: tooltip and icon of the tray with id `main`, and the menu tree, which goes live to the open menu
/// panels (and to the native menu when that is in use). Skips the work when nothing changed.
pub fn apply(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> {
let Some(tray) = app.tray_by_id("main") else { return Ok(()) };
let key = format!("{s:?}");
if LAST.lock().ok().is_some_and(|l| l.as_deref() == Some(key.as_str())) {
return Ok(());
}
tray.set_tooltip(Some(tooltip_text(s)))?;
tray.set_menu(Some(build_menu(app, &menu_model(s))?))?;
crate::menu_panel::set_nodes(app, from_entries(&menu_model(s)));
if let Some(icon) = app.default_window_icon() {
let img = if s.attention > 0 { Image::new_owned(with_attention_dot(icon.rgba(), icon.width(), icon.height()), icon.width(), icon.height()) } else { icon.clone() };
tray.set_icon(Some(img))?;

View file

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

View file

@ -0,0 +1,203 @@
//! The tray menu panels' pure half: the tree and its stable keys, live updates, placement inside the work area, the
//! panel book (transient vs pinned, parents, restore after a restart).
use storebound_desktop::menu_panel::*;
use storebound_desktop::store::State;
use storebound_desktop::tray::{menu_model, Entry, TrayApp, TrayLabels, TraySummary};
const WORK: Rect = (0.0, 0.0, 1920.0, 1040.0);
const AT: Anchor = Anchor::At { x: 0.0, y: 0.0 };
fn summary(state: &str, apps_label: &str) -> TraySummary {
TraySummary {
apps: vec![TrayApp { key: "k1".into(), name: "First".into(), state: state.into() }, TrayApp { key: "k2".into(), name: "Second".into(), state: String::new() }],
labels: TrayLabels { apps: apps_label.into(), ..TrayLabels::default() },
..TraySummary::default()
}
}
fn tree() -> Vec<Node> {
from_entries(&[
Entry::Item { id: "open".into(), label: "Open".into(), enabled: true },
Entry::Separator,
Entry::Sub { key: String::new(), label: "Fleet".into(), items: vec![Entry::Sub { key: String::new(), label: "Recently lowered".into(), items: vec![] }] },
Entry::Sub { key: String::new(), label: "Profile: Work".into(), items: vec![] },
])
}
#[test]
fn keys_ignore_live_parts_and_stay_unique() {
assert_eq!(sub_key("Profile: Work"), "profile");
assert_eq!(sub_key("\u{25CF} First \u{00B7} ok"), "first");
assert_eq!(sub_key("Queue (1 running, 2 queued)"), "queue");
assert_eq!(sub_key("\u{2716}"), "sub");
let nodes = unique_keys(vec![Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }, Node::Sub { key: "a".into(), label: "A".into(), items: vec![] }]);
assert!(matches!(&nodes[1], Node::Sub { key, .. } if key == "a-2"));
}
#[test]
fn find_walks_keys() {
let t = tree();
let (items, label) = find(&t, &["fleet".into(), "recently-lowered".into()]).unwrap();
assert_eq!((items.len(), label), (0, Some("Recently lowered")));
assert!(find(&t, &["nope".into()]).is_none());
assert_eq!(find(&t, &[]).unwrap().0.len(), 4);
}
#[test]
fn the_tray_tree_keeps_its_apps_key_in_every_language_and_value() {
let en = from_entries(&menu_model(&summary("App Store: In review", "Apps")));
let tr = from_entries(&menu_model(&summary("App Store: Live", "Uygulamalar")));
let key = |n: &[Node]| n.iter().find_map(|n| if let Node::Sub { key, .. } = n { Some(key.clone()) } else { None }).unwrap();
assert_eq!(key(&en), "apps");
assert_eq!(key(&tr), "apps");
// a live value changed: the tree differs (so it is pushed), the open Apps panel still resolves
assert_ne!(en, tr);
let (items, _) = find(&tr, &["apps".into()]).unwrap();
assert!(matches!(&items[0], Node::Item { id, label, .. } if id == "tray-app:k1" && label.contains("Live")));
// the same summary twice: the same tree (nothing is pushed)
assert_eq!(from_entries(&menu_model(&summary("x", "Apps"))), from_entries(&menu_model(&summary("x", "Apps"))));
}
#[test]
fn the_tree_serializes_as_the_panel_page_reads_it() {
let v = serde_json::to_value(tree()).unwrap();
assert_eq!(v[0], serde_json::json!({ "kind": "item", "id": "open", "label": "Open", "enabled": true }));
assert_eq!(v[1], serde_json::json!({ "kind": "sep" }));
assert_eq!(v[2]["kind"], "sub");
assert_eq!(v[2]["key"], "fleet");
}
#[test]
fn root_opens_away_from_the_taskbar_and_stays_inside() {
// bottom taskbar, icon at the right: above and left of the click
let (x, y) = place_menu(Anchor::Point { x: 1900.0, y: 1060.0 }, (300.0, 400.0), WORK);
assert!(x + 300.0 <= 1920.0 - GAP && y + 400.0 <= 1040.0 - GAP && x >= 0.0 && y >= 0.0);
// macOS menu bar at the top: below it
let (_, y) = place_menu(Anchor::Point { x: 1800.0, y: 0.0 }, (300.0, 400.0), (0.0, 25.0, 1920.0, 1055.0));
assert!(y >= 25.0 + GAP);
// a second monitor left of the primary (negative x)
let (x, _) = place_menu(Anchor::Point { x: -10.0, y: 500.0 }, (300.0, 400.0), (-1920.0, 0.0, 1920.0, 1080.0));
assert!(x + 300.0 <= -GAP + 0.001 && x >= -1920.0);
// a panel taller than the work area: pinned to the top edge, never above it
let (_, y) = place_menu(Anchor::Point { x: 100.0, y: 100.0 }, (300.0, 2000.0), WORK);
assert_eq!(y, GAP);
}
#[test]
fn submenus_open_beside_and_flip_left_at_the_edge() {
let (x, _) = place_menu(Anchor::Beside { left: 1600.0, right: 1880.0, top: 100.0 }, (280.0, 200.0), WORK);
assert!(x + 280.0 <= 1602.0);
let (x, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 100.0 }, (280.0, 200.0), WORK);
assert!((378.0..=380.0).contains(&x) && (90.0..=100.0).contains(&y));
// a row near the bottom: the submenu moves up into the work area
let (_, y) = place_menu(Anchor::Beside { left: 100.0, right: 380.0, top: 1000.0 }, (280.0, 300.0), WORK);
assert!(y + 300.0 <= 1040.0 - GAP);
}
#[test]
fn pinned_panels_step_aside_and_stay_inside() {
let others = [(100.0, 100.0, 280.0, 300.0), (100.0, 408.0, 280.0, 300.0)];
let (x, y) = free_spot((120.0, 120.0, 280.0, 300.0), &others, WORK);
assert!(others.iter().all(|o| !overlaps((x, y, 280.0, 300.0), *o)));
assert!(x >= 0.0 && y >= 0.0 && x + 280.0 <= 1920.0 && y + 300.0 <= 1040.0);
// no room left below: the next column
let col = [(8.0, 8.0, 280.0, 1000.0)];
let (x, _) = free_spot((8.0, 8.0, 280.0, 300.0), &col, WORK);
assert!(x >= 288.0);
// nothing in the way: where it was
assert_eq!(free_spot((500.0, 500.0, 280.0, 300.0), &others, WORK), (500.0, 500.0));
}
#[test]
fn several_menus_open_and_pinned_ones_survive_a_new_menu() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
assert!(b.open_sub(&root, "fleet", AT, false).is_none(), "the same submenu again is a no-op");
let (deep, _) = b.open_sub(&fleet, "recently-lowered", AT, false).unwrap();
assert_eq!(b.descendants(&root).len(), 2);
// pinning the Fleet panel detaches it; the root closes, its own child stays its own
let closes = b.pin(&fleet, true);
assert_eq!(closes, vec![root.clone()]);
assert!(b.panels[&fleet].pinned && b.panels[&fleet].parent.is_none());
assert_eq!(b.descendants(&fleet), vec![deep.clone()]);
let (root2, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
assert_eq!(closes, vec![deep]);
assert!(b.panels.contains_key(&fleet));
assert_eq!(b.transient(), vec![root2.clone()]);
// a pick / Esc: transient panels go, the pinned one stays
assert_eq!(b.dismiss(), vec![root2]);
assert_eq!(b.panels.len(), 1);
// unpinned, it behaves like a menu again and goes with the next one
b.pin(&fleet, false);
let (_, closes) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
assert!(closes.contains(&fleet));
}
#[test]
fn a_torn_off_submenu_keeps_one_pinned_copy() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (p1, _) = b.open_sub(&root, "fleet", AT, true).unwrap();
let (p2, closes) = b.open_sub(&root, "fleet", AT, true).unwrap();
assert!(closes.contains(&p1) && b.panels[&p2].pinned && !b.panels.contains_key(&p1));
assert_eq!(b.panels[&p2].path, vec!["fleet".to_string()]);
// the root stays open (a tear-off is not a pick)
assert!(b.panels.contains_key(&root));
// closing a panel that is already gone does nothing
assert!(b.close("menu-99").is_empty());
}
#[test]
fn a_live_update_closes_nothing_that_still_exists() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (profile, _) = b.open_sub(&root, "profile", AT, true).unwrap();
let (fleet, _) = b.open_sub(&root, "fleet", AT, false).unwrap();
assert!(b.prune(&tree()).is_empty());
// the Fleet submenu is gone from the tree: its transient panel closes, the pinned Profile panel stays even when its
// submenu goes too (it shows that it is gone)
let smaller = from_entries(&[Entry::Item { id: "open".into(), label: "Open".into(), enabled: true }]);
assert_eq!(b.prune(&smaller), vec![fleet]);
assert!(b.panels.contains_key(&root) && b.panels.contains_key(&profile));
}
#[test]
fn pins_are_saved_in_the_state_and_restored_after_a_restart() {
let mut b = Book::default();
let (root, _) = b.open_root(Anchor::Point { x: 0.0, y: 0.0 });
let (p, _) = b.open_sub(&root, "apps", AT, true).unwrap();
// not placed yet: nothing to save
assert!(b.pins().is_empty());
b.panels.get_mut(&p).unwrap().pos = Some((40.4, 50.6));
let pins = b.pins();
assert_eq!(pins, vec![PinRecord { path: vec!["apps".into()], x: 40, y: 51 }]);
// through state.json
let st = State { menu_pins: pins.clone(), ..State::default() };
let text = serde_json::to_string(&st).unwrap();
assert!(text.contains("\"menuPins\""));
let back: State = serde_json::from_str(&text).unwrap();
assert_eq!(back.menu_pins, pins);
// an empty list is not written at all (older files stay as they were)
assert!(!serde_json::to_string(&State::default()).unwrap().contains("menuPins"));
let mut fresh = Book::default();
let mut saved = back.menu_pins.clone();
saved.push(saved[0].clone()); // a duplicate is dropped
let labels = fresh.restore(&saved);
assert_eq!(labels.len(), 1);
let r = &fresh.panels[&labels[0]];
assert!(r.pinned && r.path == vec!["apps".to_string()] && r.pos == Some((40.0, 51.0)) && r.parent.is_none());
// a restored pin survives the next menu
let (_, closes) = fresh.open_root(Anchor::Point { x: 0.0, y: 0.0 });
assert!(closes.is_empty());
let many: Vec<PinRecord> = (0..40).map(|i| PinRecord { path: vec![format!("s{i}")], x: 0, y: 0 }).collect();
assert_eq!(Book::default().restore(&many).len(), MAX_PINS);
}
#[test]
fn panel_labels_are_menu_and_a_number() {
assert!(is_menu_label("menu-1") && is_menu_label("menu-204"));
for bad in ["menu-", "menu-a", "card", "main", "menu-1-2", "xmenu-1"] {
assert!(!is_menu_label(bad), "{bad}");
}
}

View file

@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use storebound_desktop::policy::*;
use storebound_desktop::{CARD_COMMANDS, COMMANDS};
use storebound_desktop::{CARD_COMMANDS, COMMANDS, MENU_COMMANDS};
use url::Url;
fn root() -> PathBuf {
@ -26,7 +26,7 @@ fn one_capability_for_the_main_window_with_exactly_the_app_commands() {
let dir = root().join("capabilities");
let mut files: Vec<PathBuf> = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
files.sort();
assert_eq!(files.len(), 2, "main and card capability files expected, found {files:?}");
assert_eq!(files.len(), 3, "main, card and menu 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"]));
@ -71,6 +71,45 @@ fn the_card_window_gets_only_the_card_commands() {
assert!(require_main("card", &u("tauri://localhost/card.html"), false).is_err());
}
#[test]
fn the_menu_panels_get_only_the_menu_commands() {
let cap = read_json(&root().join("capabilities/menu.json"));
assert_eq!(cap["identifier"], "menu-window");
assert_eq!(cap["windows"], json!(["menu-*"]));
assert!(cap.get("webviews").is_none() && cap.get("remote").is_none());
assert_ne!(cap["local"], json!(false));
let got: BTreeSet<String> = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect();
let mut want: BTreeSet<String> = MENU_COMMANDS.iter().map(|c| format!("allow-{}", c.replace('_', "-"))).collect();
want.insert("core:event:allow-listen".into());
want.insert("core:event:allow-unlisten".into());
// a pinned panel is dragged by its header (data-tauri-drag-region): only its own window
want.insert("core:window:allow-start-dragging".into());
assert_eq!(got, want);
// the menu commands never leak into the main or card capability
for other in ["main.json", "card.json"] {
let cap = read_json(&root().join("capabilities").join(other));
let perms = cap["permissions"].to_string();
for c in MENU_COMMANDS {
assert!(!perms.contains(&format!("\"allow-{}\"", c.replace('_', "-"))), "{c} granted in {other}");
}
}
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
for c in MENU_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_menu(&window)?;"), "{c} must start with guard_menu(&window)?");
}
assert!(require_menu("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_ok());
assert!(require_menu("menu-12", &u("http://tauri.localhost/tray-menu.html"), false).is_ok());
for bad in ["main", "card", "menu-", "menu-x", "menu-1a", "menus-1"] {
assert!(require_menu(bad, &u("tauri://localhost/tray-menu.html"), false).is_err(), "{bad}");
}
assert!(require_menu("menu-1", &u("https://example.com/tray-menu.html"), false).is_err());
assert!(require_main("menu-1", &u("tauri://localhost/tray-menu.html"), false).is_err());
assert!(require_card("menu-1", &u("tauri://localhost/tray-menu.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();
@ -81,7 +120,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().chain(CARD_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_COMMANDS).map(|s| s.to_string()).collect::<Vec<_>>());
// every command checks the caller first
let cmds = fs::read_to_string(root().join("src/commands.rs")).unwrap();
for c in COMMANDS {
@ -109,7 +148,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", "card-window"]));
assert_eq!(conf["app"]["security"]["capabilities"], json!(["main-window", "card-window", "menu-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();

View file

@ -23,7 +23,7 @@ fn ids(m: &[Entry]) -> Vec<String> {
fn menu_has_every_entry_in_order() {
let m = menu_model(&summary());
assert_eq!(ids(&m), vec![ID_OPEN, ID_READ, ID_MUTE, ID_UPDATE, ID_SETTINGS, ID_QUIT]);
let Entry::Sub { label, items } = &m[3] else { panic!("the Apps submenu comes after the first separator") };
let Entry::Sub { label, items, .. } = &m[3] else { panic!("the Apps submenu comes after the first separator") };
assert_eq!(label, "Apps");
assert_eq!(items.len(), 2);
let Entry::Item { id, label, enabled } = &items[0] else { panic!() };