收口HostBridge能力分发门禁
移动壳检查展开能力列表并校验request capability都有handler 桌面壳检查capabilities与Rust request分发一致 更新原生壳方案和共享决策记录
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 replay:Expo 和 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 分发不得处理共享契约外 method,Tauri 壳 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 icon:Expo 移动壳启动页和 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 但没有 handler,H5 会展示入口后收到 unsupported;壳如果处理了未声明 method,H5 又无法根据 capability 决定是否调用,容易形成隐藏能力或跨端漂移。
|
||||
- 决策:移动壳配置检查展开 `MOBILE_HOST_CAPABILITIES` / `IOS_MOBILE_HOST_CAPABILITIES` 并解析 `handleRequest` case;桌面壳配置检查解析 `capabilities()` 与 Rust request 分发 match。凡共享契约中属于 request method 的 capability,被壳声明后必须有对应 handler;handler 处理的 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 不得处理共享契约外 method,Tauri 壳 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 回归。
|
||||
|
||||
Reference in New Issue
Block a user