接入桌面壳系统托盘

Tauri 桌面壳启用真实系统托盘

托盘菜单提供显示主窗口刷新和退出动作

托盘注册失败不阻断主窗口启动

补充桌面壳托盘检查测试和架构文档
This commit is contained in:
2026-06-18 07:56:47 +08:00
parent 29cd7866fc
commit 45a9f9b0cd
5 changed files with 160 additions and 3 deletions

View File

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

View File

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

View File

@@ -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!(

View File

@@ -45,6 +45,7 @@
- 2026-06-18 草稿生成完成 / 失败通知:平台壳层的 `markDraftReady` / `markDraftFailed` 统一收口会在原生壳声明 `notification.showLocal` 时请求即时本地通知;通知 payload 只包含生成完成 / 失败标题和草稿来源正文,按草稿来源去重,同一草稿重新进入生成中后才允许再次通知。该能力不替代现有完成 / 错误弹窗、作品架红点、队列概览或后端状态回读,通知失败不阻断主流程。
- 2026-06-18 剪贴板读取能力:新增 `clipboard.readText` HostBridge capabilityH5 只能读取纯文本结果,契约限制返回文本最多 100000 字符Expo 壳通过 `expo-clipboard` 读取系统剪贴板文本Tauri 壳通过 Rust 侧 `tauri-plugin-clipboard-manager` 读取文本且不开放插件 JS guest API。该能力不读取图片、HTML、文件列表或剪贴板监听事件宿主未声明或读取失败时由 H5 视作失败并保留原流程。
- 2026-06-18 文本文件导入能力:新增 `file.importText` HostBridge capabilityH5 统一通过 `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固定玩法在各宿主中读取同一作品数据和运行态 snapshotAI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`

View File

@@ -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 5AI H5 sandbox