收口宿主上下文查询契约
共享 HostBridge 契约新增宿主上下文 query 字段和值 移动壳和 H5 运行态改用共享 query 契约 桌面壳和微信壳门禁反查共享 query 契约 微信请求头和 H5 路由保留字段跟随共享契约 同步宿主上下文契约文档和共享记忆
This commit is contained in:
@@ -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)',
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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`,不得静默降级成真实设备触觉反馈。桌面壳不声明该 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 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。
|
||||
|
||||
@@ -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 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 / 模拟 / 伪造等替身痕迹。
|
||||
|
||||
## 后续
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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'
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user