From 45a9f9b0cd5091c974946db4f78c185c75feb8a3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 07:56:47 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=89=98=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri 桌面壳启用真实系统托盘 托盘菜单提供显示主窗口刷新和退出动作 托盘注册失败不阻断主窗口启动 补充桌面壳托盘检查测试和架构文档 --- apps/desktop-shell/scripts/check-config.mjs | 15 ++ apps/desktop-shell/src-tauri/Cargo.toml | 2 +- apps/desktop-shell/src-tauri/src/main.rs | 139 ++++++++++++++++++ .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 6 +- 5 files changed, 160 insertions(+), 3 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 3d29555c..6f0003e9 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -9,6 +9,8 @@ const capabilityPath = new URL( const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8')); const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url); const buildScript = fs.readFileSync(buildScriptPath, 'utf8'); +const cargoManifestPath = new URL('../src-tauri/Cargo.toml', import.meta.url); +const cargoManifest = fs.readFileSync(cargoManifestPath, 'utf8'); const iconPath = new URL('../src-tauri/icons/icon.png', import.meta.url); const sharedContractPath = new URL( '../../../packages/shared/src/contracts/hostBridge.ts', @@ -133,6 +135,15 @@ const requiredPermissions = [ const requiredBuildCommands = ['host_bridge_request']; const requiredMainSnippets = [ 'tauri_plugin_clipboard_manager::init()', + 'TrayIconBuilder::with_id', + 'register_desktop_tray(app)', + 'DESKTOP_TRAY_ID', + 'TRAY_MENU_SHOW', + 'TRAY_MENU_RELOAD', + 'TRAY_MENU_QUIT', + 'show_main_window', + 'reload_main_window', + 'desktop tray registration failed', '"appearance.getColorScheme"', '"app.lifecycle"', '"network.status"', @@ -196,6 +207,10 @@ if (buildScript.includes('resolve_desktop_shell_runtime')) { throw new Error('desktop shell build manifest exposes an unused runtime command'); } +if (!cargoManifest.includes('features = ["tray-icon"]')) { + throw new Error('desktop shell must enable the Tauri tray-icon feature'); +} + const icon = fs.readFileSync(iconPath); if (icon.length < 10000) { throw new Error('desktop shell icon must use a real brand asset'); diff --git a/apps/desktop-shell/src-tauri/Cargo.toml b/apps/desktop-shell/src-tauri/Cargo.toml index 6b3b74a4..d2342808 100644 --- a/apps/desktop-shell/src-tauri/Cargo.toml +++ b/apps/desktop-shell/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ tauri-build = { version = "2.6.2", features = [] } base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri = { version = "2.11.2", features = [] } +tauri = { version = "2.11.2", features = ["tray-icon"] } tauri-plugin-clipboard-manager = "2.3.2" tauri-plugin-dialog = "2.7.1" tauri-plugin-notification = "2.3.3" diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index afb311b0..63e592b7 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -6,9 +6,11 @@ use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; +use tauri::menu::{Menu, MenuItem}; use tauri::DragDropEvent; use tauri::Manager; use tauri::Theme; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::Url; use tauri::WebviewWindow; use tauri::WindowEvent; @@ -34,6 +36,10 @@ const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; +const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray"; +const TRAY_MENU_SHOW: &str = "show-main-window"; +const TRAY_MENU_RELOAD: &str = "reload-main-window"; +const TRAY_MENU_QUIT: &str = "quit-desktop-shell"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -884,6 +890,100 @@ fn register_desktop_network_events(window: &WebviewWindow) -> tauri::Result<()> window.eval(script) } +#[derive(Debug, PartialEq, Eq)] +enum DesktopTrayAction { + ShowMainWindow, + ReloadMainWindow, + QuitApp, + Ignore, +} + +fn resolve_desktop_tray_menu_action(menu_id: &str) -> DesktopTrayAction { + match menu_id { + TRAY_MENU_SHOW => DesktopTrayAction::ShowMainWindow, + TRAY_MENU_RELOAD => DesktopTrayAction::ReloadMainWindow, + TRAY_MENU_QUIT => DesktopTrayAction::QuitApp, + _ => DesktopTrayAction::Ignore, + } +} + +fn resolve_desktop_tray_icon_action( + button: MouseButton, + button_state: MouseButtonState, +) -> DesktopTrayAction { + if button == MouseButton::Left && button_state == MouseButtonState::Up { + DesktopTrayAction::ShowMainWindow + } else { + DesktopTrayAction::Ignore + } +} + +fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + window.show()?; + window.unminimize()?; + window.set_focus()?; + } + + Ok(()) +} + +fn reload_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + window.reload()?; + } + + Ok(()) +} + +fn handle_desktop_tray_action(app: &tauri::AppHandle, action: DesktopTrayAction) { + match action { + DesktopTrayAction::ShowMainWindow => { + let _ = show_main_window(app); + } + DesktopTrayAction::ReloadMainWindow => { + let _ = reload_main_window(app); + } + DesktopTrayAction::QuitApp => app.exit(0), + DesktopTrayAction::Ignore => {} + } +} + +fn register_desktop_tray(app: &tauri::App) -> tauri::Result<()> { + let show_item = MenuItem::with_id(app, TRAY_MENU_SHOW, "显示主窗口", true, None::<&str>)?; + let reload_item = MenuItem::with_id(app, TRAY_MENU_RELOAD, "刷新", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, TRAY_MENU_QUIT, "退出", true, None::<&str>)?; + let tray_menu = Menu::with_items(app, &[&show_item, &reload_item, &quit_item])?; + let mut tray_builder = TrayIconBuilder::with_id(DESKTOP_TRAY_ID) + .menu(&tray_menu) + .tooltip("Genarrative") + .show_menu_on_left_click(false) + .on_menu_event(|app, event| { + let menu_id = event.id().0.as_str(); + handle_desktop_tray_action(app, resolve_desktop_tray_menu_action(menu_id)); + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button, + button_state, + .. + } = event + { + handle_desktop_tray_action( + tray.app_handle(), + resolve_desktop_tray_icon_action(button, button_state), + ); + } + }); + + if let Some(icon) = app.default_window_icon().cloned() { + tray_builder = tray_builder.icon(icon); + } + + tray_builder.build(app)?; + Ok(()) +} + fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> { value .get(field) @@ -1347,6 +1447,9 @@ fn main() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { + if let Err(error) = register_desktop_tray(app) { + eprintln!("desktop tray registration failed: {error}"); + } let window_config = app.config().app.windows.get(0).cloned(); if let Some(config) = window_config { let window = @@ -1589,6 +1692,42 @@ mod tests { ); } + #[test] + fn desktop_tray_menu_ids_map_to_real_window_actions() { + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_SHOW), + DesktopTrayAction::ShowMainWindow + ); + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_RELOAD), + DesktopTrayAction::ReloadMainWindow + ); + assert_eq!( + resolve_desktop_tray_menu_action(TRAY_MENU_QUIT), + DesktopTrayAction::QuitApp + ); + assert_eq!( + resolve_desktop_tray_menu_action("unknown"), + DesktopTrayAction::Ignore + ); + } + + #[test] + fn desktop_tray_left_click_restores_main_window_only_on_release() { + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Up), + DesktopTrayAction::ShowMainWindow + ); + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Left, MouseButtonState::Down), + DesktopTrayAction::Ignore + ); + assert_eq!( + resolve_desktop_tray_icon_action(MouseButton::Right, MouseButtonState::Up), + DesktopTrayAction::Ignore + ); + } + #[test] fn external_url_normalization_allows_only_safe_protocols() { assert_eq!( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 191f438e..fdf56569 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -45,6 +45,7 @@ - 2026-06-18 草稿生成完成 / 失败通知:平台壳层的 `markDraftReady` / `markDraftFailed` 统一收口会在原生壳声明 `notification.showLocal` 时请求即时本地通知;通知 payload 只包含生成完成 / 失败标题和草稿来源正文,按草稿来源去重,同一草稿重新进入生成中后才允许再次通知。该能力不替代现有完成 / 错误弹窗、作品架红点、队列概览或后端状态回读,通知失败不阻断主流程。 - 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capability,H5 只能读取纯文本结果,契约限制返回文本最多 100000 字符;Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件,宿主未声明或读取失败时由 H5 视作失败并保留原流程。 - 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capability,H5 统一通过 `importHostTextFile()` 读取宿主返回的纯文本内容;Expo 壳通过 `expo-document-picker` 打开系统文档选择器,Tauri 壳通过系统文件选择框读取真实文本文件。两端只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备 URI / 本机绝对路径,也不开放通用文件系统。 +- 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册失败不得阻断主窗口启动,`check:native-shells` 和 Tauri cargo test 覆盖托盘配置与菜单动作映射。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 0ed36614..c24428b7 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -178,7 +178,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 - Rust 侧只暴露一个受控 `host_bridge_request` command,再在 Rust 内部按 method 白名单分发。 - Tauri capabilities 只授予主窗口所需命令;默认不开放文件系统、shell、全局剪贴板或任意插件能力。 - 桌面支付首期走现有 H5 / 二维码 / 外部浏览器路径,不在 Rust 侧保存支付凭据。 -- 文件导出、作品卡保存、图片拖拽导入、系统托盘、自动更新等桌面能力按后续需求逐项开放。 +- 文件导出、作品卡保存、图片拖拽导入、系统托盘、自动更新等桌面能力按后续需求逐项开放;其中系统托盘属于桌面壳自有能力,不作为 HostBridge method 暴露给 H5。 桌面 release 和 dev 模式: @@ -293,10 +293,12 @@ GameBridge 禁止: 2026-06-18 追加:H5 账号状态刷新开始消费 `app.reloadWebView`。用户登录成功、退出登录或其它身份边界变化需要整页重新初始化时,`AuthGate` 会优先请求 Tauri 主 WebViewWindow 刷新;宿主未声明或刷新失败时再回退浏览器刷新,Tauri 仍只暴露 `host_bridge_request` 这一受控命令入口。 +2026-06-18 追加:桌面壳启用 Tauri 真实系统托盘,并复用品牌图标。托盘菜单只提供宿主壳级动作:显示主窗口、刷新主窗口和退出应用;左键点击托盘图标恢复并聚焦主窗口。该能力不进入 HostBridge capability 清单,不向 H5 暴露托盘 API、菜单 API、shell API 或任意窗口控制;如果当前桌面环境无法注册托盘,壳会继续启动主窗口。 + ### Phase 4:宿主能力扩展 - 移动端接入系统分享、推送、原生登录和渠道支付。 -- 桌面端接入自动更新、文件导出、图片拖拽导入和系统托盘。 +- 桌面端继续补齐自动更新和后续桌面分发能力;文件导出、图片拖拽导入和系统托盘已按真实宿主能力逐项接入。 - 所有新增能力先更新 HostBridge 契约和测试,再落壳实现。 ### Phase 5:AI H5 sandbox