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.*

This commit is contained in:
CRTkafa 2026-10-02 02:00:15 +07:00
parent d7903e80ce
commit 4c957df89c
12 changed files with 813 additions and 1 deletions

View file

@ -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| {

View file

@ -13,6 +13,7 @@ pub const SHELL: &[&str] = &[
"tab_action",
"tab_zoom",
"tab_find",
"menu_set",
"safe_mode_get",
"safe_mode_set",
"diag_get",

View file

@ -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;

View 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());
}
}