Files
Genarrative/apps/mobile-shell/scripts/check-config.mjs
kdletters ad883df307 校验原生壳能力声明一致性
移动壳配置检查校验声明能力来自共享 HostBridge 白名单

桌面壳配置检查校验 runtime 能力与 URL hostCapabilities 一致

文档补充新增 native capability 后必须运行双壳检查

共享决策记录补充壳能力防漂移约束
2026-06-18 01:09:02 +08:00

149 lines
4.3 KiB
JavaScript

import fs from 'node:fs';
import { PNG } from 'pngjs';
const appConfigPath = new URL('../app.json', import.meta.url);
const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo;
const appPath = new URL('../App.tsx', import.meta.url);
const appSource = fs.readFileSync(appPath, 'utf8');
const bridgePath = new URL('../src/mobileHostBridge.ts', import.meta.url);
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 packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const iconPath = new URL('../assets/icon.png', import.meta.url);
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') {
throw new Error('mobile shell scheme must be genarrative');
}
if (appConfig.icon !== './assets/icon.png') {
throw new Error('mobile shell must use the real brand icon asset');
}
if (icon.width < 512 || icon.height < 512) {
throw new Error('mobile shell icon must be a production-size brand asset');
}
if (!appConfig.ios?.associatedDomains?.includes('applinks:app.genarrative.world')) {
throw new Error('mobile shell iOS associated domain is missing');
}
const androidFilter = appConfig.android?.intentFilters?.find((filter) =>
filter?.data?.some(
(entry) =>
entry?.scheme === 'https' &&
entry?.host === 'app.genarrative.world',
),
);
if (!androidFilter) {
throw new Error('mobile shell Android app link filter is missing');
}
for (const snippet of [
'Linking.getInitialURL()',
"Linking.addEventListener('url'",
'buildMobileShellUrlFromDeepLink',
'configureMobileHostBridgeNavigation',
'navigation.canGoBack',
'buildHostBridgeMessageScript',
]) {
if (!appSource.includes(snippet)) {
throw new Error(`mobile shell App missing ${snippet}`);
}
}
for (const dependency of ['expo-file-system', 'expo-sharing']) {
if (!packageConfig.dependencies?.[dependency]) {
throw new Error(`mobile shell package missing ${dependency}`);
}
}
for (const snippet of [
'file.exportText',
'Sharing.shareAsync',
'normalizeHostBridgeExportFileName',
]) {
if (!bridgeSource.includes(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}`);
}
}