diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index c50fbac83..f20b636ea 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -929,6 +929,10 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); +const sharedDesktopCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES', +); const sharedHostBridgeProtocol = extractTsStringConst( sharedContractSource, 'HOST_BRIDGE_PROTOCOL', @@ -1154,6 +1158,12 @@ if (unknownDesktopCapabilities.length > 0) { ); } +assertSameList( + desktopCapabilities, + sharedDesktopCapabilities, + 'desktop shell HostBridge capability profile', +); + for (const capability of sdkBackedCapabilities) { if (desktopCapabilities.includes(capability)) { throw new Error( diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index b09b14e08..638c3e472 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -481,6 +481,14 @@ const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); +const sharedMobileBaseCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', +); +const sharedMobileIosCapabilities = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', +); const sharedHostBridgeProtocol = extractStringConstExport( sharedContractSource, 'HOST_BRIDGE_PROTOCOL', @@ -498,14 +506,8 @@ const sharedPublicWebUrl = extractStringConstExport( 'HOST_BRIDGE_PUBLIC_WEB_URL', ); const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource); -const mobileCapabilities = extractStringArrayExport( - hostBridgeSource, - 'MOBILE_HOST_CAPABILITIES', -); -const iosMobileCapabilities = extractStringArrayExport( - hostBridgeSource, - 'IOS_MOBILE_HOST_CAPABILITIES', -); +const mobileCapabilities = sharedMobileBaseCapabilities; +const iosMobileCapabilities = sharedMobileIosCapabilities; const mobileCapabilitySet = new Set(mobileCapabilities); const iosMobileCapabilitySet = new Set(iosMobileCapabilities); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; @@ -553,6 +555,22 @@ for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) { } } +for (const profileSource of [ + 'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES', + 'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES', +]) { + if (!hostBridgeSource.includes(profileSource)) { + throw new Error(`mobile shell must use shared HostBridge profile ${profileSource}`); + } +} + +if ( + /export const MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource) || + /export const IOS_MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource) +) { + throw new Error('mobile shell must not redeclare HostBridge capability profiles'); +} + const unknownHandledMobileMethods = handledMobileMethods.filter( (method) => !sharedMethods.includes(method), ); diff --git a/apps/mobile-shell/src/host-bridge/capabilities.ts b/apps/mobile-shell/src/host-bridge/capabilities.ts index 3f2c96759..383e850fa 100644 --- a/apps/mobile-shell/src/host-bridge/capabilities.ts +++ b/apps/mobile-shell/src/host-bridge/capabilities.ts @@ -1,37 +1,15 @@ import { Platform } from 'react-native'; -import type { HostBridgeCapability } from '../../../../packages/shared/src/contracts/hostBridge'; +import { + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, +} from '../../../../packages/shared/src/contracts/hostBridge'; -export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ - 'host.getRuntime', - 'appearance.getColorScheme', - 'host.events', - 'app.lifecycle', - 'share.open', - 'share.setTarget', - 'navigation.openNativePage', - 'navigation.canGoBack', - 'app.reloadWebView', - 'app.openExternalUrl', - 'network.status', - 'network.statusChanged', - 'clipboard.writeText', - 'clipboard.readText', - 'file.exportText', - 'file.importText', - 'file.exportImage', - 'file.importImage', - 'file.captureImage', - 'file.importAudio', - 'file.exportAudio', - 'haptics.impact', - 'notification.showLocal', -]; +export const MOBILE_HOST_CAPABILITIES = + HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES; -export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ - ...MOBILE_HOST_CAPABILITIES, - 'app.setBadgeCount', -]; +export const IOS_MOBILE_HOST_CAPABILITIES = + HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES; export function resolveMobileHostCapabilities(platform = Platform.OS) { return platform === 'ios' diff --git a/apps/mobile-shell/src/shell/url.ts b/apps/mobile-shell/src/shell/url.ts index 214043dc1..ef5461326 100644 --- a/apps/mobile-shell/src/shell/url.ts +++ b/apps/mobile-shell/src/shell/url.ts @@ -9,7 +9,7 @@ import { export type MobileShellUrlOptions = { platform: Extract; hostVersion: string; - capabilities: HostBridgeCapability[]; + capabilities: readonly HostBridgeCapability[]; }; export const DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5a0d0cc84..8b2170483 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2438,6 +2438,13 @@ - 影响范围:`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 原生壳 capability profile 来源收口 + +- 背景:Expo 移动壳、Tauri 桌面壳和方案文档都需要维护真实 capability 子集;如果移动端源码、桌面 Rust 镜像和文档各自手写完整清单,后续新增能力时容易出现入口 URL、`host.getRuntime` 回包、文档和门禁漂移。 +- 决策:`packages/shared/src/contracts/hostBridge.ts` 中的 `HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES`、`HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 和 `HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES` 是原生壳 capability profile 来源。Expo 移动壳只通过 `apps/mobile-shell/src/host-bridge/capabilities.ts` 引用共享 profile 并选择平台差异;Tauri 桌面壳 `capabilities.rs` 仍保留 Rust 运行时镜像,但 `apps/desktop-shell/scripts/check-config.mjs` 和 `npm run check:native-shells` 必须反查共享桌面 profile。新增 native capability 必须先进入共享白名单和对应平台 profile,再补真实壳实现、H5 fallback、测试和文档。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/host-bridge/capabilities.ts`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run mobile-shell:typecheck`、`npm run desktop-shell:typecheck`、`npm run test -- packages/shared/src/contracts/hostBridge.test.ts`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 原生壳本地生成物边界 - 背景:`npm run check:native-shells` 会生成 Expo `.expo/` 日志、Expo export smoke 临时目录、Tauri schema、Tauri 自动生成权限和 Rust `target/` 产物。这些文件是本机工具输出,不是生产源码;如果进入生产壳敏感词扫描或被误提交,会让门禁受工具版本、构建日志或自动生成格式影响。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 3b75cc126..a6daefbc6 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -1,6 +1,6 @@ # Expo React Native 与 Tauri 宿主壳方案 -更新时间:`2026-06-18` +更新时间:`2026-06-19` ## 结论 @@ -64,7 +64,7 @@ src/ 已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。 -三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 承接能力清单与 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接能力清单,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。 +三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol`、`webView`、`payment`、`shareGrid`、`subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js`、`payment.js`、`shareGrid.js`、`subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配。Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 只引用共享 HostBridge capability profile 并选择 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用,`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade;`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policy。Tauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接共享桌面 capability profile 的 Rust 运行时镜像,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs`、`url.rs`、`navigation.rs`、`network.rs`、`lifecycle.rs`、`file_drop.rs`、`events.rs`、`deep_link.rs`、`tray.rs`、`menu.rs`、`window_state.rs` 和 `webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。 ## HostBridge 消息协议 @@ -242,9 +242,9 @@ GameBridge 禁止: - 壳层只接受来自允许 origin / packaged asset 的消息。 - H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 - 每个请求必须有超时;H5 的 React Native WebView transport 和 Tauri `invoke` transport 都必须在前端侧按 `timeoutMs` 释放请求,宿主侧执行超时也只能返回标准 HostBridge 错误。重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 -- HostBridge 的文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享契约常量,Tauri 壳按 Rust 运行时代码镜像并由配置门禁反查共享契约。 +- HostBridge 的 capability profile、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,Tauri 壳按 Rust 运行时代码镜像并由配置门禁反查共享契约。 - 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。 -- 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。 +- 壳能力声明与三端壳验收必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行微信壳测试集合、`npm run mobile-shell:typecheck`、`npm run mobile-shell:test`、`npm run mobile-shell:config`、`npm run mobile-shell:export`、`npm run desktop-shell:typecheck`、`npm run desktop-shell:test` 或 `npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须来自共享 HostBridge profile 并存在于共享白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、Expo managed config、移动端 production bundle、桌面 release 构建入口和微信 / Expo / Tauri 三端生产源码临时替身词扫描不得漂移。 - Expo SDK、React Native、`react-native-webview`、Tauri CLI、Tauri Rust crate 和桌面 Cargo 插件版本属于宿主壳行为边界。升级这些依赖前必须同步更新壳配置检查、`package-lock.json` / `Cargo.lock` 解析版本、本文档和对应验证结果,不能只改 package / Cargo 版本让生产壳行为静默漂移。 - 登录和支付能力在真实 SDK、渠道流程、后端契约和失败回退全部落地前不得进入 Expo / Tauri capabilities,也不得写进入口 URL `hostCapabilities`;两端配置检查会拒绝 `auth.requestLogin` 和 `payment.request` 的伪声明。 - 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。 @@ -282,7 +282,7 @@ GameBridge 禁止: 2026-06-18 追加:移动壳当前通用真实能力完整清单为 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`network.status`、`network.statusChanged`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.importText`、`file.exportImage`、`file.importImage`、`file.captureImage`、`file.importAudio`、`file.exportAudio`、`haptics.impact` 和 `notification.showLocal`。 -2026-06-18 追加:移动壳 iOS 额外真实能力为 `app.setBadgeCount`。`npm run check:native-shells` 会从源码 capability 清单反查移动壳和桌面壳能力清单段,避免实现、入口 URL 和方案文档再次漂移。 +2026-06-19 追加:移动壳 iOS 额外真实能力为 `app.setBadgeCount`。`packages/shared/src/contracts/hostBridge.ts` 中的 `HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES`、`HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES` 和 `HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES` 是原生壳能力 profile 来源;移动壳直接引用共享 profile,桌面壳 Rust 清单作为运行时镜像并由配置检查反查同一共享 profile。`npm run check:native-shells` 会从共享 profile 反查能力清单文档和桌面 Rust 镜像,避免实现、入口 URL 和方案文档再次漂移。 2026-06-18 追加:移动壳 `navigation.canGoBack` 不再只读取 `react-native-webview` 的原生跨文档导航状态。Expo 壳会在 WebView `injectedJavaScriptBeforeContentLoaded` 中注入固定脚本,追踪当前 H5 文档内 `pushState` / `replaceState` / `popstate` 写入的路由栈,并通过内部 `genarrative.mobile.historyState` 消息回传;壳层把原生 WebView back-forward 状态与 H5 当前文档路由栈状态合成为 HostBridge `navigation.canGoBack` 事件。Android 返回键优先执行固定 `window.history.back(); true;` 回退 H5 SPA 路由,只有 H5 当前文档不可回退时才调用 WebView 原生 `goBack()`;该内部消息不是 HostBridge request method,也不开放 H5 到原生的通用事件写入通道。 @@ -357,7 +357,7 @@ GameBridge 禁止: 当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端,H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `