From 75b1f8391fb6821f8d853111d49fee9a67dc61bc Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 16:53:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=A1=8C=E9=9D=A2=E5=A3=B3?= =?UTF-8?q?=E6=B7=B1=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增Tauri桌面深链模块并归一到同源H5入口 启用deep-link插件与single-instance深链转发 更新原生壳门禁和架构文档 --- apps/desktop-shell/scripts/check-config.mjs | 40 ++- apps/desktop-shell/src-tauri/Cargo.lock | 98 +++++++ apps/desktop-shell/src-tauri/Cargo.toml | 3 +- apps/desktop-shell/src-tauri/src/main.rs | 4 + .../src-tauri/src/shell/deep_link.rs | 262 ++++++++++++++++++ apps/desktop-shell/src-tauri/src/shell/mod.rs | 1 + apps/desktop-shell/src-tauri/tauri.conf.json | 7 + .../shared-memory/decision-log.md | 7 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 7 +- scripts/check-native-shells.mjs | 1 + 10 files changed, 421 insertions(+), 9 deletions(-) create mode 100644 apps/desktop-shell/src-tauri/src/shell/deep_link.rs diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 23b792963..438f57416 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -493,6 +493,11 @@ for (const [sectionName, dependencyName, expectedLine] of [ 'tauri-plugin-dialog', 'tauri-plugin-dialog = "2.7.1"', ], + [ + 'dependencies', + 'tauri-plugin-deep-link', + 'tauri-plugin-deep-link = "2.4.9"', + ], [ 'dependencies', 'tauri-plugin-notification', @@ -506,7 +511,7 @@ for (const [sectionName, dependencyName, expectedLine] of [ [ 'dependencies', 'tauri-plugin-single-instance', - 'tauri-plugin-single-instance = "2.4.2"', + 'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }', ], ]) { assertCargoDependencyLine(sectionName, dependencyName, expectedLine); @@ -519,6 +524,7 @@ for (const [packageName, expectedVersion] of [ ['serde_json', '1.0.150'], ['tauri', '2.11.2'], ['tauri-plugin-clipboard-manager', '2.3.2'], + ['tauri-plugin-deep-link', '2.4.9'], ['tauri-plugin-dialog', '2.7.1'], ['tauri-plugin-notification', '2.3.3'], ['tauri-plugin-opener', '2.5.4'], @@ -534,6 +540,7 @@ for (const [dependencyName, expectedVersion] of [ ['serde_json', '1.0.150'], ['tauri', '2.11.2'], ['tauri-plugin-clipboard-manager', '2.3.2'], + ['tauri-plugin-deep-link', '2.4.9'], ['tauri-plugin-dialog', '2.7.1'], ['tauri-plugin-notification', '2.3.3'], ['tauri-plugin-opener', '2.5.4'], @@ -1039,12 +1046,22 @@ const requiredRustHostModules = [ 'host_bridge/protocol.rs', 'host_bridge/share.rs', 'main.rs', + 'shell/deep_link.rs', 'shell/mod.rs', 'shell/tray.rs', 'shell/webview.rs', ]; const requiredRustHostSnippets = [ 'tauri_plugin_single_instance::init', + 'tauri_plugin_deep_link::init()', + 'register_desktop_deep_link_events(app)', + 'register_desktop_deep_link_schemes(app)', + 'DeepLinkExt', + 'app.deep_link().on_open_url', + 'app.deep_link().get_current()', + 'app.deep_link().register_all()', + 'normalize_desktop_deep_link_url', + 'DESKTOP_DEEP_LINK_HOSTS', 'resolve_desktop_single_instance_action', 'tauri_plugin_clipboard_manager::init()', 'TrayIconBuilder::with_id', @@ -1203,8 +1220,25 @@ if (cargoManifest.includes('"devtools"')) { throw new Error('desktop shell must not enable Tauri devtools feature'); } -if (!cargoManifest.includes('tauri-plugin-single-instance = "2.4.2"')) { - throw new Error('desktop shell must depend on tauri-plugin-single-instance'); +if ( + !cargoManifest.includes( + 'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }', + ) +) { + throw new Error('desktop shell must depend on tauri-plugin-single-instance with deep-link feature'); +} + +if (!cargoManifest.includes('tauri-plugin-deep-link = "2.4.9"')) { + throw new Error('desktop shell must depend on tauri-plugin-deep-link'); +} + +if ( + !config.plugins || + !config.plugins['deep-link'] || + JSON.stringify(config.plugins['deep-link']?.desktop?.schemes ?? []) !== + JSON.stringify(['genarrative']) +) { + throw new Error('desktop shell deep-link plugin must register only the genarrative scheme'); } if ( diff --git a/apps/desktop-shell/src-tauri/Cargo.lock b/apps/desktop-shell/src-tauri/Cargo.lock index 1c855fead..373f96c52 100644 --- a/apps/desktop-shell/src-tauri/Cargo.lock +++ b/apps/desktop-shell/src-tauri/Cargo.lock @@ -520,6 +520,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "cookie" version = "0.18.1" @@ -810,6 +830,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1283,6 +1312,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-opener", @@ -1510,6 +1540,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2478,6 +2514,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3019,6 +3065,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3717,6 +3773,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -3809,6 +3886,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", @@ -4034,6 +4112,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4956,6 +5043,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/apps/desktop-shell/src-tauri/Cargo.toml b/apps/desktop-shell/src-tauri/Cargo.toml index ea4781c30..7ba25cd96 100644 --- a/apps/desktop-shell/src-tauri/Cargo.toml +++ b/apps/desktop-shell/src-tauri/Cargo.toml @@ -13,7 +13,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri = { version = "2.11.2", features = ["tray-icon"] } tauri-plugin-clipboard-manager = "2.3.2" +tauri-plugin-deep-link = "2.4.9" tauri-plugin-dialog = "2.7.1" tauri-plugin-notification = "2.3.3" tauri-plugin-opener = "2.5.4" -tauri-plugin-single-instance = "2.4.2" +tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 5be11efbc..29c89f75d 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -2,6 +2,7 @@ mod host_bridge; 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::tray::{ register_desktop_tray, register_desktop_window_close_events, resolve_desktop_single_instance_action, show_main_window, DesktopSingleInstanceAction, @@ -26,6 +27,7 @@ fn main() { } } })) + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) @@ -68,6 +70,8 @@ fn main() { let _ = emit_desktop_lifecycle_event(&window, "active", true, "created"); let _ = register_desktop_network_events(&window); register_desktop_file_drop_events(&window); + register_desktop_deep_link_events(app)?; + register_desktop_deep_link_schemes(app); } Ok(()) }) diff --git a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs new file mode 100644 index 000000000..da3ccbcb2 --- /dev/null +++ b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs @@ -0,0 +1,262 @@ +use crate::host_bridge::protocol::{capabilities, HOST_BRIDGE_VERSION}; +use crate::shell::tray::show_main_window; +use crate::shell::webview::{desktop_platform, WEB_APP_ORIGIN}; +use tauri::{Manager, Url, WebviewWindow}; +use tauri_plugin_deep_link::DeepLinkExt; + +const DESKTOP_DEEP_LINK_HOSTS: [&str; 2] = ["open", "app"]; +const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [ + "clientRuntime", + "clientType", + "hostShell", + "hostPlatform", + "hostVersion", + "bridgeVersion", + "hostCapabilities", +]; + +fn extract_path_from_custom_scheme(url: &Url) -> String { + let host = url.host_str().unwrap_or_default(); + if DESKTOP_DEEP_LINK_HOSTS.contains(&host) { + return format!( + "{}{}{}", + url.path(), + url.query() + .map(|query| format!("?{query}")) + .unwrap_or_default(), + url.fragment() + .map(|fragment| format!("#{fragment}")) + .unwrap_or_default(), + ); + } + + format!( + "{}{}{}{}", + if host.is_empty() { "" } else { "/" }, + host, + url.path(), + url.query() + .map(|query| format!("?{query}")) + .unwrap_or_default(), + ) + &url + .fragment() + .map(|fragment| format!("#{fragment}")) + .unwrap_or_default() +} + +pub(crate) fn normalize_desktop_deep_link_url(raw_url: &Url) -> Option { + let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; + let target_path = match raw_url.scheme() { + "genarrative" => extract_path_from_custom_scheme(raw_url), + "https" if raw_url.origin() == base_url.origin() => { + format!( + "{}{}{}", + raw_url.path(), + raw_url + .query() + .map(|query| format!("?{query}")) + .unwrap_or_default(), + raw_url + .fragment() + .map(|fragment| format!("#{fragment}")) + .unwrap_or_default(), + ) + } + _ => return None, + }; + let mut target_url = base_url.join(&target_path).ok()?; + if target_url.origin() != base_url.origin() { + return None; + } + + let retained_query_pairs = target_url + .query_pairs() + .filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref())) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + target_url + .query_pairs_mut() + .clear() + .extend_pairs( + retained_query_pairs + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ) + .append_pair("clientRuntime", "native_app") + .append_pair("clientType", "native_app") + .append_pair("hostShell", "tauri_desktop") + .append_pair("hostPlatform", desktop_platform()) + .append_pair("hostVersion", env!("CARGO_PKG_VERSION")) + .append_pair("bridgeVersion", &HOST_BRIDGE_VERSION.to_string()) + .append_pair("hostCapabilities", &capabilities().join(",")); + if target_url.origin() != base_url.origin() { + return None; + } + + Some(target_url) +} + +fn open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) { + let Some(target_url) = normalize_desktop_deep_link_url(url) else { + return; + }; + + let _ = window.navigate(target_url); + let _ = show_main_window(window.app_handle()); +} + +pub(crate) fn register_desktop_deep_link_events(app: &tauri::App) -> tauri::Result<()> { + let app_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + let Some(window) = app_handle.get_webview_window("main") else { + return; + }; + + for url in event.urls() { + open_desktop_deep_link_url(&window, &url); + } + }); + + let Some(window) = app.get_webview_window("main") else { + return Ok(()); + }; + if let Ok(Some(urls)) = app.deep_link().get_current() { + for url in urls { + open_desktop_deep_link_url(&window, &url); + } + } + + Ok(()) +} + +pub(crate) fn register_desktop_deep_link_schemes(app: &tauri::App) { + let _ = app.deep_link().register_all(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn normalized(raw_url: &str) -> Url { + normalize_desktop_deep_link_url(&Url::parse(raw_url).expect("deep link url")) + .expect("normalized deep link url") + } + + #[test] + fn custom_scheme_open_host_maps_to_same_origin_h5_route_with_host_context() { + let url = normalized("genarrative://open/works/detail?work=PZ-1#play"); + + assert_eq!(url.origin().ascii_serialization(), WEB_APP_ORIGIN); + assert_eq!(url.path(), "/works/detail"); + assert_eq!( + url.query_pairs().find(|(key, _)| key == "work").unwrap().1, + "PZ-1" + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "clientRuntime") + .unwrap() + .1, + "native_app" + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "hostShell") + .unwrap() + .1, + "tauri_desktop" + ); + assert_eq!(url.fragment(), Some("play")); + } + + #[test] + fn custom_scheme_without_reserved_host_keeps_host_as_first_path_segment() { + let url = normalized("genarrative://works/detail?work=PZ-1"); + + assert_eq!(url.path(), "/works/detail"); + assert_eq!( + url.query_pairs().find(|(key, _)| key == "work").unwrap().1, + "PZ-1" + ); + } + + #[test] + fn same_origin_https_link_keeps_path_and_restores_desktop_host_context() { + let url = + normalized("https://app.genarrative.world/creation/puzzle?clientRuntime=browser#draft"); + + assert_eq!(url.path(), "/creation/puzzle"); + assert_eq!( + url.query_pairs() + .filter(|(key, _)| key == "clientRuntime") + .count(), + 1 + ); + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "clientType") + .unwrap() + .1, + "native_app" + ); + assert_eq!(url.fragment(), Some("draft")); + } + + #[test] + fn external_and_unsafe_links_are_rejected() { + for raw_url in [ + "https://example.com/works/detail?work=PZ-1", + "http://app.genarrative.world/works/detail?work=PZ-1", + "mailto:hi@example.com", + "javascript:alert(1)", + ] { + let url = Url::parse(raw_url).expect("url"); + assert_eq!(normalize_desktop_deep_link_url(&url), None); + } + } + + #[test] + fn desktop_deep_link_config_check_requires_custom_scheme() { + let configured = serde_json::json!({ + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["genarrative"] + } + } + } + }); + let missing = serde_json::json!({ + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["example"] + } + } + } + }); + + assert_eq!( + configured + .get("plugins") + .and_then(|plugins| plugins.get("deep-link")) + .and_then(|deep_link| deep_link.get("desktop")) + .and_then(|desktop| desktop.get("schemes")) + .and_then(serde_json::Value::as_array) + .and_then(|schemes| schemes.first()) + .and_then(serde_json::Value::as_str), + Some("genarrative") + ); + assert_ne!( + missing + .get("plugins") + .and_then(|plugins| plugins.get("deep-link")) + .and_then(|deep_link| deep_link.get("desktop")) + .and_then(|desktop| desktop.get("schemes")) + .and_then(serde_json::Value::as_array) + .and_then(|schemes| schemes.first()) + .and_then(serde_json::Value::as_str), + Some("genarrative") + ); + } +} diff --git a/apps/desktop-shell/src-tauri/src/shell/mod.rs b/apps/desktop-shell/src-tauri/src/shell/mod.rs index a7d7130e2..bbd8f9a44 100644 --- a/apps/desktop-shell/src-tauri/src/shell/mod.rs +++ b/apps/desktop-shell/src-tauri/src/shell/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod deep_link; pub(crate) mod tray; pub(crate) mod webview; diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index a8b833e9d..c9edcc3ec 100644 --- a/apps/desktop-shell/src-tauri/tauri.conf.json +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -39,5 +39,12 @@ "icons/icon.ico", "icons/icon.png" ] + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["genarrative"] + } + } } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 062206a03..6c35b11d6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -61,7 +61,8 @@ - 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 覆盖托盘配置、菜单动作映射和关闭策略。 -- 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd / 深链内容作为事件透传给 H5。桌面深链后续如需接入,必须先定义受控 URL 归一和宿主边界,不能借单实例回调直接开放任意启动参数。 +- 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd 作为事件透传给 H5。Windows / Linux 的二次实例深链只通过 single-instance 的 `deep-link` feature 交给 Tauri deep-link 插件,再由 `shell/deep_link.rs` 做受控 URL 归一。 +- 2026-06-18 Tauri 桌面深链:桌面壳启用 `tauri-plugin-deep-link`,但不安装 JS guest 包、不把 deep-link command 加入主窗口 capability,也不新增 HostBridge capability。Tauri 配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://` 和 `https://app.genarrative.world/...`,统一跳转到同源 H5 并补写 `native_app`、`tauri_desktop`、当前平台、版本和真实 capability 清单;外域、明文协议和危险协议不进入主 WebView。 - 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 桌面壳观测与渠道 SDK 边界:Tauri 桌面壳默认不接入崩溃上报、analytics、遥测日志、自动更新或渠道分发 SDK;Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等 Node / Cargo 依赖、`package-lock.json` / `Cargo.lock` 解析包和 Rust 初始化片段都会被配置检查拒绝。后续只有在真实端点、采集字段、用户授权、隐私披露、签名和发布流程确定后,才能按单项能力更新方案并接入。 - 2026-06-18 桌面壳 HostBridge 版本边界:Tauri release / dev 入口 URL 的 `hostVersion` 必须与 `tauri.conf.json`、`apps/desktop-shell/package.json` 和 Cargo package 版本一致;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会拒绝入口 query 版本、Tauri 配置版本或 Rust runtime 版本来源分叉。 @@ -85,7 +86,7 @@ - 2026-06-18 移动壳 WebView 安全开关:Expo 移动壳 WebView 必须显式禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;同源主站页面才能留在带 HostBridge 的 WebView 内,外链只通过受控协议离开容器交给系统。配置检查和移动壳导航测试会拒绝这些边界被放宽。 - 2026-06-18 移动壳 WebView 默认下载边界:Expo WebView 内网页自动下载和 `` 直接落盘默认关闭;壳层注入脚本阻断 download 链接,iOS `onFileDownload` 只丢弃不落盘,Android 包配置通过 `blockedPermissions` 移除外部存储读写、管理外部存储和请求安装包权限。移动端文本、图片、音频保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等 HostBridge 受控导出能力进入系统分享 / 保存面板。 - 2026-06-18 移动壳 HostBridge 消息来源校验:Expo 移动壳 `onMessage` 必须根据 `event.nativeEvent.url` 校验消息来源,只有同源主站页面能进入 `handleMobileHostBridgeMessage`;`about:blank`、外域、协议降级和危险协议页面消息直接丢弃,不返回宿主能力错误细节。该规则与 WebView 导航留壳规则共用同源判断,配置检查和移动壳导航测试会拒绝移除。 -- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 +- 2026-06-18 三端桥接层目录同构:微信小程序、Expo 移动壳和 Tauri 桌面壳都按 `host-bridge / shell` 两层管理宿主桥接代码。微信 `miniprogram/host-bridge/wechatHostBridge*.js` 只放协议归一、支付 / 订阅 / 分享结果编解码和可测试桥接函数,`miniprogram/shell/wechatShell*.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂;页面目录只保留 `Page(createWechat...Page())` 装配。Tauri `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为。`npm run check:native-shells` 会校验微信、移动和桌面三端目录清单,新增宿主能力必须按同一边界落文件和测试。 - 影响范围:`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`。 @@ -2474,6 +2475,6 @@ ## 2026-06-18 三端宿主桥接层文件结构对齐 - 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。 -- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 负责协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单。 +- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 负责协议分发,`apps/mobile-shell/src/shell/mobileShell*.ts` 负责 URL、导航、网络、生命周期、安全区和 WebView policy;Tauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 deep link、tray、webview 分文件承接容器行为,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单。 - 影响范围:`miniprogram/host-bridge/`、`miniprogram/pages/*/index.js`、`apps/mobile-shell/src/`、`apps/desktop-shell/src-tauri/src/`、`scripts/check-native-shells.mjs`、宿主壳方案文档。 - 验证方式:`npm run test -- miniprogram/host-bridge/wechatHostBridgeWebView.test.js miniprogram/host-bridge/wechatHostBridgePayment.test.js miniprogram/host-bridge/wechatHostBridgeShareGrid.test.js miniprogram/host-bridge/wechatHostBridgeSubscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js`、`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index f6b1319ab..836185f53 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -188,6 +188,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 - 主 WebView 显式关闭 DevTools,Cargo 不启用 Tauri `devtools` feature;本地调试通过普通浏览器和 Vite 完成,不把可分发桌面壳变成调试容器。 - 崩溃上报、前端 analytics、桌面遥测日志、自动更新和渠道分发 SDK 都必须等真实端点、采集字段、用户同意、隐私策略、签名和发布流程确定后逐项接入;当前桌面壳不安装 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等相关依赖。 - 桌面壳和根 H5 包不安装 `@tauri-apps/api` 或 `@tauri-apps/plugin-*` JS guest 包;生产 H5 只通过 Tauri 注入的 `window.__TAURI__.core.invoke('host_bridge_request', request)` 进入 HostBridge。opener、clipboard、dialog、notification 等能力只保留 Rust Cargo 插件,由 Rust 内部分发并受 capability 白名单约束。 +- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://app.genarrative.world` URL;壳层会把目标路径归一为带 `native_app`、`tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。 桌面 release 和 dev 模式: @@ -348,7 +349,9 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳启用 Tauri 真实系统托盘,并复用品牌图标。托盘菜单只提供宿主壳级动作:显示主窗口、刷新主窗口和退出应用;左键点击托盘图标恢复并聚焦主窗口。该能力不进入 HostBridge capability 清单,不向 H5 暴露托盘 API、菜单 API、shell API 或任意窗口控制;如果当前桌面环境无法注册托盘,壳会继续启动主窗口。托盘注册成功时,用户点击主窗口关闭按钮会隐藏到托盘,必须通过托盘“退出”动作结束应用;托盘注册失败时不拦截关闭,避免窗口消失后没有恢复入口。 -2026-06-18 追加:桌面壳启用 Tauri 单实例。用户重复启动桌面 App 时,新实例会退出并唤醒已有主窗口;该回调只执行显示、取消最小化和聚焦主窗口,不把第二实例的命令行参数、工作目录或深链内容作为事件透传给 H5。桌面深链若后续需要接入,必须单独定义受控来源、路径归一和 HostBridge / GameBridge 边界。 +2026-06-18 追加:桌面壳启用 Tauri 单实例。用户重复启动桌面 App 时,新实例会退出并唤醒已有主窗口;该回调只执行显示、取消最小化和聚焦主窗口,不把第二实例的命令行参数或工作目录作为事件透传给 H5。Windows / Linux 上由单实例插件的 `deep-link` feature 把二次实例 URL 交给 deep-link 插件,仍由 `shell/deep_link.rs` 做受控归一。 + +2026-06-18 追加:桌面壳接入 Tauri deep-link 插件,但不开放插件 JS guest API,也不把 deep-link 命令加入 capability。桌面配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://` 和 `https://app.genarrative.world/...`,统一跳到同源 H5 路径并补写 `clientRuntime=native_app`、`hostShell=tauri_desktop`、当前平台、版本与真实 `hostCapabilities`。外域、`http:`、`mailto:`、`javascript:`、`file:` 等来源不进入主 WebView。 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 禁用约束。 @@ -412,7 +415,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/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 和 `apps/mobile-shell/src/shell/mobileShell*.ts`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面或桌面入口。 +2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/wechatHostBridge*.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/mobileHostBridge.ts` 和 `apps/mobile-shell/src/shell/mobileShell*.ts`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs` 与 `apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `shell/deep_link.rs`、`shell/tray.rs`、`shell/webview.rs` 分别承接深链、托盘和 WebView 容器行为,薄 `main.rs` 只声明两个模块并装配 Tauri builder / plugin / window。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面或桌面入口。 ### Phase 4:宿主能力扩展 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 2109e7733..98a55fe66 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -55,6 +55,7 @@ const expectedDesktopHostBridgeRustFiles = [ 'share.rs', ]; const expectedDesktopShellRustFiles = [ + 'deep_link.rs', 'mod.rs', 'tray.rs', 'webview.rs',