接入桌面壳系统托盘
Tauri 桌面壳启用真实系统托盘 托盘菜单提供显示主窗口刷新和退出动作 托盘注册失败不阻断主窗口启动 补充桌面壳托盘检查测试和架构文档
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user