收口宿主上下文查询契约

共享 HostBridge 契约新增宿主上下文 query 字段和值

移动壳和 H5 运行态改用共享 query 契约

桌面壳和微信壳门禁反查共享 query 契约

微信请求头和 H5 路由保留字段跟随共享契约

同步宿主上下文契约文档和共享记忆
This commit is contained in:
2026-06-19 07:39:51 +08:00
parent db153b0dde
commit de2798d415
14 changed files with 408 additions and 72 deletions
@@ -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)',
+22 -25
View File
@@ -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<Url> {
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<Ur
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 target_url);
if target_url.origin() != base_url.origin() {
return None;
@@ -61,14 +66,8 @@ fn append_desktop_host_context_to_url(mut url: Url) -> 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::<Vec<_>>()
})
.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}")
+70 -6
View File
@@ -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)',
+30 -7
View File
@@ -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();
}
@@ -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`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 capabilityH5 继续按 HostBridge fallback 处理。
- 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capabilityH5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。
@@ -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,运行时开发域名回退生产域名只作为异常兜底。
内容检查:
@@ -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 生产调用链文件。
## 参考资料
@@ -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 capabilityHostBridge 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 / 模拟 / 伪造等替身痕迹。
## 后续
+8 -6
View File
@@ -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,
@@ -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',
@@ -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',
+89 -4
View File
@@ -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() {
+3 -5
View File
@@ -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<string, SelectionStage>;
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();
+27 -11
View File
@@ -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'
) {