diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 5963aae52..21b8737f9 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -734,6 +734,22 @@ function extractTsStringConst(source, constName) { return match[1]; } +function extractTsStringObjectConst(source, constName) { + const match = source.match( + new RegExp(`export const ${constName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`), + ); + if (!match) { + throw new Error(`unable to read TypeScript const ${constName}`); + } + + return Object.fromEntries( + [...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [ + entry[1], + entry[2], + ]), + ); +} + function extractTsNumberConst(source, constName) { const match = source.match( new RegExp(`export const ${constName}\\s*=\\s*([^;]+);`), @@ -968,6 +984,14 @@ const sharedPublicWebOrigin = extractTsStringConst( sharedContractSource, 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', ); +const sharedNativeAppQueryKeys = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS', +); +const sharedNativeAppQuery = extractTsStringObjectConst( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY', +); const sharedHostBridgePayloadLimits = { HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractTsNumberConst( sharedContractSource, @@ -1111,6 +1135,26 @@ if (desktopPublicWebOrigin !== sharedPublicWebOrigin) { ); } +assertSameList( + extractRustStringArrayConst(desktopShellUrlSource, 'HOST_CONTEXT_QUERY_KEYS'), + sharedNativeAppQueryKeys, + 'desktop shell host-context query keys', +); + +if (!desktopShellUrlSource.includes('fn append_desktop_host_context(url: &mut Url)')) { + throw new Error('desktop shell host-context query appending must stay centralized'); +} + +for (const [key, value, label] of [ + ['clientRuntime', sharedNativeAppQuery.clientRuntime, 'client runtime'], + ['clientType', sharedNativeAppQuery.clientType, 'client type'], + ['hostShell', sharedNativeAppQuery.hostShellTauriDesktop, 'host shell'], +]) { + if (!desktopShellUrlSource.includes(`.append_pair("${key}", "${value}")`)) { + throw new Error(`desktop shell host-context ${label} drifted from shared contract`); + } +} + for (const expectedNetworkSnippet of [ 'use crate::shell::url::WEB_APP_ORIGIN', 'Url::parse(WEB_APP_ORIGIN)', diff --git a/apps/desktop-shell/src-tauri/src/shell/url.rs b/apps/desktop-shell/src-tauri/src/shell/url.rs index e9f3d74da..27298b28c 100644 --- a/apps/desktop-shell/src-tauri/src/shell/url.rs +++ b/apps/desktop-shell/src-tauri/src/shell/url.rs @@ -15,6 +15,17 @@ const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [ "hostCapabilities", ]; +fn append_desktop_host_context(url: &mut Url) { + url.query_pairs_mut() + .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(",")); +} + pub(crate) fn desktop_h5_url_with_host_context(mut target_url: Url) -> Option { let base_url = Url::parse(WEB_APP_ORIGIN).ok()?; if target_url.scheme() != "https" || target_url.origin() != base_url.origin() { @@ -33,14 +44,8 @@ pub(crate) fn desktop_h5_url_with_host_context(mut target_url: Url) -> Option String { 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(",")); + ); + append_desktop_host_context(&mut url); url.to_string() } @@ -100,15 +99,13 @@ pub(crate) fn desktop_entry_url_with_host_context(raw_url: &str) -> String { .collect::>() }) .unwrap_or_default(); - 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 mut context_url = Url::parse(WEB_APP_ORIGIN).expect("desktop web origin"); + append_desktop_host_context(&mut context_url); + pairs.extend( + context_url + .query_pairs() + .map(|(key, value)| format!("{key}={value}")), + ); let normalized_url = format!("{path}?{}", pairs.join("&")); if let Some(hash) = hash { format!("{normalized_url}#{hash}") diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index f3cacf055..8a57f60f7 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -175,6 +175,22 @@ function extractStringConstExport(source, exportName) { return match[1]; } +function extractStringObjectConstExport(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Object.fromEntries( + [...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [ + entry[1], + entry[2], + ]), + ); +} + function extractNumberConstExport(source, exportName) { const match = source.match( new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`), @@ -540,6 +556,18 @@ const sharedPublicWebUrl = extractStringConstExport( sharedContractSource, 'HOST_BRIDGE_PUBLIC_WEB_URL', ); +const sharedNativeAppQueryKeys = extractStringArrayExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS', +); +const sharedNativeAppQueryKey = extractStringObjectConstExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY', +); +const sharedNativeAppQuery = extractStringObjectConstExport( + sharedContractSource, + 'HOST_BRIDGE_NATIVE_APP_QUERY', +); const sharedPublicWebOriginUrl = new URL(sharedPublicWebOrigin); if (sharedPublicWebOriginUrl.protocol !== 'https:') { throw new Error('shared HostBridge public web origin must use https for mobile app links'); @@ -1096,18 +1124,54 @@ if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world' for (const snippet of [ 'buildMobileShellUrl(', + 'HOST_BRIDGE_NATIVE_APP_QUERY', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY', 'HOST_BRIDGE_VERSION.toString()', - "url.searchParams.set('clientRuntime', 'native_app')", - "url.searchParams.set('hostShell', 'expo_mobile')", - "url.searchParams.set('hostPlatform', options.platform)", - "url.searchParams.set('bridgeVersion', HOST_BRIDGE_VERSION.toString())", - "url.searchParams.set('hostCapabilities', options.capabilities.join(','))", -]) { + 'HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime', + 'HOST_BRIDGE_NATIVE_APP_QUERY.clientType', + 'HOST_BRIDGE_NATIVE_APP_QUERY.hostShellExpoMobile', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientType', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostShell', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostPlatform', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostVersion', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.bridgeVersion', + 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostCapabilities', + ]) { if (!urlSource.includes(snippet)) { throw new Error(`mobile shell host-context URL builder missing ${snippet}`); } } +assertSameList( + Object.values(sharedNativeAppQueryKey), + sharedNativeAppQueryKeys, + 'shared native app query key map', +); +if ( + sharedNativeAppQuery.clientRuntime !== 'native_app' || + sharedNativeAppQuery.clientType !== 'native_app' || + sharedNativeAppQuery.hostShellExpoMobile !== 'expo_mobile' +) { + throw new Error('shared native app mobile query values drifted'); +} + +for (const hardcodedHostContextSnippet of [ + "url.searchParams.set('clientRuntime'", + "url.searchParams.set('clientType'", + "url.searchParams.set('hostShell'", + "url.searchParams.set('hostPlatform'", + "url.searchParams.set('hostVersion'", + "url.searchParams.set('bridgeVersion'", + "url.searchParams.set('hostCapabilities'", +]) { + if (urlSource.includes(hardcodedHostContextSnippet)) { + throw new Error( + 'mobile shell host-context URL builder must use shared query key constants', + ); + } +} + for (const snippet of [ 'resolveMobileShellBaseWebUrl(baseWebUrl)', 'resolveTargetPath(rawUrl, webOrigin)', diff --git a/apps/mobile-shell/src/shell/url.ts b/apps/mobile-shell/src/shell/url.ts index ef5461326..c47d5a071 100644 --- a/apps/mobile-shell/src/shell/url.ts +++ b/apps/mobile-shell/src/shell/url.ts @@ -1,4 +1,6 @@ import { + HOST_BRIDGE_NATIVE_APP_QUERY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEY, HOST_BRIDGE_PUBLIC_WEB_ORIGIN, HOST_BRIDGE_PUBLIC_WEB_URL, HOST_BRIDGE_VERSION, @@ -56,12 +58,33 @@ export function buildMobileShellUrl( options: MobileShellUrlOptions, ) { const url = new URL(resolveMobileShellBaseWebUrl(rawUrl)); - url.searchParams.set('clientRuntime', 'native_app'); - url.searchParams.set('clientType', 'native_app'); - url.searchParams.set('hostShell', 'expo_mobile'); - url.searchParams.set('hostPlatform', options.platform); - url.searchParams.set('hostVersion', options.hostVersion); - url.searchParams.set('bridgeVersion', HOST_BRIDGE_VERSION.toString()); - url.searchParams.set('hostCapabilities', options.capabilities.join(',')); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime, + HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientType, + HOST_BRIDGE_NATIVE_APP_QUERY.clientType, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostShell, + HOST_BRIDGE_NATIVE_APP_QUERY.hostShellExpoMobile, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostPlatform, + options.platform, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostVersion, + options.hostVersion, + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.bridgeVersion, + HOST_BRIDGE_VERSION.toString(), + ); + url.searchParams.set( + HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostCapabilities, + options.capabilities.join(','), + ); return url.toString(); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index aa3771a00..dd82b2232 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -29,10 +29,11 @@ - 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` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 由 `miniprogram/host-bridge/protocol.test.js` 和根级 `npm run check:native-shells` 反查共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES`。新增能力必须先更新契约和真实壳实现,再通过这些检查。 +- 2026-06-19 宿主上下文 query 契约收口:`packages/shared/src/contracts/hostBridge.ts` 是宿主上下文 query 字段和值的唯一 TypeScript 来源;`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime parser 字段,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query,`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要保留的宿主字段。Expo 壳直接引用共享常量,Tauri Rust 和微信 CommonJS 镜像由 `npm run check:native-shells` / 单壳配置检查反查;微信请求头必须从 `WEB_VIEW_SOURCE_QUERY` 读取 `clientType` / `clientRuntime`,不得另起常量。 - 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` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;根级 `npm run check` 会在 lint、主站测试、构建和内容检查后继续执行该门禁,避免 HostBridge、三端壳、Expo managed config、移动端 production bundle、桌面 release 入口和 H5 HostBridge 真实调用链禁替身验收散落成容易漏跑的单项命令。 - 2026-06-19 原生壳临时替身扫描范围:`npm run check:native-shells` 的生产替身词扫描必须覆盖微信小程序壳生产 `.js`、Expo / Tauri 壳源码与配置、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件;新增 H5 调用点接入 HostBridge 时,必须让自动扫描覆盖对应文件或在同等门禁中证明生产代码没有 mock / fake / stub / TODO / FIXME / 模拟 / 伪造。 - 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-19 微信壳路由一致性门禁:`npm run check:native-shells` 必须反查 `miniprogram/app.json.pages`、`miniprogram/host-bridge/protocol.js`、H5 `src/services/host-bridge/hostBridge.ts` 小程序页面常量、H5 `src/services/wechatMiniProgramSubscribe.ts` 订阅授权页面常量、`miniprogram/host-bridge/webView.js` 分享入口 / 分享消息类型和 `miniprogram/config.js` source query / 域名格式。新增或调整小程序页面、登录派生 URL、支付页、九宫切图页、订阅页、WebView 来源标记、H5 入口域名或 API base URL 时,必须同步这几处常量并保持生产 / 开发域名都显式配置为纯 HTTPS domain;运行时开发域名回退生产域名只作为异常兜底。 +- 2026-06-19 微信壳路由一致性门禁:`npm run check:native-shells` 必须反查 `miniprogram/app.json.pages`、`miniprogram/host-bridge/protocol.js`、H5 `src/services/host-bridge/hostBridge.ts` 小程序页面常量、H5 `src/services/wechatMiniProgramSubscribe.ts` 订阅授权页面常量、`miniprogram/host-bridge/webView.js` 分享入口 / 分享消息类型、`miniprogram/config.js` source query / 域名格式、`miniprogram/shell/webView.js` 请求头来源标记、H5 runtime parser 和 H5 路由保留字段。新增或调整小程序页面、登录派生 URL、支付页、九宫切图页、订阅页、WebView 来源标记、H5 入口域名、API base URL 或宿主上下文 query 字段时,必须同步这几处常量并保持生产 / 开发域名都显式配置为纯 HTTPS domain;运行时开发域名回退生产域名只作为异常兜底。 - 2026-06-18 登录 / 支付能力禁伪声明:`auth.requestLogin` 和 `payment.request` 保留在共享 HostBridge 契约中供未来真实接入,但 Expo / Tauri 壳在真实 SDK、渠道流程和后端契约落地前不得声明这些 capability,也不得把它们写入入口 URL `hostCapabilities`;两端检查脚本会拒绝伪声明,请求实际到达壳层时必须返回明确 `unsupported_method` 并让 H5 fallback,两端壳测试直接覆盖这两个 method。 - 2026-06-18 移动壳触觉反馈边界:`haptics.impact` 只接受 `light`、`medium`、`heavy` 三档 impact style,缺省为 `light`;未知值必须返回 `invalid_request`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 capability,H5 继续按 HostBridge fallback 处理。 - 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capability,H5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index d2a3d238d..48be09ef6 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -219,7 +219,7 @@ npm run check:native-shells 该命令会覆盖 H5 HostBridge 关键测试、微信 / Expo / Tauri 三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面壳 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描,确认 Expo managed config、移动端 iOS / Android production bundle、打包 H5 资产、Tauri release 入口和 H5 HostBridge 真实调用链没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。壳源码和配置继续严格禁止 mock / fake / placeholder / stub / TODO / FIXME / 占位 / 模拟 / 伪造;H5 业务调用链允许正常表单 `placeholder` 属性和业务占位图文案,但仍禁止 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。 该命令同时会运行微信小程序 `miniprogram/host-bridge/`、`miniprogram/shell/`、`pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。 -该命令还会反查微信小程序 `app.json.pages` 与 `host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY` 和 H5 / API base URL 格式,避免页面路由、来源标记或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。 +该命令还会反查微信小程序 `app.json.pages` 与 `host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。 内容检查: diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index cd1c31f95..93e5a5b5b 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -79,8 +79,11 @@ H5 进入原生 App 壳时由壳层附加稳定 query: &hostPlatform=ios|android|macos|windows|linux &hostVersion=0.1.0 &bridgeVersion=1 +&hostCapabilities=host.getRuntime,... ``` +这些字段名和值不在各壳里单独定义。`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 是 H5 runtime parser 的字段名来源,`HOST_BRIDGE_NATIVE_APP_QUERY_KEY` / `HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` / `HOST_BRIDGE_NATIVE_APP_QUERY` 是 Expo 与 Tauri 原生壳入口 query 来源,`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 是微信 WebView `clientType=mini_program` / `clientRuntime=wechat_mini_program` 来源,`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 是 H5 页面内导航保留宿主上下文的字段来源。Expo 直接导入共享常量,Tauri Rust 和微信 CommonJS 镜像由检查脚本反查共享契约。 + 消息 envelope 统一为 JSON: ```ts @@ -254,9 +257,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 的 capability profile、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度、二维码文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,微信小程序壳和 Tauri 壳分别保留小程序 CommonJS / Rust 运行时代码镜像并由测试和配置门禁反查共享契约。 +- HostBridge 的 capability profile、宿主上下文 query 字段和值、文件 MIME 清单、导入 / 导出体积上限、文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度、二维码文本长度和本地通知标题 / 正文长度都必须以 `packages/shared/src/contracts/hostBridge.ts` 为声明来源;Expo 移动壳直接导入共享 profile 和契约常量,微信小程序壳和 Tauri 壳分别保留小程序 CommonJS / 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 profile 并存在于共享白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、文件载荷边界、微信 WebView / 支付 / 订阅 / 分享桥接行为、微信小程序页面路由、WebView source query、微信壳 H5 / API HTTPS 域名格式、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 / 支付 / 订阅 / 分享桥接行为、微信小程序页面路由、WebView source query、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段、微信壳 H5 / API HTTPS 域名格式、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。 @@ -455,7 +458,7 @@ GameBridge 禁止: 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-19 追加:微信小程序壳路由一致性进入原生壳统一验收。`npm run check:native-shells` 会反查 `miniprogram/app.json.pages` 与 `miniprogram/host-bridge/protocol.js` 页面 URL 常量一致,H5 `src/services/host-bridge/hostBridge.ts` 的小程序登录、支付、九宫切图页面常量、H5 `src/services/wechatMiniProgramSubscribe.ts` 的订阅授权页面常量与微信协议常量一致,`miniprogram/host-bridge/webView.js` 的 WebView 分享入口和分享目标消息类型不漂移;同时会校验 `miniprogram/config.js` 的生产 / 开发 H5 入口与 API base URL 都是显式配置的纯 HTTPS 域名,`WEB_VIEW_SOURCE_QUERY` 只包含 `clientType=mini_program` 和 `clientRuntime=wechat_mini_program`。运行时对开发域名的生产域名回退只作为异常兜底,不作为配置口径;新增小程序页面、改页面路径、调整来源 query 或切换域名格式时,必须同步协议常量、H5 HostBridge / 订阅服务常量、`app.json` 和这条门禁。 +2026-06-19 追加:微信小程序壳路由一致性进入原生壳统一验收。`npm run check:native-shells` 会反查 `miniprogram/app.json.pages` 与 `miniprogram/host-bridge/protocol.js` 页面 URL 常量一致,H5 `src/services/host-bridge/hostBridge.ts` 的小程序登录、支付、九宫切图页面常量、H5 `src/services/wechatMiniProgramSubscribe.ts` 的订阅授权页面常量与微信协议常量一致,`miniprogram/host-bridge/webView.js` 的 WebView 分享入口和分享目标消息类型不漂移;同时会校验 `miniprogram/config.js` 的生产 / 开发 H5 入口与 API base URL 都是显式配置的纯 HTTPS 域名,`WEB_VIEW_SOURCE_QUERY` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 一致,`miniprogram/shell/webView.js` 请求头从 `WEB_VIEW_SOURCE_QUERY` 读取 `clientType` / `clientRuntime`,H5 runtime parser 读取共享 `HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY`,H5 路由保留字段读取共享 `HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS`。运行时对开发域名的生产域名回退只作为异常兜底,不作为配置口径;新增小程序页面、改页面路径、调整来源 query 或切换域名格式时,必须同步协议常量、H5 HostBridge / 订阅服务常量、`app.json`、共享 HostBridge query 契约和这条门禁。 2026-06-18 追加:原生壳本地生成物不作为生产源码门禁输入。Expo `.expo/`、Expo export smoke 临时目录、Tauri `target/`、Tauri schema `gen/` 和 Tauri 自动生成权限目录都必须保持 gitignored;根级生产壳敏感词扫描只检查可提交的壳源码和配置,避免本机工具输出影响生产门禁。手写 capability / 权限配置仍需保留在扫描范围内。 @@ -503,7 +506,7 @@ GameBridge 禁止: - AI sandbox 无法调用 HostBridge,也无法读取 H5 登录态。 - Tauri release 包不允许任意远端页面调用桌面命令。 - Expo WebView 外链离开主站后不保留完整 HostBridge。 -- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、微信小程序页面路由与来源 query 反查、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。该扫描范围必须包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。 +- 根级验收入口 `npm run check:native-shells` 必须同时覆盖 H5 HostBridge 关键路径、宿主上下文 query 契约、微信小程序页面路由与来源 query 反查、三端桥接层文件结构门禁、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描。该扫描范围必须包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。 ## 参考资料 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index c155f2009..cfdfe4165 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -43,6 +43,8 @@ AI H5 sandbox Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗口配置,并在创建 WebView 前补写 `native_app`、`tauri_desktop` 和真实 capability 上下文;缺少主窗口配置时启动直接失败,不允许按 `windows[0]` 兜底或无主窗口静默运行。 +宿主上下文 query 的字段名和值以 `packages/shared/src/contracts/hostBridge.ts` 为源。`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime 识别可读取的 `clientRuntime`、`clientType`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;`HOST_BRIDGE_NATIVE_APP_QUERY_KEY`、`HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` 与 `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query;`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记;`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要跨路径保留的宿主字段。Expo 移动壳直接引用共享常量,Tauri Rust 和微信小程序 CommonJS 运行时镜像由 `npm run check:native-shells` 反查;H5 `getHostRuntime()` 和路由保留列表不得重新手写这些字段。 + ## 首批能力 - `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。 @@ -79,7 +81,7 @@ HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_ 2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。 3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。 4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。 -5. 每次新增或调整 native capability / HostBridge event 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应微信 / Expo / Tauri capability profile 和事件白名单,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、微信小程序页面路由与 H5 常量反查、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;排查单端问题时再单独运行 `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`。 +5. 每次新增或调整 native capability、HostBridge event 或宿主上下文 query 后,必须先更新 `packages/shared/src/contracts/hostBridge.ts` 中对应微信 / Expo / Tauri capability profile、事件白名单和 query 契约,再运行 `npm run check:native-shells`,统一覆盖 H5 HostBridge 关键测试、三端桥接层文件结构门禁、微信小程序页面路由与 H5 常量反查、Expo 壳 typecheck / test / config smoke / Metro export smoke、Tauri 壳 typecheck / cargo test、桌面 release `--no-bundle` 构建烟测,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描;排查单端问题时再单独运行 `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`。 ## 验收 @@ -88,7 +90,7 @@ HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_ - 小程序支付仍跳转 `/pages/wechat-pay/index` 并保留支付结果 hash 回灌确认。 - 小程序订阅授权仍跳转 `/pages/subscribe-message/index`,且返回不阻断生成主链路。 - 普通浏览器分享、H5 支付和 Native 二维码支付不受影响。 -- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、微信 / Expo / Tauri 共享 capability profile、HostBridge event 白名单、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、微信小程序 `app.json.pages` 与 `protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、WebView source query、生产 / 开发 H5 与 API HTTPS 域名格式、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 +- 原生壳统一验收入口 `npm run check:native-shells` 通过,能力白名单、微信 / Expo / Tauri 共享 capability profile、HostBridge event 白名单、宿主上下文 query 契约、壳 runtime 回包、URL `hostCapabilities`、H5 fallback、微信小程序 `app.json.pages` 与 `protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、WebView source query、微信请求头来源标记、H5 路由保留字段、生产 / 开发 H5 与 API HTTPS 域名格式、三端桥接层结构、两端壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口,以及可分发壳与 H5 HostBridge 真实调用链的临时替身词扫描没有漂移;扫描范围包含微信小程序壳生产 `.js`、共享 HostBridge 契约、H5 native transport,并自动覆盖已接入真实宿主能力 facade 的 H5 生产调用链文件。H5 业务文件允许正常表单 `placeholder` 属性和业务占位图文案,但不得出现 mock / fake / stub / TODO / FIXME / 模拟 / 伪造等替身痕迹。 ## 后续 diff --git a/miniprogram/shell/webView.js b/miniprogram/shell/webView.js index fe8f8d489..bad9b202d 100644 --- a/miniprogram/shell/webView.js +++ b/miniprogram/shell/webView.js @@ -18,8 +18,6 @@ const { resolveWebViewUrlFromRuntimeConfig, } = require('../host-bridge/webView'); -const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; -const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id'; const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; @@ -69,6 +67,10 @@ function trimTrailingSlash(value) { return String(value || '').trim().replace(/\/+$/u, ''); } +function readWebViewSourceQueryValue(key) { + return String((WEB_VIEW_SOURCE_QUERY && WEB_VIEW_SOURCE_QUERY[key]) || '').trim(); +} + function isConfiguredApiBaseUrl(value) { return /^https:\/\/[^/]+/i.test(String(value || '').trim()); } @@ -306,8 +308,8 @@ function requestMiniProgramLogin(code, displayName) { }, header: { 'content-type': 'application/json', - 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, - 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, + 'x-client-type': readWebViewSourceQueryValue('clientType'), + 'x-client-runtime': readWebViewSourceQueryValue('clientRuntime'), 'x-client-platform': resolveClientPlatform(), 'x-client-instance-id': getClientInstanceId(), 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, @@ -352,8 +354,8 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode, displayName) { header: { authorization: `Bearer ${authToken}`, 'content-type': 'application/json', - 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, - 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, + 'x-client-type': readWebViewSourceQueryValue('clientType'), + 'x-client-runtime': readWebViewSourceQueryValue('clientRuntime'), 'x-client-platform': resolveClientPlatform(), 'x-client-instance-id': getClientInstanceId(), 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index 8ec0c79b6..b61decaee 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -4,8 +4,14 @@ import { HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES, HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES, HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES, + HOST_BRIDGE_NATIVE_APP_QUERY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEY, + HOST_BRIDGE_NATIVE_APP_QUERY_KEYS, + HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS, + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES, HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES, + HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, HOST_BRIDGE_PUBLIC_WEB_ORIGIN, HOST_BRIDGE_PUBLIC_WEB_URL, HOST_BRIDGE_TAURI_COMMAND, @@ -37,6 +43,52 @@ describe('HostBridge shared contract helpers', () => { expect(HOST_BRIDGE_TAURI_COMMAND).toBe('host_bridge_request'); }); + test('固定宿主上下文 query 契约', () => { + expect(HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY).toEqual({ + clientRuntime: 'clientRuntime', + clientType: 'clientType', + miniProgramEnv: 'miniProgramEnv', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', + }); + expect(HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS).toEqual([ + 'clientType', + 'clientRuntime', + 'miniProgramEnv', + ]); + expect(HOST_BRIDGE_NATIVE_APP_QUERY_KEY).toEqual({ + clientRuntime: 'clientRuntime', + clientType: 'clientType', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', + }); + expect(HOST_BRIDGE_NATIVE_APP_QUERY_KEYS).toEqual([ + 'clientRuntime', + 'clientType', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', + ]); + expect(HOST_BRIDGE_NATIVE_APP_QUERY).toEqual({ + clientRuntime: 'native_app', + clientType: 'native_app', + hostShellExpoMobile: 'expo_mobile', + hostShellTauriDesktop: 'tauri_desktop', + }); + expect(HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY).toEqual({ + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }); + }); + test('只允许明确的外链协议交给宿主打开', () => { expect(normalizeHostBridgeExternalUrl(' https://example.com/a ')).toBe( 'https://example.com/a', diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 9ce1273d6..a16100c21 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -16,6 +16,55 @@ export type NativeHostPlatform = | 'linux' | 'unknown'; +export const HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY = { + clientRuntime: 'clientRuntime', + clientType: 'clientType', + miniProgramEnv: 'miniProgramEnv', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', +} as const; + +export const HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS = [ + 'clientType', + 'clientRuntime', + 'miniProgramEnv', +] as const; + +export const HOST_BRIDGE_NATIVE_APP_QUERY_KEY = { + clientRuntime: 'clientRuntime', + clientType: 'clientType', + hostShell: 'hostShell', + hostPlatform: 'hostPlatform', + hostVersion: 'hostVersion', + bridgeVersion: 'bridgeVersion', + hostCapabilities: 'hostCapabilities', +} as const; + +export const HOST_BRIDGE_NATIVE_APP_QUERY_KEYS = [ + 'clientRuntime', + 'clientType', + 'hostShell', + 'hostPlatform', + 'hostVersion', + 'bridgeVersion', + 'hostCapabilities', +] as const; + +export const HOST_BRIDGE_NATIVE_APP_QUERY = { + clientRuntime: 'native_app', + clientType: 'native_app', + hostShellExpoMobile: 'expo_mobile', + hostShellTauriDesktop: 'tauri_desktop', +} as const; + +export const HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY = { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', +} as const; + export const HOST_BRIDGE_METHODS = [ 'host.getRuntime', 'appearance.getColorScheme', diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 4db7eb2b7..b3a115764 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -690,6 +690,22 @@ function extractStringConst(source, constName) { return match[1]; } +function extractTsStringObject(source, exportName) { + const match = source.match( + new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`), + ); + if (!match) { + throw new Error(`unable to read ${exportName}`); + } + + return Object.fromEntries( + [...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [ + entry[1], + entry[2], + ]), + ); +} + function extractDocumentCapabilityList(source, marker) { const markerIndex = source.indexOf(marker); if (markerIndex === -1) { @@ -938,6 +954,10 @@ function assertHttpsDomain(value, label) { } function assertWechatMiniProgramRouteParity() { + const sharedContractSource = fs.readFileSync( + sharedHostBridgeContractPath, + 'utf8', + ); const appConfig = JSON.parse(fs.readFileSync('miniprogram/app.json', 'utf8')); const runtimeConfig = requireCommonJsModule('miniprogram/config.js'); const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js'); @@ -945,6 +965,14 @@ function assertWechatMiniProgramRouteParity() { 'miniprogram/host-bridge/webView.js', 'utf8', ); + const webViewShellSource = fs.readFileSync( + 'miniprogram/shell/webView.js', + 'utf8', + ); + const appPageRoutesSource = fs.readFileSync( + 'src/routing/appPageRoutes.ts', + 'utf8', + ); const h5HostBridgeSource = fs.readFileSync( 'src/services/host-bridge/hostBridge.ts', 'utf8', @@ -1013,15 +1041,72 @@ function assertWechatMiniProgramRouteParity() { } const sourceQuery = runtimeConfig.WEB_VIEW_SOURCE_QUERY ?? {}; + const sharedRuntimeContextQueryKey = extractTsStringObject( + sharedContractSource, + 'HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY', + ); + const sharedPreservedRuntimeContextQueryKeys = extractTsStringArray( + sharedContractSource, + 'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', + ); + const sharedWechatSourceQuery = extractTsStringObject( + sharedContractSource, + 'HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY', + ); + assertSameList( + Object.keys(sourceQuery), + Object.keys(sharedWechatSourceQuery), + 'wechat web-view source query keys', + ); if ( - sourceQuery.clientType !== 'mini_program' || - sourceQuery.clientRuntime !== 'wechat_mini_program' || - Object.keys(sourceQuery).some( - (key) => key !== 'clientType' && key !== 'clientRuntime', + Object.entries(sharedWechatSourceQuery).some( + ([key, value]) => sourceQuery[key] !== value, ) ) { throw new Error('wechat web-view source query drifted from HostBridge runtime markers'); } + assertSameList( + sharedPreservedRuntimeContextQueryKeys, + [ + sharedRuntimeContextQueryKey.clientType, + sharedRuntimeContextQueryKey.clientRuntime, + sharedRuntimeContextQueryKey.miniProgramEnv, + ], + 'preserved HostBridge runtime context query keys', + ); + if ( + !appPageRoutesSource.includes( + 'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', + ) || + !appPageRoutesSource.includes( + 'APP_RUNTIME_CONTEXT_QUERY_KEYS =\n HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS', + ) + ) { + throw new Error('H5 app routes must preserve HostBridge runtime context keys from shared contract'); + } + if ( + !h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY') || + h5HostBridgeSource.includes("params.get('clientType')") || + h5HostBridgeSource.includes("params.get('clientRuntime')") || + h5HostBridgeSource.includes("params.get('hostCapabilities')") || + h5HostBridgeSource.includes("params.get('miniProgramEnv')") + ) { + throw new Error('H5 HostBridge runtime parser must read query keys from shared contract'); + } + for (const snippet of [ + "readWebViewSourceQueryValue('clientType')", + "readWebViewSourceQueryValue('clientRuntime')", + ]) { + if (!webViewShellSource.includes(snippet)) { + throw new Error('wechat request headers must read runtime markers from WEB_VIEW_SOURCE_QUERY'); + } + } + if ( + webViewShellSource.includes('MINI_PROGRAM_CLIENT_TYPE') || + webViewShellSource.includes('MINI_PROGRAM_CLIENT_RUNTIME') + ) { + throw new Error('wechat request runtime markers must not be duplicated outside WEB_VIEW_SOURCE_QUERY'); + } } function assertHostBridgeLayerLayout() { diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index e041db4b4..5599b4e78 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -1,4 +1,5 @@ import type { SelectionStage } from '../components/platform-entry'; +import { HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS } from '../../packages/shared/src/contracts/hostBridge'; import { buildCreationUrlSearchFromParams, isCreationRestorePath, @@ -76,11 +77,8 @@ const ROUTE_STAGE_BY_PATH = new Map( STAGE_ROUTE_ENTRIES.map(([stage, path]) => [path, stage] as const), ) as Map; -const APP_RUNTIME_CONTEXT_QUERY_KEYS = [ - 'clientType', - 'clientRuntime', - 'miniProgramEnv', -] as const; +const APP_RUNTIME_CONTEXT_QUERY_KEYS = + HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS; export function normalizeAppPath(pathname: string) { const trimmedPathname = pathname.trim().toLowerCase(); diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 18a72bc21..16434ce7f 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -27,6 +27,9 @@ import type { ShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; import { + HOST_BRIDGE_NATIVE_APP_QUERY, + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY, + HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY, isHostBridgeCapability, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, @@ -270,16 +273,28 @@ export function resolveHostRuntime( ): HostRuntimeSnapshot { const location = resolveLocation(context); const params = new URLSearchParams(location?.search ?? ''); - const clientType = params.get('clientType'); - const clientRuntime = params.get('clientRuntime'); - const hostShell = params.get('hostShell'); - const hostPlatform = params.get('hostPlatform'); - const hostVersion = params.get('hostVersion'); - const queryHostCapabilities = (params.get('hostCapabilities') ?? '') + const clientType = params.get( + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.clientType, + ); + const clientRuntime = params.get( + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.clientRuntime, + ); + const hostShell = params.get(HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostShell); + const hostPlatform = params.get( + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostPlatform, + ); + const hostVersion = params.get( + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostVersion, + ); + const queryHostCapabilities = ( + params.get(HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.hostCapabilities) ?? '' + ) .split(',') .map((capability) => capability.trim()) .filter(isHostBridgeCapability); - const miniProgramEnv = params.get('miniProgramEnv'); + const miniProgramEnv = params.get( + HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY.miniProgramEnv, + ); const navigatorLike = resolveNavigator(context); const wxBridge = resolveWxBridge(context); const tauriBridge = resolveTauriBridge(context); @@ -290,8 +305,9 @@ export function resolveHostRuntime( ); if ( - clientRuntime === 'wechat_mini_program' || - clientType === 'mini_program' || + clientRuntime === + HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY.clientRuntime || + clientType === HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY.clientType || isWechatMiniProgramUserAgent(navigatorLike?.userAgent ?? '') || hasWechatMiniProgramBridge(wxBridge) ) { @@ -308,8 +324,8 @@ export function resolveHostRuntime( } if ( - clientRuntime === 'native_app' || - clientType === 'native_app' || + clientRuntime === HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime || + clientType === HOST_BRIDGE_NATIVE_APP_QUERY.clientType || typeof tauriBridge?.core?.invoke === 'function' || typeof reactNativeWebView?.postMessage === 'function' ) {