diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 880d3aaed..2024728bb 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -676,6 +676,30 @@ function extractRustStringArrayConst(source, constName) { return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]); } +function extractRustStringConst(source, constName) { + const match = source.match( + new RegExp(`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*&str\\s*=\\s*"([^"]+)";`), + ); + if (!match) { + throw new Error(`unable to read Rust const ${constName}`); + } + + return match[1]; +} + +function extractRustNumberConst(source, constName) { + const match = source.match( + new RegExp( + `(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|usize|i64|i32)\\s*=\\s*(\\d+);`, + ), + ); + if (!match) { + throw new Error(`unable to read Rust const ${constName}`); + } + + return Number(match[1]); +} + function extractTsStringConst(source, constName) { const match = source.match( new RegExp(`export const ${constName}\\s*=\\s*'([^']+)';`), @@ -687,6 +711,17 @@ function extractTsStringConst(source, constName) { return match[1]; } +function extractTsNumberConst(source, constName) { + const match = source.match( + new RegExp(`export const ${constName}\\s*=\\s*(\\d+);`), + ); + if (!match) { + throw new Error(`unable to read TypeScript const ${constName}`); + } + + return Number(match[1]); +} + function extractDesktopCapabilities(source) { const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/); if (!match) { @@ -713,17 +748,25 @@ function extractDesktopHandledMethods(source) { ]; } -function resolveHostCapabilitiesFromUrl(rawUrl) { - const url = new URL(rawUrl, 'https://app.genarrative.world/'); - return (url.searchParams.get('hostCapabilities') ?? '') - .split(',') - .map((capability) => capability.trim()) - .filter(Boolean); -} +function assertNoRawHostContextUrl(urlValue, label) { + const rawUrl = String(urlValue ?? ''); + const blockedQueryKeys = [ + 'clientRuntime', + 'clientType', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', + ]; -function resolveHostVersionFromUrl(rawUrl) { - const url = new URL(rawUrl, 'https://app.genarrative.world/'); - return url.searchParams.get('hostVersion'); + for (const key of blockedQueryKeys) { + if (rawUrl.includes(`${key}=`)) { + throw new Error( + `${label} must not hardcode ${key}; desktop Rust shell/url.rs must append host context`, + ); + } + } } function assertSameList(actual, expected, label) { @@ -840,7 +883,23 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); +const sharedHostBridgeProtocol = extractTsStringConst( + sharedContractSource, + 'HOST_BRIDGE_PROTOCOL', +); +const sharedHostBridgeVersion = extractTsNumberConst( + sharedContractSource, + 'HOST_BRIDGE_VERSION', +); const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS'); +const desktopHostBridgeProtocol = extractRustStringConst( + rustHostSource, + 'HOST_BRIDGE_PROTOCOL', +); +const desktopHostBridgeVersion = extractRustNumberConst( + rustHostSource, + 'HOST_BRIDGE_VERSION', +); const desktopCapabilities = extractDesktopCapabilities( desktopHostBridgeCapabilitiesSource, ); @@ -848,6 +907,18 @@ const desktopHandledMethods = extractDesktopHandledMethods( desktopHostBridgeDispatchSource, ); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; +if (desktopHostBridgeProtocol !== sharedHostBridgeProtocol) { + throw new Error( + `desktop shell HostBridge protocol drifted: expected ${sharedHostBridgeProtocol} but got ${desktopHostBridgeProtocol}`, + ); +} + +if (desktopHostBridgeVersion !== sharedHostBridgeVersion) { + throw new Error( + `desktop shell HostBridge version drifted: expected ${sharedHostBridgeVersion} but got ${desktopHostBridgeVersion}`, + ); +} + assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist'); const unknownHandledDesktopMethods = desktopHandledMethods.filter( (method) => !sharedMethods.includes(method), @@ -953,14 +1024,17 @@ if (String(mainWindow.url ?? '').startsWith('http')) { throw new Error('desktop shell release window must load packaged H5 assets'); } -if (!String(mainWindow.url ?? '').startsWith('index.html?')) { +if (String(mainWindow.url ?? '') !== 'index.html') { throw new Error('desktop shell release window must enter through packaged index.html'); } -if (!String(config.build?.devUrl ?? '').startsWith('http://127.0.0.1:3000/?')) { +if (String(config.build?.devUrl ?? '') !== 'http://127.0.0.1:3000/') { throw new Error('desktop shell dev URL must load the local Vite H5 entry'); } +assertNoRawHostContextUrl(mainWindow.url, 'desktop shell main window URL'); +assertNoRawHostContextUrl(config.build?.devUrl, 'desktop shell dev URL'); + if (mainWindow.devtools !== false) { throw new Error('desktop shell main WebView devtools must be explicitly disabled'); } @@ -1015,53 +1089,6 @@ for (const requiredDevCspToken of [ } } -const requiredUrlParts = [ - 'clientRuntime=native_app', - 'clientType=native_app', - 'hostShell=tauri_desktop', - 'hostPlatform=unknown', - 'bridgeVersion=1', -]; - -for (const part of requiredUrlParts) { - if (!String(mainWindow.url ?? '').includes(part)) { - throw new Error(`desktop shell main window URL missing ${part}`); - } - if (!String(config.build?.devUrl ?? '').includes(part)) { - throw new Error(`desktop shell dev URL missing ${part}`); - } -} - -if (resolveHostVersionFromUrl(mainWindow.url) !== config.version) { - throw new Error('desktop shell main window hostVersion must match tauri.conf.json version'); -} - -if (resolveHostVersionFromUrl(config.build?.devUrl ?? '') !== config.version) { - throw new Error('desktop shell dev hostVersion must match tauri.conf.json version'); -} - -assertSameList( - resolveHostCapabilitiesFromUrl(mainWindow.url), - desktopCapabilities, - 'desktop shell main window hostCapabilities', -); -assertSameList( - resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? ''), - desktopCapabilities, - 'desktop shell dev hostCapabilities', -); - -for (const capability of sdkBackedCapabilities) { - if ( - resolveHostCapabilitiesFromUrl(mainWindow.url).includes(capability) || - resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? '').includes(capability) - ) { - throw new Error( - `desktop shell URL must not advertise ${capability} without a real SDK/channel flow`, - ); - } -} - const allowedPermissions = [ 'allow-host-bridge-request', ]; @@ -1251,7 +1278,7 @@ const requiredRustHostSnippets = [ '__genarrativeDesktopHistoryIndex', 'file.imageDropped', 'app.notification().builder()', - 'desktop_entry_url_with_platform', + 'desktop_entry_url_with_host_context', 'desktop_h5_url_with_host_context', 'desktop_h5_url_with_host_context(target_url)', 'desktop_h5_url_with_host_context(normalized_url)', diff --git a/apps/desktop-shell/src-tauri/src/shell/url.rs b/apps/desktop-shell/src-tauri/src/shell/url.rs index 81f66f02b..e9f3d74da 100644 --- a/apps/desktop-shell/src-tauri/src/shell/url.rs +++ b/apps/desktop-shell/src-tauri/src/shell/url.rs @@ -49,23 +49,33 @@ pub(crate) fn desktop_h5_url_with_host_context(mut target_url: Url) -> Option 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(); +fn append_desktop_host_context_to_url(mut url: Url) -> String { + let retained_query_pairs = url + .query_pairs() + .filter(|(key, _)| !HOST_CONTEXT_QUERY_KEYS.contains(&key.as_ref())) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + 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(",")); + + url.to_string() +} + +pub(crate) fn desktop_entry_url_with_host_context(raw_url: &str) -> String { + if let Ok(url) = Url::parse(raw_url) { + return append_desktop_host_context_to_url(url); } let (without_hash, hash) = raw_url @@ -80,17 +90,25 @@ pub(crate) fn desktop_entry_url_with_platform(raw_url: &str) -> String { query .split('&') .filter(|pair| { - !pair.is_empty() - && !pair - .split_once('=') - .map(|(key, _)| key == "hostPlatform") - .unwrap_or(false) + if pair.is_empty() { + return false; + } + let key = pair.split_once('=').map(|(key, _)| key).unwrap_or(pair); + !HOST_CONTEXT_QUERY_KEYS.contains(&key) }) .map(str::to_owned) .collect::>() }) .unwrap_or_default(); - pairs.push(format!("hostPlatform={platform}")); + pairs.extend([ + "clientRuntime=native_app".to_string(), + "clientType=native_app".to_string(), + "hostShell=tauri_desktop".to_string(), + format!("hostPlatform={}", desktop_platform()), + format!("hostVersion={}", env!("CARGO_PKG_VERSION")), + format!("bridgeVersion={HOST_BRIDGE_VERSION}"), + format!("hostCapabilities={}", capabilities().join(",")), + ]); let normalized_url = format!("{path}?{}", pairs.join("&")); if let Some(hash) = hash { format!("{normalized_url}#{hash}") @@ -104,12 +122,12 @@ pub(crate) fn desktop_window_config_with_runtime_platform( ) -> 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), + Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url), ), WebviewUrl::CustomProtocol(url) => WebviewUrl::CustomProtocol( - Url::parse(&desktop_entry_url_with_platform(url.as_str())).unwrap_or(url), + Url::parse(&desktop_entry_url_with_host_context(url.as_str())).unwrap_or(url), ), - WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_platform( + WebviewUrl::App(path) => WebviewUrl::App(PathBuf::from(desktop_entry_url_with_host_context( path.to_string_lossy().as_ref(), ))), other => other, @@ -181,14 +199,18 @@ mod tests { } #[test] - fn desktop_entry_url_replaces_static_platform_marker() { + fn desktop_entry_url_adds_host_context_from_plain_entries() { 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 = desktop_entry_url_with_host_context("http://127.0.0.1:3000/"); let dev_url = Url::parse(&dev_url).expect("dev url"); + assert_eq!( + dev_url + .query_pairs() + .find(|(key, _)| key == "clientRuntime"), + Some(("clientRuntime".into(), "native_app".into())) + ); assert_eq!( dev_url.query_pairs().find(|(key, _)| key == "hostPlatform"), Some(("hostPlatform".into(), platform.into())) @@ -200,13 +222,28 @@ mod tests { .count(), 1 ); - - let packaged_url = desktop_entry_url_with_platform( - "index.html?clientRuntime=native_app&hostPlatform=unknown&bridgeVersion=1#works", + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "hostVersion"), + Some(("hostVersion".into(), env!("CARGO_PKG_VERSION").into())) + ); + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "bridgeVersion"), + Some(("bridgeVersion".into(), HOST_BRIDGE_VERSION.to_string().into())) + ); + assert_eq!( + dev_url.query_pairs().find(|(key, _)| key == "hostCapabilities"), + Some(("hostCapabilities".into(), capabilities().join(",").into())) ); + let packaged_url = desktop_entry_url_with_host_context("index.html#works"); + + assert!(packaged_url.contains("clientRuntime=native_app")); + assert!(packaged_url.contains("clientType=native_app")); + assert!(packaged_url.contains("hostShell=tauri_desktop")); assert!(packaged_url.contains(&format!("hostPlatform={platform}"))); - assert!(!packaged_url.contains("hostPlatform=unknown")); + assert!(packaged_url.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION")))); + assert!(packaged_url.contains(&format!("bridgeVersion={HOST_BRIDGE_VERSION}"))); + assert!(packaged_url.contains(&format!("hostCapabilities={}", capabilities().join(",")))); assert!(packaged_url.ends_with("#works")); } } diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 76d9a5381..aadd334c6 100644 --- a/apps/desktop-shell/src-tauri/tauri.conf.json +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "npm --prefix ../.. run dev:web", "beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck", - "devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,host.events,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,navigation.canGoBack,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.exportAudio,file.imageDropped,notification.showLocal", + "devUrl": "http://127.0.0.1:3000/", "frontendDist": "../../../dist" }, "app": { @@ -14,7 +14,7 @@ { "create": false, "label": "main", - "url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,host.events,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,navigation.canGoBack,app.reloadWebView,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,clipboard.readText,file.exportText,file.importText,file.exportImage,file.importImage,file.importAudio,file.exportAudio,file.imageDropped,notification.showLocal", + "url": "index.html", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4acd95a25..3e545c689 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,7 +26,7 @@ - 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。 - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。 - 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。 -- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;新增能力必须先更新契约和真实壳实现,再通过这些检查。 +- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck` 与 `npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 也由 `miniprogram/host-bridge/protocol.test.js` 反查同一共享白名单。新增能力必须先更新契约和真实壳实现,再通过这些检查。 - 2026-06-18 原生壳统一验收门禁:根级 `npm run check:native-shells` 统一执行 H5 HostBridge 关键测试、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测和微信 / Expo / Tauri 三端生产壳临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和壳生产源码禁替身验收散落成容易漏跑的单项命令。 - 2026-06-18 微信壳桥接层纳入统一验收:`npm run check:native-shells` 还会运行 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的微信壳测试,覆盖 WebView 入口、登录触发、分享目标、支付结果、订阅消息结果和九宫切图行为;三端桥接层文件结构检查只证明目录边界,行为回归必须由同一门禁中的微信壳测试证明。 - 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。 @@ -53,6 +53,7 @@ - 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://app.genarrative.world`,以及本机开发联调 `http://127.0.0.1`、`http://localhost`、`http://[::1]`;空值、相对路径、外域、`file:`、`javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射归一后基准 origin 的 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。 - 2026-06-18 移动壳主动导航上下文:Expo 壳的 `navigation.openNativePage` 与 deep link 都必须复用 `buildMobileShellUrl(...)` 补写 `native_app`、`expo_mobile`、真实平台、版本和 capability 清单;受控导航只接受当前允许 origin 的同源 H5 URL。移动壳配置检查会拒绝主动导航或 deep link 绕过该宿主上下文构造入口。 - 2026-06-18 移动壳协议常量来源:Expo 壳的 HostBridge 事件注入、入口 URL `bridgeVersion`、`host.getRuntime` 回包和 Expo public config smoke 必须使用 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`,不得在壳层重新写死协议名或版本字面量;配置检查会拒绝这些常量漂移。 +- 2026-06-18 桌面壳协议常量来源:Tauri Rust 侧 `host_bridge/protocol.rs` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`、桌面入口 URL `bridgeVersion`、HostBridge event 注入和 runtime 回包必须与 `packages/shared/src/contracts/hostBridge.ts` 保持一致;桌面配置检查会反查共享契约并拒绝协议名或协议版本漂移。`tauri.conf.json` 只保留基础入口,`shell/url.rs` 统一补写桌面宿主上下文和真实 capability 清单,配置检查会拒绝把 `hostCapabilities` 等宿主 query 长串重新写回 Tauri 配置。 - 2026-06-18 移动壳默认入口:Expo 壳默认 H5 地址固定为 `https://app.genarrative.world/`,开发联调本机 Vite 必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。 - 2026-06-18 移动壳安装包身份:Expo 移动壳的 iOS bundle identifier 与 Android package 统一固定为 `world.genarrative.mobile`,应用版本固定为 `0.1.0`,iOS `buildNumber` 从字符串 `"1"` 起步,Android `versionCode` 从整数 `1` 起步;后续分发安装包时递增构建号 / versionCode,产品版本号按发布节奏调整。移动壳配置检查会校验 `app.json` 与 `package.json` 版本一致,并拒绝缺失或漂移的包标识,当前不写入假商店元数据、假更新端点或占位渠道 SDK 配置。 - 2026-06-18 移动壳 HostBridge 版本单一来源:Expo 移动壳的 H5 入口 query 和 `host.getRuntime` 回包都读取 `MOBILE_SHELL_HOST_VERSION`,该常量必须与 `app.json` / `package.json` 版本一致;配置检查会拒绝 `App.tsx` 或 `bridge.ts` 重新散落硬编码版本,避免安装包版本升级时 H5 首屏上下文与 runtime 回读分叉。 @@ -71,8 +72,8 @@ - 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 版本来源分叉。 -- 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值;Rust `setup` 手动创建主窗口前必须把入口 URL 改写为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。 +- 2026-06-18 桌面壳 HostBridge 版本边界:Tauri release / dev 入口 URL 的 `hostVersion` 由 Rust `shell/url.rs` 从 Cargo package 版本统一补写;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会确认 `tauri.conf.json`、`apps/desktop-shell/package.json` 和 Cargo package 版本一致,并拒绝在 Tauri 配置里手写入口 query 版本。 +- 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置不再写入 `hostPlatform` 或其它宿主上下文 query;Rust `setup` 手动创建主窗口前必须把基础入口改写为当前 `macos` / `windows` / `linux` 平台和完整宿主上下文,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。 - 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。 - 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。 - 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index fb7448cf9..5240926a2 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -324,6 +324,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 HostBridge 协议名和协议版本统一从 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION` 读取。Expo 入口 query、WebView 事件注入、`host.getRuntime` 回包和 Expo public config smoke 都必须反查共享常量;移动壳配置检查会拒绝重新写死 `GenarrativeHostBridge` 或字面量版本。 +2026-06-18 追加:桌面壳 HostBridge 协议名和协议版本也必须反查同一共享契约。Tauri Rust 侧仍保留 `host_bridge/protocol.rs` 常量作为运行时代码入口,但 `apps/desktop-shell/scripts/check-config.mjs` 会把 Rust `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION` 与 `packages/shared/src/contracts/hostBridge.ts` 对齐,避免桌面壳事件注入、runtime 回包和 H5 transport 分叉。 + 2026-06-18 追加:移动壳默认 H5 地址固定为 `https://app.genarrative.world/`。开发联调如需加载本机 Vite,必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时默认加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。 2026-06-18 追加:移动壳系统深链声明固定为生产主站唯一入口。iOS `associatedDomains` 只能包含 `applinks:app.genarrative.world`;Android `intentFilters` 只能存在一个 `VIEW` / `autoVerify=true` 的 App Link 过滤器,category 只能是 `BROWSABLE` 和 `DEFAULT`,data 只能绑定 `https://app.genarrative.world`,不得额外声明外域、明文协议、pathPattern 或其它可接管范围。实际 deep link 解析仍由壳层把同源路径归一后附加 HostBridge 上下文,非法来源回退默认首页。 @@ -387,9 +389,9 @@ GameBridge 禁止: 2026-06-18 追加:桌面壳默认不接入崩溃上报、analytics、遥测日志或渠道分发 SDK。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等 Node / Cargo 依赖、`package-lock.json` / `Cargo.lock` 解析包和 Rust 初始化片段;后续只有在真实采集端点、数据字段、用户授权、隐私披露、签名和发布流程确定后,才能按单项能力补充方案与实现。 -2026-06-18 追加:桌面壳 release / dev 入口 URL 的 `hostVersion` 必须与 Tauri `tauri.conf.json`、Node package 和 Cargo package 版本一致;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会拒绝入口 query 版本、Tauri 配置版本或 Rust runtime 版本来源分叉,避免桌面包升级时 H5 首屏上下文与 runtime 回读不一致。 +2026-06-18 追加:桌面壳 release / dev 在 `tauri.conf.json` 中只保留基础入口 `index.html` 和 `http://127.0.0.1:3000/`,由 Rust `shell/url.rs` 统一补写 `clientRuntime=native_app`、`hostShell=tauri_desktop`、`hostVersion`、`bridgeVersion` 和真实 `hostCapabilities`。`hostVersion` 必须与 Tauri `tauri.conf.json`、Node package 和 Cargo package 版本一致,`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`;配置检查会拒绝在 Tauri 配置里重新手写宿主上下文,避免桌面包升级或能力变化时 H5 首屏上下文与 runtime 回读不一致。 -2026-06-18 追加:桌面壳静态 Tauri 配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值。Rust `setup` 手动创建主窗口前会把入口 URL 归一为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致;不通过第二实例参数、外部 deep link 或 H5 自报值覆盖该平台字段。 +2026-06-18 追加:桌面壳静态 Tauri 配置不再写入 `hostPlatform` 或其它宿主上下文 query。Rust `setup` 手动创建主窗口前会把基础入口归一为当前 `macos` / `windows` / `linux` 平台和完整宿主上下文,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致;不通过第二实例参数、外部 deep link 或 H5 自报值覆盖该平台字段。 2026-06-18 追加:桌面壳主 WebView 增加顶层导航边界。打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 可以继续留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航和 `window.open` 请求只交给系统 opener 后阻止留壳;`javascript:`、`file:` 等危险协议直接阻断。该规则不新增 HostBridge capability,也不开放 opener 插件 JS guest API,避免外域页面停留在带 `host_bridge_request` 权限的主 WebView 内。 diff --git a/miniprogram/host-bridge/protocol.test.js b/miniprogram/host-bridge/protocol.test.js index 9a25bd6f4..f43dac2b6 100644 --- a/miniprogram/host-bridge/protocol.test.js +++ b/miniprogram/host-bridge/protocol.test.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { describe, expect, test } from 'vitest'; +import { HOST_BRIDGE_CAPABILITIES } from '../../packages/shared/src/contracts/hostBridge.ts'; import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js'; const protocolPath = path.resolve( @@ -39,6 +40,11 @@ describe('wechat mini program host bridge protocol index', () => { 'share.setTarget', 'share.open', ]); + expect( + protocol.WECHAT_HOST_CAPABILITIES.filter( + (capability) => !HOST_BRIDGE_CAPABILITIES.includes(capability), + ), + ).toEqual([]); expect(protocol.WECHAT_AUTH_PAGE_URL).toBe( '/pages/web-view/index?authAction=login&returnTo=previous', );