桌面壳接入应用菜单

新增 Tauri 应用菜单并复用显示、刷新和退出窗口动作

收紧桌面壳菜单模块和结构门禁

同步宿主壳方案文档和团队决策记录
This commit is contained in:
2026-06-18 19:42:17 +08:00
parent f3b5edd4c6
commit ec36300d92
8 changed files with 163 additions and 4 deletions
@@ -1079,6 +1079,7 @@ const requiredRustHostModules = [
'shell/events.rs',
'shell/file_drop.rs',
'shell/lifecycle.rs',
'shell/menu.rs',
'shell/mod.rs',
'shell/navigation.rs',
'shell/network.rs',
@@ -1104,6 +1105,15 @@ const requiredRustHostSnippets = [
'DESKTOP_DEEP_LINK_HOSTS',
'resolve_desktop_single_instance_action',
'tauri_plugin_clipboard_manager::init()',
'register_desktop_app_menu(app)?',
'DESKTOP_APP_MENU_SHOW',
'DESKTOP_APP_MENU_RELOAD',
'DESKTOP_APP_MENU_QUIT',
'resolve_desktop_app_menu_action',
'app.set_menu',
'app.on_menu_event',
'Submenu::with_items',
'PredefinedMenuItem::copy',
'TrayIconBuilder::with_id',
'register_desktop_tray(app)',
'DESKTOP_TRAY_ID',
+2
View File
@@ -3,6 +3,7 @@ mod shell;
use host_bridge::{host_bridge_request, DesktopShareState, HostBridgeReplayState};
use shell::deep_link::{register_desktop_deep_link_events, register_desktop_deep_link_schemes};
use shell::menu::register_desktop_app_menu;
use shell::tray::{
register_desktop_tray, register_desktop_window_close_events,
resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction,
@@ -35,6 +36,7 @@ fn main() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
register_desktop_app_menu(app)?;
let tray_registered = match register_desktop_tray(app) {
Ok(()) => true,
Err(error) => {
@@ -0,0 +1,136 @@
use crate::shell::tray::{reload_main_window, show_main_window};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
pub(crate) const DESKTOP_APP_MENU_SHOW: &str = "app-menu-show-main-window";
pub(crate) const DESKTOP_APP_MENU_RELOAD: &str = "app-menu-reload-main-window";
pub(crate) const DESKTOP_APP_MENU_QUIT: &str = "app-menu-quit-desktop-shell";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum DesktopAppMenuAction {
ShowMainWindow,
ReloadMainWindow,
QuitApp,
Ignore,
}
pub(crate) fn resolve_desktop_app_menu_action(menu_id: &str) -> DesktopAppMenuAction {
match menu_id {
DESKTOP_APP_MENU_SHOW => DesktopAppMenuAction::ShowMainWindow,
DESKTOP_APP_MENU_RELOAD => DesktopAppMenuAction::ReloadMainWindow,
DESKTOP_APP_MENU_QUIT => DesktopAppMenuAction::QuitApp,
_ => DesktopAppMenuAction::Ignore,
}
}
fn handle_desktop_app_menu_action(app: &tauri::AppHandle, action: DesktopAppMenuAction) {
match action {
DesktopAppMenuAction::ShowMainWindow => {
let _ = show_main_window(app);
}
DesktopAppMenuAction::ReloadMainWindow => {
let _ = reload_main_window(app);
}
DesktopAppMenuAction::QuitApp => app.exit(0),
DesktopAppMenuAction::Ignore => {}
}
}
pub(crate) fn register_desktop_app_menu(app: &tauri::App) -> tauri::Result<()> {
let show_item =
MenuItem::with_id(app, DESKTOP_APP_MENU_SHOW, "显示主窗口", true, None::<&str>)?;
let reload_item = MenuItem::with_id(
app,
DESKTOP_APP_MENU_RELOAD,
"刷新",
true,
Some("CmdOrCtrl+R"),
)?;
let quit_item = MenuItem::with_id(
app,
DESKTOP_APP_MENU_QUIT,
"退出",
true,
Some("CmdOrCtrl+Q"),
)?;
let app_separator = PredefinedMenuItem::separator(app)?;
let app_menu = Submenu::with_items(
app,
"Genarrative",
true,
&[&show_item, &reload_item, &app_separator, &quit_item],
)?;
let undo_item = PredefinedMenuItem::undo(app, Some("撤销"))?;
let redo_item = PredefinedMenuItem::redo(app, Some("重做"))?;
let edit_separator = PredefinedMenuItem::separator(app)?;
let cut_item = PredefinedMenuItem::cut(app, Some("剪切"))?;
let copy_item = PredefinedMenuItem::copy(app, Some("复制"))?;
let paste_item = PredefinedMenuItem::paste(app, Some("粘贴"))?;
let select_all_item = PredefinedMenuItem::select_all(app, Some("全选"))?;
let edit_menu = Submenu::with_items(
app,
"编辑",
true,
&[
&undo_item,
&redo_item,
&edit_separator,
&cut_item,
&copy_item,
&paste_item,
&select_all_item,
],
)?;
let minimize_item = PredefinedMenuItem::minimize(app, Some("最小化"))?;
let maximize_item = PredefinedMenuItem::maximize(app, Some("最大化"))?;
let window_separator = PredefinedMenuItem::separator(app)?;
let close_item = PredefinedMenuItem::close_window(app, Some("关闭窗口"))?;
let window_menu = Submenu::with_items(
app,
"窗口",
true,
&[
&minimize_item,
&maximize_item,
&window_separator,
&close_item,
],
)?;
app.set_menu(Menu::with_items(
app,
&[&app_menu, &edit_menu, &window_menu],
)?)?;
app.on_menu_event(|app, event| {
let menu_id = event.id().0.as_str();
handle_desktop_app_menu_action(app, resolve_desktop_app_menu_action(menu_id));
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_app_menu_ids_map_to_window_actions() {
assert_eq!(
resolve_desktop_app_menu_action(DESKTOP_APP_MENU_SHOW),
DesktopAppMenuAction::ShowMainWindow
);
assert_eq!(
resolve_desktop_app_menu_action(DESKTOP_APP_MENU_RELOAD),
DesktopAppMenuAction::ReloadMainWindow
);
assert_eq!(
resolve_desktop_app_menu_action(DESKTOP_APP_MENU_QUIT),
DesktopAppMenuAction::QuitApp
);
assert_eq!(
resolve_desktop_app_menu_action("unknown"),
DesktopAppMenuAction::Ignore
);
}
}
@@ -2,6 +2,7 @@ pub(crate) mod deep_link;
mod events;
mod file_drop;
mod lifecycle;
pub(crate) mod menu;
mod navigation;
mod network;
mod runtime;
@@ -68,7 +68,7 @@ pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
Ok(())
}
fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
pub(crate) fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window("main") {
window.reload()?;
}
@@ -2499,3 +2499,10 @@
- 决策:桌面壳接入 `tauri-plugin-window-state`,并把插件配置收口到 `apps/desktop-shell/src-tauri/src/shell/window_state.rs`。只保存 `SIZE``POSITION``MAXIMIZED`,不保存 `VISIBLE``FULLSCREEN``DECORATIONS`;该能力属于宿主壳自身体验,不进入 HostBridge capability,不暴露窗口状态插件 command 给 H5。插件注册顺序固定为 single-instance 优先,其后才是 window-state、deep-link 和其它系统插件。
- 影响范围:`apps/desktop-shell/src-tauri/Cargo.toml``apps/desktop-shell/src-tauri/Cargo.lock``apps/desktop-shell/src-tauri/src/main.rs``apps/desktop-shell/src-tauri/src/shell/window_state.rs``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle``npm run check:native-shells``npm run typecheck -- --pretty false``npm run check:encoding``git diff --check`
## 2026-06-18 桌面壳应用菜单
- 背景:桌面壳方案要求 Tauri 承接系统菜单,但当前桌面壳只有系统托盘菜单和 HostBridge 受控能力;可分发桌面包缺少常规应用菜单会让刷新、退出、系统编辑和窗口操作只能依赖 WebView 或托盘。
- 决策:新增 `apps/desktop-shell/src-tauri/src/shell/menu.rs` 注册 Tauri 应用菜单。应用菜单只复用宿主壳级显示主窗口、刷新主窗口和退出应用动作;编辑菜单和窗口菜单使用 Tauri 原生预定义项承接剪切、复制、粘贴、全选、最小化、最大化和关闭窗口。该能力不进入 HostBridge capability,不开放菜单 API、shell API 或任意窗口控制给 H5;菜单注册失败直接阻断启动,避免生产桌面壳缺少系统菜单仍静默运行。
- 影响范围:`apps/desktop-shell/src-tauri/src/main.rs``apps/desktop-shell/src-tauri/src/shell/menu.rs``apps/desktop-shell/src-tauri/src/shell/tray.rs``apps/desktop-shell/scripts/check-config.mjs``scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle``npm run check:native-shells``npm run typecheck -- --pretty false``npm run check:encoding``git diff --check`
@@ -64,7 +64,7 @@ src/
已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。
三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*``miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol``webView``payment``shareGrid``subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js``payment.js``shareGrid.js``subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 承接能力清单与 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接能力清单,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``window_state.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、窗口状态持久化和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。
三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*``miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol``webView``payment``shareGrid``subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js``payment.js``shareGrid.js``subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 承接能力清单与 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接能力清单,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``menu.rs``window_state.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。
## HostBridge 消息协议
@@ -183,7 +183,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。
- Rust 侧只暴露一个受控 `host_bridge_request` command,再在 Rust 内部按 method 白名单分发。
- Tauri capabilities 只授予主窗口所需命令;默认不开放文件系统、shell、全局剪贴板或任意插件能力。
- 桌面支付首期走现有 H5 / 二维码 / 外部浏览器路径,不在 Rust 侧保存支付凭据。
- 文件导出、作品卡保存、图片拖拽导入、系统托盘、自动更新等桌面能力按后续需求逐项开放;其中系统托盘属于桌面壳自有能力,不作为 HostBridge method 暴露给 H5。
- 文件导出、作品卡保存、图片拖拽导入、应用菜单、系统托盘、自动更新等桌面能力按后续需求逐项开放;其中应用菜单和系统托盘属于桌面壳自有能力,不作为 HostBridge method 暴露给 H5。
- 主 WebView 默认拒绝网页自动下载或 `<a download>` 触发的落盘动作;用户保存文本、图片、音频等内容必须走已声明的 `file.exportText``file.exportImage``file.exportAudio` HostBridge method,由 Rust 侧执行 MIME、大小、文件名清洗和系统保存对话框确认。
- 主 WebView 显式关闭 DevToolsCargo 不启用 Tauri `devtools` feature;本地调试通过普通浏览器和 Vite 完成,不把可分发桌面壳变成调试容器。
- 崩溃上报、前端 analytics、桌面遥测日志、自动更新和渠道分发 SDK 都必须等真实端点、采集字段、用户同意、隐私策略、签名和发布流程确定后逐项接入;当前桌面壳不安装 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等相关依赖。
@@ -350,6 +350,8 @@ GameBridge 禁止:
2026-06-18 追加:桌面壳启用 Tauri 真实系统托盘,并复用品牌图标。托盘菜单只提供宿主壳级动作:显示主窗口、刷新主窗口和退出应用;左键点击托盘图标恢复并聚焦主窗口。该能力不进入 HostBridge capability 清单,不向 H5 暴露托盘 API、菜单 API、shell API 或任意窗口控制;如果当前桌面环境无法注册托盘,壳会继续启动主窗口。托盘注册成功时,用户点击主窗口关闭按钮会隐藏到托盘,必须通过托盘“退出”动作结束应用;托盘注册失败时不拦截关闭,避免窗口消失后没有恢复入口。
2026-06-18 追加:桌面壳启用 Tauri 应用菜单。`shell/menu.rs` 注册应用菜单、编辑菜单和窗口菜单,其中应用菜单只复用宿主壳级动作:显示主窗口、刷新主窗口和退出应用;编辑 / 窗口项使用 Tauri 原生预定义菜单项承接系统剪切、复制、粘贴、全选、最小化、最大化和关闭窗口。该能力不进入 HostBridge capability,不向 H5 暴露菜单 API、shell API 或任意窗口控制;应用菜单注册失败视为桌面壳启动失败,避免可分发桌面包缺少系统菜单仍静默运行。
2026-06-18 追加:桌面壳启用 Tauri 单实例。用户重复启动桌面 App 时,新实例会退出并唤醒已有主窗口;该回调只执行显示、取消最小化和聚焦主窗口,不把第二实例的命令行参数或工作目录作为事件透传给 H5。Windows / Linux 上由单实例插件的 `deep-link` feature 把二次实例 URL 交给 deep-link 插件,仍由 `shell/deep_link.rs` 做受控归一。
2026-06-18 追加:桌面壳启用 Tauri 窗口状态持久化。`shell/window_state.rs` 只配置 `tauri-plugin-window-state` 保存主窗口大小、位置和最大化状态,并排除可见性、全屏和装饰状态;插件注册顺序固定为 single-instance 优先,其后才是窗口状态、deep-link 和其它系统能力插件。该能力不进入 HostBridge 清单,不向 H5 暴露任意窗口状态读写,也不改变托盘关闭隐藏、单实例唤醒和托盘恢复主窗口的既有语义。
@@ -418,7 +420,7 @@ GameBridge 禁止:
2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage``about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。
2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts``files.ts``share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs``files.rs``share.rs``mod.rs` 对齐;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs``apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``window_state.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。
2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts``files.ts``share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs``files.rs``share.rs``mod.rs` 对齐;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs``apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``menu.rs``window_state.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。
### Phase 4:宿主能力扩展
+1
View File
@@ -74,6 +74,7 @@ const expectedDesktopShellRustFiles = [
'events.rs',
'file_drop.rs',
'lifecycle.rs',
'menu.rs',
'mod.rs',
'navigation.rs',
'network.rs',