接入桌面壳深链

新增Tauri桌面深链模块并归一到同源H5入口

启用deep-link插件与single-instance深链转发

更新原生壳门禁和架构文档
This commit is contained in:
2026-06-18 16:53:52 +08:00
parent 7609c37d5f
commit 75b1f8391f
10 changed files with 421 additions and 9 deletions
+37 -3
View File
@@ -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 (
+98
View File
@@ -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"
+2 -1
View File
@@ -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"] }
+4
View File
@@ -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(())
})
@@ -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<Url> {
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::<Vec<_>>();
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")
);
}
}
@@ -1,2 +1,3 @@
pub(crate) mod deep_link;
pub(crate) mod tray;
pub(crate) mod webview;
@@ -39,5 +39,12 @@
"icons/icon.ico",
"icons/icon.png"
]
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["genarrative"]
}
}
}
}
@@ -61,7 +61,8 @@
- 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 覆盖托盘配置、菜单动作映射和关闭策略。
- 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` schemeRust 层只接受 `genarrative://open/...``genarrative://app/...``genarrative://<path>``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、遥测日志、自动更新或渠道分发 SDKSentry、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 内网页自动下载和 `<a download>` 直接落盘默认关闭;壳层注入脚本阻断 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 无法直接调用 HostBridgeTauri 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 policyTauri 桌面壳拆成 `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 policyTauri 桌面壳拆成 `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`
@@ -188,6 +188,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。
- 主 WebView 显式关闭 DevToolsCargo 不启用 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` schemeRust 层只接受 `genarrative://open/...``genarrative://app/...``genarrative://<path>``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:宿主能力扩展
+1
View File
@@ -55,6 +55,7 @@ const expectedDesktopHostBridgeRustFiles = [
'share.rs',
];
const expectedDesktopShellRustFiles = [
'deep_link.rs',
'mod.rs',
'tray.rs',
'webview.rs',