校验原生壳能力声明一致性
移动壳配置检查校验声明能力来自共享 HostBridge 白名单 桌面壳配置检查校验 runtime 能力与 URL hostCapabilities 一致 文档补充新增 native capability 后必须运行双壳检查 共享决策记录补充壳能力防漂移约束
This commit is contained in:
@@ -10,6 +10,76 @@ const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
|
|||||||
const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url);
|
const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url);
|
||||||
const buildScript = fs.readFileSync(buildScriptPath, 'utf8');
|
const buildScript = fs.readFileSync(buildScriptPath, 'utf8');
|
||||||
const iconPath = new URL('../src-tauri/icons/icon.png', import.meta.url);
|
const iconPath = new URL('../src-tauri/icons/icon.png', import.meta.url);
|
||||||
|
const sharedContractPath = new URL(
|
||||||
|
'../../../packages/shared/src/contracts/hostBridge.ts',
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
|
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
|
||||||
|
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
|
||||||
|
const main = fs.readFileSync(mainPath, 'utf8');
|
||||||
|
|
||||||
|
function extractStringArrayExport(source, exportName) {
|
||||||
|
const match = source.match(
|
||||||
|
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDesktopCapabilities(source) {
|
||||||
|
const match = source.match(/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('unable to read desktop shell capabilities');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHostCapabilitiesFromUrl(rawUrl) {
|
||||||
|
const url = new URL(rawUrl, 'https://app.genarrative.world/');
|
||||||
|
return (url.searchParams.get('hostCapabilities') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((capability) => capability.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSameList(actual, expected, label) {
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((value, index) => value !== expected[index])
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedCapabilities = extractStringArrayExport(
|
||||||
|
sharedContractSource,
|
||||||
|
'HOST_BRIDGE_CAPABILITIES',
|
||||||
|
);
|
||||||
|
const desktopCapabilities = extractDesktopCapabilities(main);
|
||||||
|
const unknownDesktopCapabilities = desktopCapabilities.filter(
|
||||||
|
(capability) => !sharedCapabilities.includes(capability),
|
||||||
|
);
|
||||||
|
if (unknownDesktopCapabilities.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`desktop shell declares unknown HostBridge capabilities: ${unknownDesktopCapabilities.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.build?.frontendDist !== '../../../dist') {
|
if (config.build?.frontendDist !== '../../../dist') {
|
||||||
throw new Error('desktop shell must package the root H5 dist');
|
throw new Error('desktop shell must package the root H5 dist');
|
||||||
@@ -34,7 +104,6 @@ const requiredUrlParts = [
|
|||||||
'hostShell=tauri_desktop',
|
'hostShell=tauri_desktop',
|
||||||
'hostPlatform=unknown',
|
'hostPlatform=unknown',
|
||||||
'bridgeVersion=1',
|
'bridgeVersion=1',
|
||||||
'hostCapabilities=host.getRuntime,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,clipboard.writeText,file.exportText',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const part of requiredUrlParts) {
|
for (const part of requiredUrlParts) {
|
||||||
@@ -46,6 +115,17 @@ for (const part of requiredUrlParts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertSameList(
|
||||||
|
resolveHostCapabilitiesFromUrl(mainWindow.url),
|
||||||
|
desktopCapabilities,
|
||||||
|
'desktop shell main window hostCapabilities',
|
||||||
|
);
|
||||||
|
assertSameList(
|
||||||
|
resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? ''),
|
||||||
|
desktopCapabilities,
|
||||||
|
'desktop shell dev hostCapabilities',
|
||||||
|
);
|
||||||
|
|
||||||
const requiredPermissions = [
|
const requiredPermissions = [
|
||||||
'core:default',
|
'core:default',
|
||||||
'allow-host-bridge-request',
|
'allow-host-bridge-request',
|
||||||
@@ -86,9 +166,6 @@ if (icon.length < 10000) {
|
|||||||
throw new Error('desktop shell icon must use a real brand asset');
|
throw new Error('desktop shell icon must use a real brand asset');
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
|
|
||||||
const main = fs.readFileSync(mainPath, 'utf8');
|
|
||||||
|
|
||||||
for (const snippet of requiredMainSnippets) {
|
for (const snippet of requiredMainSnippets) {
|
||||||
if (!main.includes(snippet)) {
|
if (!main.includes(snippet)) {
|
||||||
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
|
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
|
||||||
|
|||||||
@@ -8,11 +8,68 @@ const appPath = new URL('../App.tsx', import.meta.url);
|
|||||||
const appSource = fs.readFileSync(appPath, 'utf8');
|
const appSource = fs.readFileSync(appPath, 'utf8');
|
||||||
const bridgePath = new URL('../src/mobileHostBridge.ts', import.meta.url);
|
const bridgePath = new URL('../src/mobileHostBridge.ts', import.meta.url);
|
||||||
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
|
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
|
||||||
|
const sharedContractPath = new URL(
|
||||||
|
'../../../packages/shared/src/contracts/hostBridge.ts',
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
|
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
|
||||||
const packagePath = new URL('../package.json', import.meta.url);
|
const packagePath = new URL('../package.json', import.meta.url);
|
||||||
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
const iconPath = new URL('../assets/icon.png', import.meta.url);
|
const iconPath = new URL('../assets/icon.png', import.meta.url);
|
||||||
const icon = PNG.sync.read(fs.readFileSync(iconPath));
|
const icon = PNG.sync.read(fs.readFileSync(iconPath));
|
||||||
|
|
||||||
|
function extractStringArrayExport(source, exportName) {
|
||||||
|
const match = source.match(
|
||||||
|
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedCapabilities = extractStringArrayExport(
|
||||||
|
sharedContractSource,
|
||||||
|
'HOST_BRIDGE_CAPABILITIES',
|
||||||
|
);
|
||||||
|
const mobileCapabilities = extractStringArrayExport(
|
||||||
|
bridgeSource,
|
||||||
|
'MOBILE_HOST_CAPABILITIES',
|
||||||
|
);
|
||||||
|
const mobileCapabilitySet = new Set(mobileCapabilities);
|
||||||
|
const unknownMobileCapabilities = mobileCapabilities.filter(
|
||||||
|
(capability) => !sharedCapabilities.includes(capability),
|
||||||
|
);
|
||||||
|
if (unknownMobileCapabilities.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`mobile shell declares unknown HostBridge capabilities: ${unknownMobileCapabilities.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const capability of mobileCapabilities) {
|
||||||
|
const switchCase = `case '${capability}':`;
|
||||||
|
if (
|
||||||
|
capability !== 'host.events' &&
|
||||||
|
capability !== 'navigation.canGoBack' &&
|
||||||
|
!bridgeSource.includes(switchCase)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`mobile shell declares ${capability} but does not handle it in HostBridge`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (appConfig.scheme !== 'genarrative') {
|
if (appConfig.scheme !== 'genarrative') {
|
||||||
throw new Error('mobile shell scheme must be genarrative');
|
throw new Error('mobile shell scheme must be genarrative');
|
||||||
}
|
}
|
||||||
@@ -68,3 +125,24 @@ for (const snippet of [
|
|||||||
throw new Error(`mobile shell HostBridge missing ${snippet}`);
|
throw new Error(`mobile shell HostBridge missing ${snippet}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
|
||||||
|
if (!appSource.includes(capabilityQuerySnippet)) {
|
||||||
|
throw new Error('mobile shell URL must use MOBILE_HOST_CAPABILITIES');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const capability of [
|
||||||
|
'host.getRuntime',
|
||||||
|
'share.open',
|
||||||
|
'share.setTarget',
|
||||||
|
'navigation.openNativePage',
|
||||||
|
'navigation.canGoBack',
|
||||||
|
'app.openExternalUrl',
|
||||||
|
'clipboard.writeText',
|
||||||
|
'file.exportText',
|
||||||
|
'haptics.impact',
|
||||||
|
]) {
|
||||||
|
if (!mobileCapabilitySet.has(capability)) {
|
||||||
|
throw new Error(`mobile shell capabilities missing ${capability}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
|
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
|
||||||
- 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 能力声明收紧:`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 宿主 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` 和实现分支保持一致;新增能力必须先更新契约和真实壳实现,再通过这些检查。
|
||||||
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
||||||
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
||||||
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ GameBridge 禁止:
|
|||||||
- 壳层只接受来自允许 origin / packaged asset 的消息。
|
- 壳层只接受来自允许 origin / packaged asset 的消息。
|
||||||
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
|
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
|
||||||
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
|
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
|
||||||
|
- 壳能力声明必须通过 `npm run mobile-shell:typecheck` / `npm run desktop-shell:typecheck` 校验:声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities` 和壳实现不得漂移。
|
||||||
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
|
- 宿主壳不得把长期 token、支付密钥或用户敏感资料回传给 H5。
|
||||||
- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。
|
- Tauri 禁止把 shell / fs 等高危插件作为默认能力暴露给主 WebView。
|
||||||
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
|
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ AI H5 sandbox
|
|||||||
2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。
|
2. `authService` 保留原导出,但内部委托 HostBridge,避免一次性改动 AuthGate。
|
||||||
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
|
3. 分享弹窗、分享目标同步、九宫切图、微信小程序支付和订阅授权改用 HostBridge 通用接口;旧微信命名服务只作为兼容导出。
|
||||||
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。
|
4. 后续新增 `native_app` adapter 时只补桥接实现和测试,业务层不新增平台分叉;主 App 启动会触发一次 `host.getRuntime` 回读并订阅能力变化,避免裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时长期隐藏真实可用能力。
|
||||||
|
5. 每次新增或调整 native capability 后,必须运行 `npm run mobile-shell:typecheck` 和 `npm run desktop-shell:typecheck`,确保共享白名单、壳 runtime 回包和 URL `hostCapabilities` 没有漂移。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user