diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index dddce7d48..57eb5db5d 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -521,6 +521,9 @@ const requiredMainSnippets = [ 'network.statusChanged', 'file.imageDropped', 'app.notification().builder()', + 'desktop_entry_url_with_platform', + 'desktop_window_config_with_runtime_platform', + 'desktop_window_config_with_runtime_platform(config)', ]; assertSameList( diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index d0ecc43e1..e082adf56 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -7,11 +7,12 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; use tauri::menu::{Menu, MenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::DragDropEvent; use tauri::Manager; use tauri::Theme; -use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::Url; +use tauri::WebviewUrl; use tauri::WebviewWindow; use tauri::WindowEvent; use tauri_plugin_clipboard_manager::ClipboardExt; @@ -97,6 +98,74 @@ fn desktop_platform() -> &'static str { } } +fn desktop_entry_url_with_platform(raw_url: &str) -> String { + let platform = desktop_platform(); + if let Ok(mut url) = Url::parse(raw_url) { + let query_pairs = url + .query_pairs() + .filter(|(key, _)| key != "hostPlatform") + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + url.query_pairs_mut() + .clear() + .extend_pairs( + query_pairs + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ) + .append_pair("hostPlatform", platform); + return url.to_string(); + } + + let (without_hash, hash) = raw_url + .split_once('#') + .map(|(path, hash)| (path, Some(hash))) + .unwrap_or((raw_url, None)); + let mut parts = without_hash.splitn(2, '?'); + let path = parts.next().unwrap_or_default(); + let query = parts.next(); + let mut pairs = query + .map(|query| { + query + .split('&') + .filter(|pair| { + !pair.is_empty() + && !pair + .split_once('=') + .map(|(key, _)| key == "hostPlatform") + .unwrap_or(false) + }) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + pairs.push(format!("hostPlatform={platform}")); + let normalized_url = format!("{path}?{}", pairs.join("&")); + if let Some(hash) = hash { + format!("{normalized_url}#{hash}") + } else { + normalized_url + } +} + +fn desktop_window_config_with_runtime_platform( + mut config: tauri::utils::config::WindowConfig, +) -> tauri::utils::config::WindowConfig { + config.url = match config.url { + WebviewUrl::External(url) => WebviewUrl::External( + Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url), + ), + WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol( + Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url), + ), + WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_platform( + path.to_string_lossy().as_ref(), + ))), + other => other, + }; + config +} + fn capabilities() -> Vec<&'static str> { vec![ "host.getRuntime", @@ -290,20 +359,23 @@ fn normalize_plain_text( fn local_notification_payload( request: &HostBridgeRequest, ) -> Result<(String, Option), HostBridgeResponse> { - let payload = request.payload.as_ref().ok_or_else(|| { - failed( - request.id.clone(), - "invalid_request", - "title is required", - ) - })?; + let payload = request + .payload + .as_ref() + .ok_or_else(|| failed(request.id.clone(), "invalid_request", "title is required"))?; let title = match normalize_plain_text( payload.get("title").and_then(Value::as_str), LOCAL_NOTIFICATION_TITLE_MAX_LENGTH, true, ) { Some(Some(title)) => title, - _ => return Err(failed(request.id.clone(), "invalid_request", "title is required")), + _ => { + return Err(failed( + request.id.clone(), + "invalid_request", + "title is required", + )) + } }; let body = match normalize_plain_text( payload.get("body").and_then(Value::as_str), @@ -311,7 +383,13 @@ fn local_notification_payload( false, ) { Some(body) => body, - None => return Err(failed(request.id.clone(), "invalid_request", "body is invalid")), + None => { + return Err(failed( + request.id.clone(), + "invalid_request", + "body is invalid", + )) + } }; Ok((title, body)) @@ -1339,8 +1417,7 @@ async fn host_bridge_request( Err(error) => return failed(request.id, "host_error", error.to_string()), }; let import_result = - tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)) - .await; + tauri::async_runtime::spawn_blocking(move || import_audio_file_payload(path)).await; match import_result { Ok(Ok(payload)) => ok(request.id, payload), Ok(Err(error)) => failed(request.id, "invalid_request", error), @@ -1500,6 +1577,7 @@ fn main() { }; let window_config = app.config().app.windows.get(0).cloned(); if let Some(config) = window_config { + let config = desktop_window_config_with_runtime_platform(config); let window = tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?; register_desktop_window_close_events(&window, tray_registered); @@ -1616,6 +1694,36 @@ mod tests { .contains(&json!("notification.showLocal"))); } + #[test] + fn desktop_entry_url_replaces_static_platform_marker() { + let platform = desktop_platform(); + + let dev_url = desktop_entry_url_with_platform( + "http://127.0.0.1:3000/?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1", + ); + let dev_url = Url::parse(&dev_url).expect("dev url"); + + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"), + Some(("hostPlatform".into(), platform.into())) + ); + assert_eq!( + dev_url + .query_pairs() + .filter(|(key, _)| key == "hostPlatform") + .count(), + 1 + ); + + let packaged_url = desktop_entry_url_with_platform( + "index.html?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1#works", + ); + + assert!(packaged_url.contains(&format!("hostPlatform={platform}"))); + assert!(!packaged_url.contains("hostPlatform=unknown")); + assert!(packaged_url.ends_with("#works")); + } + #[test] fn unsupported_method_is_explicit() { let response = resolve_host_bridge_request(request("payment.request")); @@ -1652,7 +1760,10 @@ mod tests { #[test] fn clipboard_text_is_truncated_to_contract_limit() { - assert_eq!(normalize_clipboard_text("作品号 PZ-1".to_string()), "作品号 PZ-1"); + assert_eq!( + normalize_clipboard_text("作品号 PZ-1".to_string()), + "作品号 PZ-1" + ); assert_eq!( normalize_clipboard_text("a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(), CLIPBOARD_TEXT_MAX_LENGTH @@ -2155,8 +2266,7 @@ mod tests { "mimeType": "audio/webm" })); - let (file_name, _bytes) = - export_audio_payload(&missing_extension).expect("audio payload"); + let (file_name, _bytes) = export_audio_payload(&missing_extension).expect("audio payload"); assert_eq!(file_name, "敲击音效.webm"); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c711340d1..922ab7b03 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -56,6 +56,7 @@ - 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。 - 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd / 深链内容作为事件透传给 H5。桌面深链后续如需接入,必须先定义受控 URL 归一和宿主边界,不能借单实例回调直接开放任意启动参数。 - 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载打包的 `index.html` 和根 `dist` H5 资产,dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。 +- 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值;Rust `setup` 手动创建主窗口前必须把入口 URL 改写为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。 - 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,防止把临时替身、占位文案或伪实现带进可分发壳。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 5e5e68d61..013ec5287 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -314,6 +314,8 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳安装包身份固定为 `world.genarrative.desktop`,产品名为 `Genarrative`,Tauri、Node package 与 Cargo package 版本统一为 `0.1.0`。Release 主窗口只能从打包进二进制的 `index.html` 进入根 `dist` H5 资产,dev URL 只能指向本机 Vite 调试入口;CSP 必须保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:` 这类扩大桌面攻击面的来源。当前不配置自动更新器,直到存在真实更新端点、签名密钥和发布流程再接入;`apps/desktop-shell/scripts/check-config.mjs` 会校验这些包身份、版本、CSP 和 updater 禁用约束。 +2026-06-18 追加:桌面壳静态 Tauri 配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值。Rust `setup` 手动创建主窗口前会把入口 URL 归一为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致;不通过第二实例参数、外部 deep link 或 H5 自报值覆盖该平台字段。 + 2026-06-18 追加:桌面壳命令暴露面收紧为唯一受控入口。Tauri `build.rs` manifest、Rust `generate_handler!`、主窗口 capability 权限列表和本地自动生成权限目录都只能出现 `host_bridge_request`;如果本地构建残留了其它 command 的自动生成权限文件,`apps/desktop-shell/scripts/check-config.mjs` 会直接失败。后续接入新的桌面系统能力时仍先扩展 HostBridge method 和 Rust 内部分发,不新增可被 H5 直接 invoke 的 Tauri command,也不把插件 JS guest API 授权给主窗口。 2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*` 或 `ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp`。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。