收口HostBridge能力分发门禁

移动壳检查展开能力列表并校验request capability都有handler

桌面壳检查capabilities与Rust request分发一致

更新原生壳方案和共享决策记录
This commit is contained in:
2026-06-18 13:38:11 +08:00
parent 7bb76ffb37
commit 0e0a5bb0c0
4 changed files with 105 additions and 30 deletions
+62 -9
View File
@@ -312,7 +312,11 @@ function assertDesktopIconSet() {
}
}
function extractStringArrayExport(source, exportName) {
function extractStringArrayExport(source, exportName, seen = new Set()) {
if (seen.has(exportName)) {
throw new Error(`cyclic string array export ${exportName}`);
}
const match = source.match(
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
);
@@ -320,14 +324,15 @@ function extractStringArrayExport(source, exportName) {
throw new Error(`unable to read ${exportName}`);
}
const entries = [...match[1].matchAll(/'([^']+)'/g)].map(
(entry) => entry[1],
);
if (match[1].includes('...HOST_BRIDGE_METHODS')) {
return [
...extractStringArrayExport(source, 'HOST_BRIDGE_METHODS'),
...entries,
];
const nextSeen = new Set(seen);
nextSeen.add(exportName);
const entries = [];
for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) {
if (entry[1]) {
entries.push(...extractStringArrayExport(source, entry[1], nextSeen));
} else {
entries.push(entry[2]);
}
}
return entries;
@@ -353,6 +358,23 @@ function extractDesktopCapabilities(source) {
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractDesktopHandledMethods(source) {
const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n \}/g)].map(
(match) => match[1],
);
if (matchBodies.length === 0) {
throw new Error('unable to read desktop shell HostBridge handler methods');
}
return [
...new Set(
matchBodies.flatMap((body) =>
[...body.matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]),
),
),
];
}
function resolveHostCapabilitiesFromUrl(rawUrl) {
const url = new URL(rawUrl, 'https://app.genarrative.world/');
return (url.searchParams.get('hostCapabilities') ?? '')
@@ -458,8 +480,18 @@ const sharedMethods = extractStringArrayExport(
);
const desktopMethods = extractRustStringArrayConst(main, 'HOST_BRIDGE_METHODS');
const desktopCapabilities = extractDesktopCapabilities(main);
const desktopHandledMethods = extractDesktopHandledMethods(main);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist');
const unknownHandledDesktopMethods = desktopHandledMethods.filter(
(method) => !sharedMethods.includes(method),
);
if (unknownHandledDesktopMethods.length > 0) {
throw new Error(
`desktop shell handles unknown HostBridge methods: ${unknownHandledDesktopMethods.join(', ')}`,
);
}
const unknownDesktopCapabilities = desktopCapabilities.filter(
(capability) => !sharedCapabilities.includes(capability),
);
@@ -477,6 +509,27 @@ for (const capability of sdkBackedCapabilities) {
}
}
const missingDesktopMethodHandlers = desktopCapabilities.filter(
(capability) =>
sharedMethods.includes(capability) &&
!desktopHandledMethods.includes(capability),
);
if (missingDesktopMethodHandlers.length > 0) {
throw new Error(
`desktop shell declares request capabilities without HostBridge handlers: ${missingDesktopMethodHandlers.join(', ')}`,
);
}
const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter(
(method) =>
!desktopCapabilities.includes(method) && !sdkBackedCapabilities.includes(method),
);
if (undeclaredDesktopMethodHandlers.length > 0) {
throw new Error(
`desktop shell handles unadvertised HostBridge methods: ${undeclaredDesktopMethodHandlers.join(', ')}`,
);
}
if (config.productName !== 'Genarrative') {
throw new Error('desktop shell productName must be Genarrative');
}
+33 -21
View File
@@ -101,7 +101,11 @@ const blockedAndroidPermissions = [
'android.permission.WRITE_EXTERNAL_STORAGE',
];
function extractStringArrayExport(source, exportName) {
function extractStringArrayExport(source, exportName, seen = new Set()) {
if (seen.has(exportName)) {
throw new Error(`cyclic string array export ${exportName}`);
}
const match = source.match(
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
);
@@ -109,14 +113,15 @@ function extractStringArrayExport(source, exportName) {
throw new Error(`unable to read ${exportName}`);
}
const entries = [...match[1].matchAll(/'([^']+)'/g)].map(
(entry) => entry[1],
);
if (match[1].includes('...HOST_BRIDGE_METHODS')) {
return [
...extractStringArrayExport(source, 'HOST_BRIDGE_METHODS'),
...entries,
];
const nextSeen = new Set(seen);
nextSeen.add(exportName);
const entries = [];
for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) {
if (entry[1]) {
entries.push(...extractStringArrayExport(source, entry[1], nextSeen));
} else {
entries.push(entry[2]);
}
}
return entries;
@@ -325,18 +330,25 @@ for (const capability of sdkBackedCapabilities) {
}
}
for (const capability of iosMobileCapabilities) {
const switchCase = `case '${capability}':`;
if (
capability !== 'host.events' &&
capability !== 'app.lifecycle' &&
capability !== 'navigation.canGoBack' &&
!bridgeSource.includes(switchCase)
) {
throw new Error(
`mobile shell declares ${capability} but does not handle it in HostBridge`,
);
}
const missingMobileMethodHandlers = iosMobileCapabilities.filter(
(capability) =>
sharedMethods.includes(capability) &&
!handledMobileMethods.includes(capability),
);
if (missingMobileMethodHandlers.length > 0) {
throw new Error(
`mobile shell declares request capabilities without HostBridge handlers: ${missingMobileMethodHandlers.join(', ')}`,
);
}
const undeclaredMobileMethodHandlers = handledMobileMethods.filter(
(method) =>
!iosMobileCapabilitySet.has(method) && !sdkBackedCapabilities.includes(method),
);
if (undeclaredMobileMethodHandlers.length > 0) {
throw new Error(
`mobile shell handles unadvertised HostBridge methods: ${undeclaredMobileMethodHandlers.join(', ')}`,
);
}
if (appConfig.scheme !== 'genarrative') {
@@ -70,6 +70,7 @@
- 2026-06-18 HostBridge request id replayExpo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。
- 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod``normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`
- 2026-06-18 HostBridge method 白名单跨壳门禁:`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳 HostBridge 分发不得处理共享契约外 methodTauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。新增宿主 method 必须先更新共享契约,再落两端壳实现或明确 unsupported。
- 2026-06-18 HostBridge capability / handler 关系门禁:两端壳声明 request method capability 时必须有对应 HostBridge handler;壳 handler 处理的 method 必须已被该壳声明,登录 / 支付等 SDK-backed method 只能保留明确 `unsupported_method` 路径。事件类 capability 不要求 request handler。
- 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*``ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval``tauri:``file:`
- 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 也会统一扫描两端壳生产源码,防止把临时替身、占位文案或伪实现带进可分发壳。
- 2026-06-18 移动壳启动页与 adaptive iconExpo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。
@@ -2408,6 +2409,13 @@
- 影响范围:`apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run mobile-shell:typecheck``npm run desktop-shell:typecheck``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 HostBridge capability / handler 关系门禁
- 背景:`HOST_BRIDGE_CAPABILITIES` 同时包含可请求 method 和事件类 capability。壳如果声明了 request method capability 但没有 handlerH5 会展示入口后收到 unsupported;壳如果处理了未声明 methodH5 又无法根据 capability 决定是否调用,容易形成隐藏能力或跨端漂移。
- 决策:移动壳配置检查展开 `MOBILE_HOST_CAPABILITIES` / `IOS_MOBILE_HOST_CAPABILITIES` 并解析 `handleRequest` case;桌面壳配置检查解析 `capabilities()` 与 Rust request 分发 match。凡共享契约中属于 request method 的 capability,被壳声明后必须有对应 handlerhandler 处理的 method 必须已被该壳声明,登录 / 支付等等待真实 SDK 的 method 只能保留明确 `unsupported_method` 路径。`host.events``app.lifecycle``network.statusChanged``file.imageDropped``navigation.canGoBack` 等事件 capability 不要求 request handler。
- 影响范围:`apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run mobile-shell:typecheck``npm run desktop-shell:typecheck``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 移动壳渠道 SDK 依赖收口
- 背景:Expo 移动壳运行时依赖可能从根安装树解析;如果只检查 `apps/mobile-shell/package.json`,根 H5 包仍可能直接引入 Expo Updates、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等移动端发布通道、崩溃上报或 analytics SDK,让壳边界绕过真实渠道契约。
@@ -354,6 +354,8 @@ GameBridge 禁止:
2026-06-18 追加:HostBridge method 白名单进入跨壳门禁。`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_METHODS` 是唯一协议来源;Expo 壳的 HostBridge 分发 case 不得处理共享契约外 methodTauri 壳 Rust `HOST_BRIDGE_METHODS` 必须与共享契约逐项一致。两端配置检查会在 `npm run check:native-shells` 中拒绝 method 白名单漂移,新增宿主能力必须先更新共享契约,再落壳实现。
2026-06-18 追加:HostBridge capability 与 request handler 关系进入门禁。共享契约中属于 request method 的 capability,如果被 Expo 或 Tauri 壳声明,就必须在对应壳的 HostBridge 分发中显式处理;反过来,壳分发中处理的 method 必须已被该壳声明,登录 / 支付等等待真实 SDK 的 method 只能保留明确 `unsupported_method` 路径。`host.events``app.lifecycle``network.statusChanged``file.imageDropped``navigation.canGoBack` 等事件类 capability 不要求 request handler。
2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*``ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp``apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。
2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。