diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a15c38..ac914f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ ## Unreleased +- Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged. +- Desktop right-click menus in the window work the same way: a menu's pin button keeps it open as a floating panel, a submenu can be torn off (its pin button, Ctrl+click or a middle click), pinned panels survive the next menu, can be dragged, and update in place when the data behind them changes ("Gone" when the thing they were for left the screen). - `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. - `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). - `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. -||||||| parent of ae8d773 (apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs) - `apple signing ensure` signs every target of the Xcode project, not only the app: it reads the pbxproj targets (app, app extensions such as a share extension or widget, watch app, App Clip: Release PRODUCT_BUNDLE_IDENTIFIER and entitlements), registers each extension's bundle id (`POST /v1/bundleIds`, platform from the profile type), enables the capabilities its entitlements need (read back) and, with manual signing, creates its App Store profile; `--dry` lists every target. `apple.signing.extensions` (`bundleId`, `name`, `capabilities`, `appGroups`) replaces the detected extensions when detection is wrong; `status` shows a target table. - App Groups: the API cannot assign a group to a bundle id, so `signing status` / `ensure` and (manual signing) `apple build` decode the profile and compare its `com.apple.security.application-groups` with the target's entitlements; a gap names the developer portal link (`.../identifiers/bundleId/edit/`) and the steps to tick the group. `signing ensure --refresh-profile` makes the profile again afterwards; `apple build --allow-app-group-gap` overrides. diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 673bde2..02a67cc 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(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"); } diff --git a/apps/desktop/src-tauri/capabilities/menu.json b/apps/desktop/src-tauri/capabilities/menu.json new file mode 100644 index 0000000..2f5783e --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/menu.json @@ -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" + ] +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 2cbf8f9..9739e6a 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -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> { 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 Res> { + 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(()) +} diff --git a/apps/desktop/src-tauri/src/commands_list.rs b/apps/desktop/src-tauri/src/commands_list.rs index 1daea05..a076389 100644 --- a/apps/desktop/src-tauri/src/commands_list.rs +++ b/apps/desktop/src-tauri/src/commands_list.rs @@ -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"]; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2ef5046..e616bdd 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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::(); + 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::(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)?; + 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(app: &AppHandle, 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::(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)?; - 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"); diff --git a/apps/desktop/src-tauri/src/menu_panel.rs b/apps/desktop/src-tauri/src/menu_panel.rs new file mode 100644 index 0000000..3809791 --- /dev/null +++ b/apps/desktop/src-tauri/src/menu_panel.rs @@ -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 }, +} + +/// 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) -> Vec { + let mut seen: BTreeMap = 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 { + 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, + pub pinned: bool, + pub parent: Option, + 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, + 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, + next: u32, +} + +impl Book { + fn add(&mut self, path: Vec, pinned: bool, parent: Option, 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 { + 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 { + 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) { + 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)> { + 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 = 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 { + 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 { + 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 { + 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 = 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 { + let gone: Vec = 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 { + 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 { + 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, + pub pinned: bool, + pub has_parent: bool, + pub focus_first: bool, + pub nodes: Vec, +} + +/// The panels' shared state (managed by the app). +#[derive(Default)] +pub struct MenuPanels { + pub book: Mutex, + pub nodes: Mutex>, + /// 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::() + } + + fn book(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 { + 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::() 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) { + 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::>())); + 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 { + 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::().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 { + 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 { + 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 { + let labels: Vec = 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 { + 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::*; diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index 8d6cfff..3381956 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -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 { diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 5d2c567..f999653 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -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, } 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) { 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(); diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index e3a653f..0b23b9f 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -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 }, + /// `key`: the submenu's fixed key (menu_panel::sub_key), the same in every language. + Sub { key: String, label: String, items: Vec }, } fn clean(s: &str, max: usize) -> String { @@ -132,7 +135,7 @@ pub fn menu_model(s: &TraySummary) -> Vec { 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 { static LAST: Mutex> = Mutex::new(None); -fn build_menu(app: &AppHandle, model: &[Entry]) -> tauri::Result> { - 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(app: &AppHandle, nodes: &[Node]) -> tauri::Result>>> { + let mut out: Vec>> = 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> = 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(app: &AppHandle) -> tauri::Result> { - build_menu(app, &menu_model(&TraySummary::default())) +/// The native tray menu, rendered from the same tree as the panels. +pub fn native_menu(app: &AppHandle, nodes: &[Node]) -> tauri::Result> { + let items = native_items(app, nodes)?; + let refs: Vec<&dyn IsMenuItem> = 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(app: &AppHandle, s: &TraySummary) -> tauri::Result<()> { +/// The tree before the window has sent anything (English words). +pub fn initial_nodes() -> Vec { + 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))?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index c786df1..b95e93c 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", "card-window"] + "capabilities": ["main-window", "card-window", "menu-window"] } }, "bundle": { diff --git a/apps/desktop/src-tauri/tests/menu_panel.rs b/apps/desktop/src-tauri/tests/menu_panel.rs new file mode 100644 index 0000000..0f89878 --- /dev/null +++ b/apps/desktop/src-tauri/tests/menu_panel.rs @@ -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 { + 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 = (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}"); + } +} diff --git a/apps/desktop/src-tauri/tests/security.rs b/apps/desktop/src-tauri/tests/security.rs index 1d31b45..6c6c4c6 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::{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 = 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 = cap["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + let mut want: BTreeSet = 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::>()); + assert_eq!(registered, COMMANDS.iter().chain(CARD_COMMANDS).chain(MENU_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 { @@ -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(); diff --git a/apps/desktop/src-tauri/tests/tray.rs b/apps/desktop/src-tauri/tests/tray.rs index 95d5b67..47c03e8 100644 --- a/apps/desktop/src-tauri/tests/tray.rs +++ b/apps/desktop/src-tauri/tests/tray.rs @@ -23,7 +23,7 @@ fn ids(m: &[Entry]) -> Vec { 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!() }; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 4cfa717..02a809d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -211,7 +211,7 @@ export function App({ onTheme }: { onTheme: () => void }) { const menu = useContextMenu(); const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
- +
); } @@ -130,7 +130,7 @@ export function AppView({ app, tab }: { app: UnifiedApp; tab: AppTab }) { return (
appMenu(app, d, nav, { showIgnored }))} + {...cm(() => appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)} badges={<> {e?.summary?.profile && app.project && {t(`profile.${e.summary.profile}` as 'profile.app')}} {!app.project && } diff --git a/apps/desktop/src/screens/Ignored.tsx b/apps/desktop/src/screens/Ignored.tsx index 4c7ac27..fdc3167 100644 --- a/apps/desktop/src/screens/Ignored.tsx +++ b/apps/desktop/src/screens/Ignored.tsx @@ -30,7 +30,7 @@ export function Ignored() { {apps.map(a => { const s = d.studios.find(x => x.id === a.studio) ?? null; return ( -
appMenu(a, d, nav, { showIgnored: true }))}> +
appMenu(a, d, nav, { showIgnored: true }), `app:${a.key}`)}>
{a.name}{!a.project && } diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index c49926d..27eb5b0 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -153,7 +153,7 @@ function AppCard({ r, icon, health, studio }: { r: CardRow; icon: string | null; const menu = useContextMenu(); const [showIgnored] = useShowIgnored(); return ( -
appMenu(app, d, nav, { showIgnored }))}> +
appMenu(app, d, nav, { showIgnored }), `app:${app.key}`)}>