Native menu bar (macOS): the shell describes it (menu_set, shell-only), host validates and builds it, a chosen id goes back to the shell (native-menu); App/File/Edit/View/Account/Plugins/Window/Help from the in-app menu's state, words wb.menu.*
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aa5f50c4a9
commit
788be02a26
12 changed files with 813 additions and 1 deletions
|
|
@ -729,6 +729,24 @@ async fn tab_find(webview: Webview, app: AppHandle, id: String, action: String,
|
|||
host::tab_find(&app, &id, &action, query.as_deref())
|
||||
}
|
||||
|
||||
/// Sets the native menu bar (macOS) from the shell's description ([`crate::menu`]); other platforms keep the
|
||||
/// in-app menu and answer false. Choosing an item sends its id to the shell (`native-menu`).
|
||||
#[tauri::command]
|
||||
async fn menu_set(webview: Webview, app: AppHandle, spec: serde_json::Value) -> Result<bool, String> {
|
||||
shell_only(&webview)?;
|
||||
let spec = crate::menu::parse(spec)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::menu::set(&app, &spec)?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (app, spec);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether safe mode is on ([`crate::safe`]): the shell shows it in the titlebar and drops its chrome theme.
|
||||
#[tauri::command]
|
||||
async fn safe_mode_get(webview: Webview) -> Result<crate::safe::SafeState, String> {
|
||||
|
|
@ -1236,6 +1254,7 @@ fn host_handler() -> impl Fn(tauri::ipc::Invoke<Wry>) -> bool + Send + Sync + 's
|
|||
tab_action,
|
||||
tab_zoom,
|
||||
tab_find,
|
||||
menu_set,
|
||||
safe_mode_get,
|
||||
safe_mode_set,
|
||||
diag_get,
|
||||
|
|
@ -1380,6 +1399,15 @@ impl App {
|
|||
let vault_handler = vault_handler();
|
||||
let backup_handler = backup_handler();
|
||||
let builder = tauri::Builder::<Wry>::default()
|
||||
// The native menu bar's choices go to the shell, which runs them ([`crate::menu`]).
|
||||
.on_menu_event(|app, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(id) = crate::menu::chosen(event.id().as_ref()) {
|
||||
emit_shell(app, crate::menu::EVENT_NATIVE_MENU, id.to_owned());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (app, event);
|
||||
})
|
||||
// Must be first: a second launch focuses the running window instead of fighting over
|
||||
// the app's data and WebView profile (and hands over its link).
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub const SHELL: &[&str] = &[
|
|||
"tab_action",
|
||||
"tab_zoom",
|
||||
"tab_find",
|
||||
"menu_set",
|
||||
"safe_mode_get",
|
||||
"safe_mode_set",
|
||||
"diag_get",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ pub mod hotfix;
|
|||
pub mod i18n;
|
||||
pub mod layout;
|
||||
pub mod media;
|
||||
pub mod menu;
|
||||
pub mod modes;
|
||||
pub mod plugins;
|
||||
pub mod popover;
|
||||
|
|
|
|||
255
crates/waybetter-host/src/menu.rs
Normal file
255
crates/waybetter-host/src/menu.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//! The native menu bar (macOS): the shell describes the whole bar (`menu_set`, shell-only) and
|
||||
//! native code builds it; choosing an item sends its id back to the shell (`native-menu` event),
|
||||
//! which runs the same actions the in-app menu does. Nothing here performs an action itself, and
|
||||
//! tabs gain no command: the bar is the shell's alone.
|
||||
//!
|
||||
//! The description is data and is validated before anything is built: bounded depth and size,
|
||||
//! one-line labels, ids of a fixed alphabet, accelerators of a fixed alphabet, and system items
|
||||
//! (Services, Hide, Quit, Edit's Cut/Copy/Paste, Window's Minimize/Zoom) only by name from a fixed
|
||||
//! list. Other platforms keep the in-app menu: `menu_set` does nothing there.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The shell event carrying the id of the chosen item.
|
||||
pub const EVENT_NATIVE_MENU: &str = "native-menu";
|
||||
|
||||
const MAX_NODES: usize = 400;
|
||||
const MAX_DEPTH: usize = 4;
|
||||
const MAX_LABEL: usize = 120;
|
||||
const MAX_ID: usize = 96;
|
||||
const MAX_ACCELERATOR: usize = 32;
|
||||
|
||||
/// System items the shell may ask for by name.
|
||||
pub const ROLES: &[&str] = &[
|
||||
"services", "hide", "hide_others", "show_all", "quit", "undo", "redo", "cut", "copy", "paste", "select_all", "minimize", "maximize", "fullscreen",
|
||||
];
|
||||
|
||||
/// A submenu the system treats specially (macOS lists the open windows in the Window menu, adds the
|
||||
/// search field to Help).
|
||||
pub const SUBMENU_ROLES: &[&str] = &["window", "help"];
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub enum Node {
|
||||
/// An item the shell handles: its id comes back when it is chosen. `checked` makes it a check item.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Item { id: String, label: String, #[serde(default)] accelerator: Option<String>, #[serde(default)] checked: Option<bool>, #[serde(default = "yes")] enabled: bool },
|
||||
Separator,
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Submenu { label: String, items: Vec<Node>, #[serde(default = "yes")] enabled: bool, #[serde(default)] role: Option<String> },
|
||||
/// A system item by name ([`ROLES`]); `label` overrides its title.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Predefined { role: String, #[serde(default)] label: Option<String> },
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The whole bar: top-level submenus only.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct MenuSpec {
|
||||
pub menus: Vec<Node>,
|
||||
}
|
||||
|
||||
fn valid_label(text: &str) -> bool {
|
||||
let count = text.chars().count();
|
||||
count > 0 && count <= MAX_LABEL && !text.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Ids are ASCII words (`theme:midnight`, `plugin.chat`, `file/new-tab`).
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= MAX_ID && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | ':' | '-' | '/'))
|
||||
}
|
||||
|
||||
/// Accelerators like `CmdOrCtrl+Shift+G`: words, `+` and one key (a letter, digit or punctuation mark).
|
||||
fn valid_accelerator(text: &str) -> bool {
|
||||
!text.is_empty() && text.len() <= MAX_ACCELERATOR && text.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '\\' | '[' | ']' | ',' | '.' | ';' | '\'' | '=' | '-' | '`' | '/'))
|
||||
}
|
||||
|
||||
/// Validates a description before anything is built.
|
||||
pub fn validate(spec: &MenuSpec) -> Result<(), String> {
|
||||
fn walk(nodes: &[Node], depth: usize, count: &mut usize) -> Result<(), String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("VALIDATION: menu too deep".into());
|
||||
}
|
||||
for node in nodes {
|
||||
*count += 1;
|
||||
if *count > MAX_NODES {
|
||||
return Err("VALIDATION: menu too large".into());
|
||||
}
|
||||
match node {
|
||||
Node::Item { id, label, accelerator, .. } => {
|
||||
if !valid_id(id) || !valid_label(label) || accelerator.as_deref().is_some_and(|text| !valid_accelerator(text)) {
|
||||
return Err("VALIDATION: invalid menu item".into());
|
||||
}
|
||||
}
|
||||
Node::Separator => {}
|
||||
Node::Submenu { label, items, role, .. } => {
|
||||
if !valid_label(label) || role.as_deref().is_some_and(|role| !SUBMENU_ROLES.contains(&role)) {
|
||||
return Err("VALIDATION: invalid submenu".into());
|
||||
}
|
||||
walk(items, depth + 1, count)?;
|
||||
}
|
||||
Node::Predefined { role, label } => {
|
||||
if !ROLES.contains(&role.as_str()) || label.as_deref().is_some_and(|text| !valid_label(text)) {
|
||||
return Err("VALIDATION: invalid system item".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
if spec.menus.is_empty() || spec.menus.iter().any(|node| !matches!(node, Node::Submenu { .. })) {
|
||||
return Err("VALIDATION: the bar is a list of menus".into());
|
||||
}
|
||||
walk(&spec.menus, 0, &mut 0)
|
||||
}
|
||||
|
||||
/// Parses and validates the shell's JSON description.
|
||||
pub fn parse(value: serde_json::Value) -> Result<MenuSpec, String> {
|
||||
let spec: MenuSpec = serde_json::from_value(value).map_err(|_| "VALIDATION: invalid menu".to_string())?;
|
||||
validate(&spec)?;
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tauri", target_os = "macos"))]
|
||||
pub use build::set;
|
||||
|
||||
#[cfg(all(feature = "tauri", target_os = "macos"))]
|
||||
mod build {
|
||||
use super::{valid_id, Node};
|
||||
use tauri::menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
fn append<R: Runtime>(app: &AppHandle<R>, menu: &Submenu<R>, nodes: &[Node]) -> tauri::Result<()> {
|
||||
for node in nodes {
|
||||
let item: Box<dyn IsMenuItem<R>> = match node {
|
||||
Node::Item { id, label, accelerator, checked, enabled } => {
|
||||
// An accelerator the platform cannot parse leaves the item without a key, not without an item.
|
||||
let key = accelerator.as_deref();
|
||||
match checked {
|
||||
Some(state) => Box::new(CheckMenuItem::with_id(app, id.as_str(), label, *enabled, *state, key).or_else(|_| CheckMenuItem::with_id(app, id.as_str(), label, *enabled, *state, None::<&str>))?),
|
||||
None => Box::new(MenuItem::with_id(app, id.as_str(), label, *enabled, key).or_else(|_| MenuItem::with_id(app, id.as_str(), label, *enabled, None::<&str>))?),
|
||||
}
|
||||
}
|
||||
Node::Separator => Box::new(PredefinedMenuItem::separator(app)?),
|
||||
Node::Submenu { label, items, enabled, role } => {
|
||||
let sub = Submenu::new(app, label, *enabled)?;
|
||||
append(app, &sub, items)?;
|
||||
match role.as_deref() {
|
||||
Some("window") => sub.set_as_windows_menu_for_nsapp()?,
|
||||
Some("help") => sub.set_as_help_menu_for_nsapp()?,
|
||||
_ => {}
|
||||
}
|
||||
Box::new(sub)
|
||||
}
|
||||
Node::Predefined { role, label } => {
|
||||
let text = label.as_deref();
|
||||
Box::new(match role.as_str() {
|
||||
"services" => PredefinedMenuItem::services(app, text)?,
|
||||
"hide" => PredefinedMenuItem::hide(app, text)?,
|
||||
"hide_others" => PredefinedMenuItem::hide_others(app, text)?,
|
||||
"show_all" => PredefinedMenuItem::show_all(app, text)?,
|
||||
"quit" => PredefinedMenuItem::quit(app, text)?,
|
||||
"undo" => PredefinedMenuItem::undo(app, text)?,
|
||||
"redo" => PredefinedMenuItem::redo(app, text)?,
|
||||
"cut" => PredefinedMenuItem::cut(app, text)?,
|
||||
"copy" => PredefinedMenuItem::copy(app, text)?,
|
||||
"paste" => PredefinedMenuItem::paste(app, text)?,
|
||||
"select_all" => PredefinedMenuItem::select_all(app, text)?,
|
||||
"minimize" => PredefinedMenuItem::minimize(app, text)?,
|
||||
"maximize" => PredefinedMenuItem::maximize(app, text)?,
|
||||
_ => PredefinedMenuItem::fullscreen(app, text)?,
|
||||
})
|
||||
}
|
||||
};
|
||||
menu.append(item.as_ref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the bar and makes it the application's menu (on the main thread).
|
||||
pub fn set<R: Runtime>(app: &AppHandle<R>, spec: &super::MenuSpec) -> Result<(), String> {
|
||||
let spec = spec.clone();
|
||||
let handle = app.clone();
|
||||
app.run_on_main_thread(move || {
|
||||
let build = || -> tauri::Result<Menu<R>> {
|
||||
let menu = Menu::new(&handle)?;
|
||||
for node in &spec.menus {
|
||||
if let Node::Submenu { label, items, enabled, role } = node {
|
||||
let top = Submenu::new(&handle, label, *enabled)?;
|
||||
append(&handle, &top, items)?;
|
||||
match role.as_deref() {
|
||||
Some("window") => top.set_as_windows_menu_for_nsapp()?,
|
||||
Some("help") => top.set_as_help_menu_for_nsapp()?,
|
||||
_ => {}
|
||||
}
|
||||
menu.append(&top)?;
|
||||
}
|
||||
}
|
||||
Ok(menu)
|
||||
};
|
||||
if let Ok(menu) = build() {
|
||||
let _ = handle.set_menu(menu);
|
||||
}
|
||||
})
|
||||
.map_err(|error| format!("MENU: {error}"))
|
||||
}
|
||||
|
||||
/// The id of a chosen item when it is one the shell described.
|
||||
pub fn chosen(id: &str) -> Option<&str> {
|
||||
valid_id(id).then_some(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tauri", target_os = "macos"))]
|
||||
pub use build::chosen;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn bar(items: serde_json::Value) -> serde_json::Value {
|
||||
json!({ "menus": [{ "kind": "submenu", "label": "File", "items": items }] })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bar_of_items_submenus_and_system_items_is_accepted() {
|
||||
let value = json!({ "menus": [
|
||||
{ "kind": "submenu", "label": "App", "items": [
|
||||
{ "kind": "item", "id": "settings", "label": "Settings…", "accelerator": "CmdOrCtrl+," },
|
||||
{ "kind": "separator" },
|
||||
{ "kind": "predefined", "role": "services" },
|
||||
{ "kind": "predefined", "role": "quit" } ] },
|
||||
{ "kind": "submenu", "label": "View", "items": [
|
||||
{ "kind": "submenu", "label": "Theme", "items": [ { "kind": "item", "id": "theme:midnight", "label": "Midnight", "checked": true } ] } ] },
|
||||
{ "kind": "submenu", "label": "Window", "role": "window", "items": [ { "kind": "predefined", "role": "minimize" } ] },
|
||||
] });
|
||||
let spec = parse(value).unwrap();
|
||||
assert_eq!(spec.menus.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_description_is_bounded_and_made_of_known_things() {
|
||||
assert!(parse(json!({ "menus": [] })).is_err(), "an empty bar");
|
||||
assert!(parse(json!({ "menus": [{ "kind": "item", "id": "a", "label": "A" }] })).is_err(), "the bar holds menus only");
|
||||
assert!(parse(bar(json!([{ "kind": "item", "id": "bad id", "label": "A" }]))).is_err(), "ids are words");
|
||||
assert!(parse(bar(json!([{ "kind": "item", "id": "a", "label": "two\nlines" }]))).is_err(), "labels are one line");
|
||||
assert!(parse(bar(json!([{ "kind": "item", "id": "a", "label": "" }]))).is_err());
|
||||
assert!(parse(bar(json!([{ "kind": "item", "id": "a", "label": "A", "accelerator": "Cmd+<script>" }]))).is_err());
|
||||
assert!(parse(bar(json!([{ "kind": "predefined", "role": "close_window" }]))).is_err(), "system items by name from the list only");
|
||||
assert!(parse(bar(json!([{ "kind": "item", "id": "a", "label": "A", "extra": 1 }]))).is_err(), "no unknown fields");
|
||||
assert!(parse(json!({ "menus": [{ "kind": "submenu", "label": "W", "role": "dock", "items": [] }] })).is_err());
|
||||
let too_many: Vec<_> = (0..MAX_NODES + 1).map(|n| json!({ "kind": "item", "id": format!("i{n}"), "label": "x" })).collect();
|
||||
assert!(parse(bar(json!(too_many))).is_err(), "size");
|
||||
let mut deep = json!([{ "kind": "item", "id": "a", "label": "A" }]);
|
||||
for _ in 0..MAX_DEPTH + 1 {
|
||||
deep = json!([{ "kind": "submenu", "label": "S", "items": deep }]);
|
||||
}
|
||||
assert!(parse(bar(deep)).is_err(), "depth");
|
||||
assert!(parse(bar(json!([{ "kind": "item", "id": "a".repeat(MAX_ID + 1), "label": "A" }]))).is_err());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue