Files
Genarrative/apps/mobile-shell/scripts/check-config.mjs
T
kdletters 75cd4f1abc 收口原生壳能力来源
新增共享 HostBridge capability profile,区分 Expo 移动通用、iOS 额外和 Tauri 桌面能力

移动壳能力清单改为引用共享 profile,入口 URL 类型支持 readonly 能力数组

两端配置检查和原生壳总门禁改为反查共享 profile,桌面 Rust 清单保持运行时镜像

补充 HostBridge profile 单测和 H5 readonly 能力合并类型兼容

同步 Expo/Tauri 壳方案、HostBridge 协议文档和项目决策记录
2026-06-19 02:02:15 +08:00

1277 lines
40 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 shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url);
const shellAppSource = fs.readFileSync(shellAppPath, 'utf8');
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
const sharePath = new URL('../src/host-bridge/share.ts', import.meta.url);
const shareSource = fs.readFileSync(sharePath, 'utf8');
const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url);
const bridgeSourceFiles = fs
.readdirSync(bridgeDirPath, { withFileTypes: true })
.filter(
(entry) =>
entry.isFile() &&
entry.name.endsWith('.ts') &&
!entry.name.includes('.test.'),
)
.map((entry) => new URL(entry.name, bridgeDirPath))
.sort((left, right) => left.pathname.localeCompare(right.pathname));
const hostBridgeSource = bridgeSourceFiles
.map((file) => fs.readFileSync(file, 'utf8'))
.join('\n');
const urlPath = new URL('../src/shell/url.ts', import.meta.url);
const urlSource = fs.readFileSync(urlPath, 'utf8');
const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url);
const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8');
const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url);
const navigationSource = fs.readFileSync(navigationPath, 'utf8');
const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url);
const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8');
const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url);
const webViewHistorySource = fs.readFileSync(webViewHistoryPath, 'utf8');
const loadFailurePath = new URL('../src/shell/loadFailure.ts', import.meta.url);
const loadFailureSource = fs.readFileSync(loadFailurePath, 'utf8');
const runtimePath = new URL('../src/shell/runtime.ts', import.meta.url);
const runtimeSource = fs.readFileSync(runtimePath, '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 rootPackagePath = new URL('../../../package.json', import.meta.url);
const rootPackageConfig = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url);
const rootPackageLock = JSON.parse(fs.readFileSync(rootPackageLockPath, 'utf8'));
const iconPath = new URL('../assets/icon.png', import.meta.url);
const icon = PNG.sync.read(fs.readFileSync(iconPath));
const brandBackgroundColor = '#fffdf9';
const productionSourceRoots = [
new URL('../App.tsx', import.meta.url),
new URL('../app.json', import.meta.url),
new URL('../package.json', import.meta.url),
new URL('../src/', import.meta.url),
];
const productionFileExtensions = new Set(['.json', '.mjs', '.ts', '.tsx']);
const devScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'place' + 'holder',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'占' + '位',
'模' + '拟',
'伪' + '造',
];
const blockedMobileChannelDependencies = [
'@react-native-firebase/analytics',
'@react-native-firebase/app',
'@segment/analytics-react-native',
'@sentry/react-native',
'amplitude-react-native',
'expo-application',
'expo-updates',
'posthog-react-native',
'react-native-code-push',
];
const blockedMobileChannelLockDependencies = blockedMobileChannelDependencies.filter(
(dependency) => dependency !== 'expo-application',
);
const blockedMobileChannelSnippets = [
'@react-native-firebase/analytics',
'@react-native-firebase/app',
'@segment/analytics-react-native',
'@sentry/react-native',
'Amplitude.getInstance',
'Analytics.screen',
'Analytics.track',
'CodePush.sync',
'PostHogProvider',
'Sentry.init',
'Updates.checkForUpdateAsync',
'Updates.fetchUpdateAsync',
'Updates.reloadAsync',
'analytics().logEvent',
'amplitude.init',
'codePush(',
'posthog.capture',
'posthog.init',
];
const blockedScheduledNotificationSnippets = [
'SchedulableTriggerInputTypes',
'cancelScheduledNotificationAsync',
'getAllScheduledNotificationsAsync',
'getNextTriggerDateAsync',
'seconds:',
'repeats:',
"type: 'calendar'",
"type: 'daily'",
"type: 'date'",
"type: 'monthly'",
"type: 'timeInterval'",
"type: 'weekly'",
"type: 'yearly'",
'DateTriggerInput',
'TimeIntervalTriggerInput',
];
const blockedAndroidPermissions = [
'android.permission.MANAGE_EXTERNAL_STORAGE',
'android.permission.READ_EXTERNAL_STORAGE',
'android.permission.RECORD_AUDIO',
'android.permission.RECEIVE_BOOT_COMPLETED',
'android.permission.REQUEST_INSTALL_PACKAGES',
'android.permission.SCHEDULE_EXACT_ALARM',
'android.permission.USE_EXACT_ALARM',
'android.permission.WRITE_EXTERNAL_STORAGE',
];
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]*?)\\][^;]*;`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
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;
}
function extractStringConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*'([^']+)';`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return match[1];
}
function extractNumberConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return Number(match[1]);
}
function extractMobileBridgeHandledMethods(source) {
const match = source.match(
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
);
if (!match) {
throw new Error('unable to read mobile shell HostBridge handler methods');
}
return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]);
}
function assertNoBlockedMobileChannelDependencies(packageJson, packageLabel) {
const dependencySections = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
];
for (const dependency of blockedMobileChannelDependencies) {
for (const section of dependencySections) {
if (packageJson[section]?.[dependency]) {
throw new Error(
`${packageLabel} must not depend on ${dependency} before the real mobile channel contract exists`,
);
}
}
}
}
function assertNoBlockedMobileChannelLockPackages() {
const packageNames = Object.keys(rootPackageLock.packages ?? {})
.filter((packagePath) => packagePath.startsWith('node_modules/'))
.map((packagePath) => packagePath.replace(/^node_modules\//, ''));
const dependencyNames = Object.keys(rootPackageLock.dependencies ?? {});
const lockedPackageNames = new Set([...packageNames, ...dependencyNames]);
for (const dependency of blockedMobileChannelLockDependencies) {
if (lockedPackageNames.has(dependency)) {
throw new Error(
`root package-lock must not resolve ${dependency} before the real mobile channel contract exists`,
);
}
}
}
function assertNoBlockedMobileChannelSnippets() {
const sources = [
['app.json', JSON.stringify(appConfig)],
['App.tsx', appSource],
['ShellApp.tsx', shellAppSource],
['src/host-bridge', hostBridgeSource],
['url.ts', urlSource],
['runtime.ts', runtimeSource],
];
for (const [sourceName, source] of sources) {
for (const snippet of blockedMobileChannelSnippets) {
if (source.includes(snippet)) {
throw new Error(
`mobile shell ${sourceName} must not initialize ${snippet} before the real channel contract exists`,
);
}
}
}
}
function assertPackageScript(packageJson, packageLabel, scriptName, expected) {
const actual = packageJson.scripts?.[scriptName];
if (actual !== expected) {
throw new Error(
`${packageLabel} script ${scriptName} drifted: expected ${expected} but got ${actual}`,
);
}
}
function assertPackageDependencyVersion(
packageJson,
packageLabel,
section,
dependency,
expected,
) {
const actual = packageJson[section]?.[dependency];
if (actual !== expected) {
throw new Error(
`${packageLabel} ${section}.${dependency} drifted: expected ${expected} but got ${actual}`,
);
}
}
function assertPackageLockVersion(dependency, expected) {
const actual = rootPackageLock.packages?.[`node_modules/${dependency}`]?.version;
if (actual !== expected) {
throw new Error(
`root package-lock ${dependency} resolved version drifted: expected ${expected} but got ${actual}`,
);
}
}
function collectProductionSourceFiles(entry) {
const stats = fs.statSync(entry);
if (stats.isDirectory()) {
const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`);
return fs
.readdirSync(entry, { withFileTypes: true })
.flatMap((child) =>
collectProductionSourceFiles(
new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory),
),
);
}
const path = entry.pathname;
const extension = path.match(/\.[^.]+$/)?.[0] ?? '';
if (!productionFileExtensions.has(extension)) {
return [];
}
if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) {
return [];
}
return [entry];
}
function assertNoDevScaffoldTerms(files) {
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const lineStarts = [0];
for (let index = 0; index < source.length; index += 1) {
if (source[index] === '\n') {
lineStarts.push(index + 1);
}
}
for (const term of devScaffoldTerms) {
const matchIndex = source.toLowerCase().indexOf(term.toLowerCase());
if (matchIndex === -1) {
continue;
}
const line = lineStarts.filter((start) => start <= matchIndex).length;
throw new Error(
`mobile shell production source must not include ${term}: ${file.pathname}:${line}`,
);
}
}
}
function countAlphaPixels(png) {
let transparent = 0;
let translucent = 0;
let opaque = 0;
for (let index = 3; index < png.data.length; index += 4) {
const alpha = png.data[index];
if (alpha === 0) {
transparent += 1;
} else if (alpha === 255) {
opaque += 1;
} else {
translucent += 1;
}
}
return { transparent, translucent, opaque };
}
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(', ')}`,
);
}
}
assertNoDevScaffoldTerms(
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
);
assertNoBlockedMobileChannelDependencies(packageConfig, 'mobile shell package');
assertNoBlockedMobileChannelDependencies(rootPackageConfig, 'root H5 package');
assertNoBlockedMobileChannelLockPackages();
assertNoBlockedMobileChannelSnippets();
for (const [scriptName, expected] of Object.entries({
dev: 'expo start',
android: 'expo run:android',
ios: 'expo run:ios',
test: 'vitest run -c vitest.config.ts',
'config:smoke': 'node scripts/check-expo-config.mjs',
'export:smoke': 'node scripts/check-expo-export.mjs',
typecheck: 'tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs',
})) {
assertPackageScript(packageConfig, 'mobile shell package', scriptName, expected);
}
for (const [scriptName, expected] of Object.entries({
'mobile-shell:dev': 'npm --prefix apps/mobile-shell run dev',
'mobile-shell:typecheck': 'npm --prefix apps/mobile-shell run typecheck',
'mobile-shell:test': 'npm --prefix apps/mobile-shell run test',
'mobile-shell:config': 'npm --prefix apps/mobile-shell run config:smoke',
'mobile-shell:export': 'npm --prefix apps/mobile-shell run export:smoke',
})) {
assertPackageScript(rootPackageConfig, 'root package', scriptName, expected);
}
for (const [dependency, expected] of Object.entries({
'@expo/metro-runtime': '^56.0.15',
expo: '^56.0.12',
'expo-clipboard': '^56.0.4',
'expo-document-picker': '^56.0.4',
'expo-file-system': '^56.0.8',
'expo-haptics': '^56.0.3',
'expo-image-picker': '^56.0.18',
'expo-linking': '^56.0.14',
'expo-network': '^56.0.5',
'expo-notifications': '^56.0.18',
'expo-sharing': '^56.0.18',
'expo-status-bar': '^56.0.4',
react: '^19.0.0',
'react-native': '^0.86.0',
'react-native-safe-area-context': '^5.8.0',
'react-native-webview': '^13.16.1',
})) {
assertPackageDependencyVersion(
packageConfig,
'mobile shell package',
'dependencies',
dependency,
expected,
);
assertPackageDependencyVersion(
rootPackageConfig,
'root package',
'dependencies',
dependency,
expected,
);
}
for (const [dependency, expected] of Object.entries({
'@expo/metro-runtime': '56.0.15',
expo: '56.0.12',
'expo-clipboard': '56.0.4',
'expo-document-picker': '56.0.4',
'expo-file-system': '56.0.8',
'expo-haptics': '56.0.3',
'expo-image-picker': '56.0.18',
'expo-linking': '56.0.14',
'expo-network': '56.0.5',
'expo-notifications': '56.0.18',
'expo-sharing': '56.0.18',
'expo-status-bar': '56.0.4',
react: '19.2.4',
'react-native': '0.86.0',
'react-native-safe-area-context': '5.8.0',
'react-native-webview': '13.16.1',
})) {
assertPackageLockVersion(dependency, expected);
}
for (const [dependency, expected] of Object.entries({
typescript: '~5.8.2',
vitest: '^0.34.6',
})) {
assertPackageDependencyVersion(
packageConfig,
'mobile shell package',
'devDependencies',
dependency,
expected,
);
assertPackageDependencyVersion(
rootPackageConfig,
'root package',
'devDependencies',
dependency,
expected,
);
}
for (const [dependency, expected] of Object.entries({
typescript: '5.8.3',
vitest: '0.34.6',
})) {
assertPackageLockVersion(dependency, expected);
}
const sharedCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_CAPABILITIES',
);
const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const sharedMobileBaseCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
);
const sharedMobileIosCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
);
const sharedHostBridgeProtocol = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_PROTOCOL',
);
const sharedHostBridgeVersion = extractNumberConstExport(
sharedContractSource,
'HOST_BRIDGE_VERSION',
);
const sharedPublicWebOrigin = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
);
const sharedPublicWebUrl = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_PUBLIC_WEB_URL',
);
const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource);
const mobileCapabilities = sharedMobileBaseCapabilities;
const iosMobileCapabilities = sharedMobileIosCapabilities;
const mobileCapabilitySet = new Set(mobileCapabilities);
const iosMobileCapabilitySet = new Set(iosMobileCapabilities);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
const sharedPayloadBoundaryImports = [
'HOST_BRIDGE_AUDIO_MIME_TYPES',
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_TEXT_MIME_TYPES',
];
for (const boundaryImport of sharedPayloadBoundaryImports) {
if (!hostBridgeSource.includes(boundaryImport)) {
throw new Error(
`mobile shell must import shared HostBridge payload boundary ${boundaryImport}`,
);
}
}
const forbiddenLocalPayloadBoundaryDeclarations = [
'EXPORT_TEXT_MAX_BYTES',
'IMPORT_TEXT_MAX_BYTES',
'EXPORT_IMAGE_MAX_BYTES',
'IMPORT_IMAGE_MAX_BYTES',
'EXPORT_AUDIO_MAX_BYTES',
'IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_TEXT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
];
for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) {
if (new RegExp(`const ${localBoundary}\\s*=`).test(hostBridgeSource)) {
throw new Error(
`mobile shell must not redeclare HostBridge payload boundary ${localBoundary}`,
);
}
}
for (const profileSource of [
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
]) {
if (!hostBridgeSource.includes(profileSource)) {
throw new Error(`mobile shell must use shared HostBridge profile ${profileSource}`);
}
}
if (
/export const MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource) ||
/export const IOS_MOBILE_HOST_CAPABILITIES[^=]*= \[/.test(hostBridgeSource)
) {
throw new Error('mobile shell must not redeclare HostBridge capability profiles');
}
const unknownHandledMobileMethods = handledMobileMethods.filter(
(method) => !sharedMethods.includes(method),
);
if (unknownHandledMobileMethods.length > 0) {
throw new Error(
`mobile shell handles unknown HostBridge methods: ${unknownHandledMobileMethods.join(', ')}`,
);
}
const unknownMobileCapabilities = mobileCapabilities.filter(
(capability) => !sharedCapabilities.includes(capability),
);
if (unknownMobileCapabilities.length > 0) {
throw new Error(
`mobile shell declares unknown HostBridge capabilities: ${unknownMobileCapabilities.join(', ')}`,
);
}
const unknownIosMobileCapabilities = iosMobileCapabilities.filter(
(capability) => !sharedCapabilities.includes(capability),
);
if (unknownIosMobileCapabilities.length > 0) {
throw new Error(
`iOS mobile shell declares unknown HostBridge capabilities: ${unknownIosMobileCapabilities.join(', ')}`,
);
}
for (const capability of sdkBackedCapabilities) {
if (
mobileCapabilitySet.has(capability) ||
iosMobileCapabilitySet.has(capability)
) {
throw new Error(
`mobile shell must not declare ${capability} until a real SDK/channel flow is implemented`,
);
}
}
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') {
throw new Error('mobile shell scheme must be genarrative');
}
if (appConfig.name !== 'Genarrative') {
throw new Error('mobile shell app name must be Genarrative');
}
if (appConfig.slug !== 'genarrative-mobile-shell') {
throw new Error('mobile shell slug must be genarrative-mobile-shell');
}
if (appConfig.version !== '0.1.0') {
throw new Error('mobile shell app version must be 0.1.0');
}
const mobileHostVersion = extractStringConstExport(
runtimeSource,
'MOBILE_SHELL_HOST_VERSION',
);
if (mobileHostVersion !== appConfig.version) {
throw new Error('mobile shell HostBridge host version must match app.json version');
}
if (appConfig.orientation !== 'default') {
throw new Error('mobile shell must allow device orientation for landscape-capable H5 games');
}
if (packageConfig.version !== appConfig.version) {
throw new Error('mobile shell package version must match app.json version');
}
if (appConfig.icon !== './assets/icon.png') {
throw new Error('mobile shell must use the real brand icon asset');
}
if (appConfig.userInterfaceStyle !== 'automatic') {
throw new Error('mobile shell must follow the native system appearance setting');
}
assertSameList(
appConfig.assetBundlePatterns ?? [],
['**/*'],
'mobile shell asset bundle patterns',
);
if (icon.width < 512 || icon.height < 512) {
throw new Error('mobile shell icon must be a production-size brand asset');
}
const iconAlpha = countAlphaPixels(icon);
if (iconAlpha.transparent === 0 || iconAlpha.opaque === 0) {
throw new Error('mobile shell adaptive icon foreground must use the real transparent brand asset');
}
if (
appConfig.splash?.image !== './assets/icon.png' ||
appConfig.splash?.resizeMode !== 'contain' ||
appConfig.splash?.backgroundColor !== brandBackgroundColor
) {
throw new Error('mobile shell splash must use the real brand icon and brand background');
}
if (appConfig.updates?.enabled !== false) {
throw new Error('mobile shell OTA updates must stay disabled until a real release channel exists');
}
if (Object.keys(appConfig.updates ?? {}).some((key) => key !== 'enabled')) {
throw new Error('mobile shell must not configure OTA update metadata without a real release channel');
}
if ('runtimeVersion' in appConfig) {
throw new Error('mobile shell must not configure runtimeVersion without a real OTA release channel');
}
if ('releaseChannel' in appConfig || 'channel' in appConfig) {
throw new Error('mobile shell must not configure an app release channel without a real release process');
}
assertSameList(
appConfig.ios?.associatedDomains ?? [],
['applinks:app.genarrative.world'],
'mobile shell iOS associated domains',
);
if (appConfig.ios?.bundleIdentifier !== 'world.genarrative.mobile') {
throw new Error('mobile shell iOS bundle identifier must be world.genarrative.mobile');
}
if (appConfig.ios?.buildNumber !== '1') {
throw new Error('mobile shell iOS build number must start at 1');
}
if (appConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption !== false) {
throw new Error('mobile shell iOS encryption export flag must be explicit');
}
if (
appConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads !== false
) {
throw new Error('mobile shell iOS ATS must not allow arbitrary network loads');
}
if (appConfig.android?.package !== 'world.genarrative.mobile') {
throw new Error('mobile shell Android package must be world.genarrative.mobile');
}
if (appConfig.android?.versionCode !== 1) {
throw new Error('mobile shell Android versionCode must start at 1');
}
if (appConfig.android?.usesCleartextTraffic !== false) {
throw new Error('mobile shell Android package must disable cleartext traffic');
}
if (appConfig.android?.allowBackup !== false) {
throw new Error('mobile shell Android package must disable app data backup');
}
if (appConfig.android?.softwareKeyboardLayoutMode !== 'resize') {
throw new Error('mobile shell Android keyboard layout must resize the WebView');
}
for (const permission of blockedAndroidPermissions) {
if (!appConfig.android?.blockedPermissions?.includes(permission)) {
throw new Error(`mobile shell Android package must block ${permission}`);
}
}
for (const permission of blockedAndroidPermissions) {
if (appConfig.android?.permissions?.includes(permission)) {
throw new Error(`mobile shell Android package must not request ${permission}`);
}
}
if (appConfig.android?.permissions && appConfig.android.permissions.length > 0) {
throw new Error('mobile shell Android package must not request explicit permissions by default');
}
if (
appConfig.android?.adaptiveIcon?.foregroundImage !== './assets/icon.png' ||
appConfig.android?.adaptiveIcon?.backgroundColor !== brandBackgroundColor
) {
throw new Error('mobile shell Android adaptive icon must use the real brand icon and brand background');
}
const androidFilters = appConfig.android?.intentFilters ?? [];
if (androidFilters.length !== 1) {
throw new Error('mobile shell Android app link filter must be the only intent filter');
}
const [androidFilter] = androidFilters;
if (androidFilter.action !== 'VIEW' || androidFilter.autoVerify !== true) {
throw new Error('mobile shell Android app link filter must be a verified VIEW filter');
}
assertSameList(
androidFilter.category ?? [],
['BROWSABLE', 'DEFAULT'],
'mobile shell Android app link categories',
);
const androidFilterData = androidFilter.data ?? [];
if (
androidFilterData.length !== 1 ||
androidFilterData[0]?.scheme !== 'https' ||
androidFilterData[0]?.host !== 'app.genarrative.world' ||
Object.keys(androidFilterData[0] ?? {}).some(
(key) => key !== 'scheme' && key !== 'host',
)
) {
throw new Error('mobile shell Android app link data must only bind https://app.genarrative.world');
}
if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) {
throw new Error('mobile shell extra HostBridge version must match shared HostBridge version');
}
for (const snippet of [
'Linking.getInitialURL()',
"Linking.addEventListener('url'",
'buildMobileShellUrlFromDeepLink',
'configureMobileHostBridgeNavigation',
'HOST_BRIDGE_PROTOCOL',
'HOST_BRIDGE_VERSION',
'shouldAcceptMobileShellHostBridgeMessage',
'webViewRef.current?.reload()',
'const reloadCurrentWebView = useCallback(() => {',
'reloadWebView: reloadCurrentWebView',
'onContentProcessDidTerminate={reloadCurrentWebView}',
'onRenderProcessGone={reloadCurrentWebView}',
'normalizeMobileShellLoadFailure',
'handleWebViewLoadError',
'handleWebViewHttpError',
'handleRetryLoadFailure',
'onError={handleWebViewLoadError}',
'onHttpError={handleWebViewHttpError}',
'loadFailurePanel',
'loadFailureButton',
'AppState.addEventListener',
'app.lifecycle',
'network.statusChanged',
'getMobileNetworkStatus',
'subscribeMobileNetworkStatus',
'nativeCanGoBackRef',
'h5CanGoBackRef',
'syncNavigationCanGoBack',
'resetNavigationCanGoBack',
'injectHostBridgeEvent',
'injectLifecycleEvent',
'injectNetworkStatusEvent',
'handleWebViewLoad',
'onLoad={handleWebViewLoad}',
'navigation.canGoBack',
"syncNavigationCanGoBack('h5', historyState.canGoBack)",
"syncNavigationCanGoBack('native', event.canGoBack)",
"webViewRef.current?.injectJavaScript('window.history.back(); true;')",
'buildHostBridgeMessageScript',
'parseMobileWebViewHistoryStateMessage',
'origin: window.location.origin',
'source: window',
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
'shouldBlockMobileWebViewNavigationRequest',
'SafeAreaProvider',
'SafeAreaView',
'MOBILE_SHELL_SAFE_AREA_EDGES',
'resolveMobileShellBaseWebUrl',
'javaScriptCanOpenWindowsAutomatically={false}',
'mixedContentMode="never"',
'allowFileAccess={false}',
'allowFileAccessFromFileURLs={false}',
'allowUniversalAccessFromFileURLs={false}',
'allowsFullscreenVideo',
'allowsInlineMediaPlayback',
'mediaPlaybackRequiresUserAction',
'thirdPartyCookiesEnabled={false}',
'sharedCookiesEnabled={false}',
'webviewDebuggingEnabled={false}',
'injectedJavaScriptBeforeContentLoaded={MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT}',
'handleBlockedFileDownload',
'onFileDownload={handleBlockedFileDownload}',
'setSupportMultipleWindows={false}',
]) {
if (!shellAppSource.includes(snippet)) {
throw new Error(`mobile shell ShellApp missing ${snippet}`);
}
}
for (const snippet of [
'MobileShellLoadFailureInput',
'normalizeMobileShellLoadFailure',
'shouldShowLoadFailure',
'sameDocumentUrl',
'shouldOpenInMobileShellWebView',
"input.type === 'http'",
"title: '网络不可用'",
"retryLabel: '重试'",
"url.pathname !== '/favicon.ico'",
]) {
if (!loadFailureSource.includes(snippet)) {
throw new Error(`mobile shell load failure policy missing ${snippet}`);
}
}
for (const snippet of [
'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT',
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
"'blob:'",
"'data:'",
"'file:'",
"'filesystem:'",
'shouldBlockMobileWebViewDownloadUrl',
'shouldBlockMobileWebViewNavigationRequest',
"target.closest('a')",
'event.stopImmediatePropagation()',
'window.open = function(url)',
'HTMLAnchorElement.prototype.click',
'window.history.pushState = function(state, title, url)',
'window.history.replaceState = function(state, title, url)',
"window.addEventListener('popstate'",
'ReactNativeWebView',
'genarrative.mobile.historyState',
'__genarrativeMobileHistoryIndex',
'__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__',
]) {
if (!webViewPolicySource.includes(snippet)) {
throw new Error(`mobile shell WebView policy missing ${snippet}`);
}
}
for (const snippet of [
'parseMobileWebViewHistoryStateMessage',
'genarrative.mobile.historyState',
'typeof candidate.canGoBack !== \'boolean\'',
]) {
if (!webViewHistorySource.includes(snippet)) {
throw new Error(`mobile shell WebView history parser missing ${snippet}`);
}
}
if (!appSource.includes("import ShellApp from './src/shell/ShellApp';")) {
throw new Error('mobile shell App must import the shell app facade');
}
if (!appSource.includes('return <ShellApp />;')) {
throw new Error('mobile shell App must only render the shell app facade');
}
if (appSource.includes('./src/host-bridge/')) {
throw new Error('mobile shell App must not import HostBridge directly');
}
if (
shellAppSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||')
) {
throw new Error(
'mobile shell ShellApp must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL',
);
}
for (const snippet of [
'HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
'HOST_BRIDGE_PUBLIC_WEB_URL',
'DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL',
'ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
'LOCAL_DEVELOPMENT_WEB_HOSTS',
"'127.0.0.1'",
"'localhost'",
"'[::1]'",
'isAllowedMobileShellBaseUrl',
]) {
if (!urlSource.includes(snippet)) {
throw new Error(`mobile shell H5 URL allowlist missing ${snippet}`);
}
}
for (const localWebOriginDuplicate of [
`DEFAULT_MOBILE_SHELL_WEB_URL = '${sharedPublicWebUrl}'`,
`ALLOWED_PRODUCTION_WEB_ORIGIN = '${sharedPublicWebOrigin}'`,
]) {
if (urlSource.includes(localWebOriginDuplicate)) {
throw new Error('mobile shell public web origin must come from shared HostBridge contract');
}
}
if (!webViewPolicySource.includes('DEFAULT_MOBILE_SHELL_WEB_URL')) {
throw new Error('mobile shell WebView policy must use the shared default H5 URL');
}
if (webViewPolicySource.includes(`'${sharedPublicWebUrl}'`)) {
throw new Error('mobile shell WebView policy must not duplicate the public web URL');
}
for (const snippet of [
'ALLOWED_PRODUCTION_WEB_ORIGIN',
'normalizePublicShareUrl',
"rawUrl.startsWith('//')",
'url.origin !== ALLOWED_PRODUCTION_WEB_ORIGIN',
]) {
if (!shareSource.includes(snippet)) {
throw new Error(`mobile shell share URL policy missing ${snippet}`);
}
}
if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world'")) {
throw new Error('mobile shell share URL policy must reuse the shared web origin');
}
for (const snippet of [
'buildMobileShellUrl(',
'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(','))",
]) {
if (!urlSource.includes(snippet)) {
throw new Error(`mobile shell host-context URL builder missing ${snippet}`);
}
}
for (const snippet of [
'resolveMobileShellBaseWebUrl(baseWebUrl)',
'resolveTargetPath(rawUrl, webOrigin)',
'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)',
]) {
if (!deepLinkSource.includes(snippet)) {
throw new Error(`mobile shell deep link host-context flow missing ${snippet}`);
}
}
for (const snippet of [
'resolveMobileShellWebViewUrl',
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
'new URL(rawUrl, allowedOrigin).toString()',
]) {
if (!navigationSource.includes(snippet)) {
throw new Error(`mobile shell native-page navigation policy missing ${snippet}`);
}
}
if (shellAppSource.includes('127.0.0.1:3000')) {
throw new Error(
'mobile shell ShellApp must not hard-code localhost as the default H5 URL',
);
}
for (const dependency of [
'expo-file-system',
'expo-document-picker',
'expo-image-picker',
'expo-network',
'expo-notifications',
'expo-sharing',
'react-native-safe-area-context',
]) {
if (!packageConfig.dependencies?.[dependency]) {
throw new Error(`mobile shell package missing ${dependency}`);
}
}
const imagePickerPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-image-picker' : plugin === 'expo-image-picker',
);
if (!imagePickerPlugin) {
throw new Error('mobile shell image picker plugin is missing');
}
if (Array.isArray(imagePickerPlugin)) {
const pluginOptions = imagePickerPlugin[1] ?? {};
if (typeof pluginOptions.photosPermission !== 'string') {
throw new Error('mobile shell image picker photo permission text is missing');
}
if (typeof pluginOptions.cameraPermission !== 'string') {
throw new Error('mobile shell image picker camera permission text is missing');
}
if (pluginOptions.microphonePermission !== false) {
throw new Error('mobile shell image picker must not request microphone');
}
}
const notificationsPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-notifications' : plugin === 'expo-notifications',
);
if (!notificationsPlugin) {
throw new Error('mobile shell notifications plugin is missing');
}
if (
appConfig.plugins?.some((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-updates' : plugin === 'expo-updates',
)
) {
throw new Error('mobile shell must not install expo-updates without a real release channel');
}
if (Array.isArray(notificationsPlugin)) {
const pluginOptions = notificationsPlugin[1] ?? {};
if (pluginOptions.enableBackgroundRemoteNotifications !== false) {
throw new Error('mobile shell must not enable background remote notifications');
}
}
for (const forbiddenNotificationSnippet of [
'getExpoPushTokenAsync',
'getDevicePushTokenAsync',
'addPushTokenListener',
'addNotificationResponseReceivedListener',
...blockedScheduledNotificationSnippets,
]) {
if (hostBridgeSource.includes(forbiddenNotificationSnippet)) {
throw new Error(
`mobile shell must not register remote or background notification flow: ${forbiddenNotificationSnippet}`,
);
}
}
for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) {
if (hostBridgeSource.includes(forbiddenHostBridgeRuntimeSnippet)) {
throw new Error(
`mobile shell HostBridge production code must not rely on ${forbiddenHostBridgeRuntimeSnippet}`,
);
}
}
if (
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
dispatchSource,
)
) {
throw new Error('mobile shell local notifications must stay immediate and channel-only');
}
for (const snippet of [
'file.exportText',
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'clipboard.readText',
'notification.showLocal',
'network.status',
'app.reloadWebView',
'getMobileNetworkStatus',
'Notifications.scheduleNotificationAsync',
'Notifications.setNotificationChannelAsync',
'Notifications.getPermissionsAsync',
'Notifications.requestPermissionsAsync',
'Sharing.shareAsync',
'DocumentPicker.getDocumentAsync',
'Clipboard.getStringAsync',
'ImagePicker.launchImageLibraryAsync',
'ImagePicker.launchCameraAsync',
'ImagePicker.requestMediaLibraryPermissionsAsync',
'ImagePicker.requestCameraPermissionsAsync',
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
"'audio/*'",
'File(asset.uri)',
'file.base64()',
'detectImageMimeType',
'detectAudioMimeType',
'ensureImageBytesMatchMimeType',
'ensureAudioBytesMatchMimeType',
"'image bytes do not match MIME'",
"'audio bytes do not match MIME'",
'normalizeExportedImageFileName',
'normalizeHostBridgeExportFileName',
'normalizeHostBridgeClipboardText',
'normalizeHostBridgeHapticsImpactStyle',
'base64Data',
'isHostBridgeMethod',
'normalizeHostBridgeRequestId',
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
'completedHostBridgeResponses',
'inFlightHostBridgeResponses',
'resolveMobileHostBridgeResponse',
'rememberHostBridgeResponse',
'buildMobileShellUrl(webViewUrl, navigation.urlOptions)',
]) {
if (!hostBridgeSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge missing ${snippet}`);
}
}
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
if (shellAppSource.includes(capabilityQuerySnippet)) {
throw new Error('mobile shell URL must resolve platform-aware capabilities');
}
if (!shellAppSource.includes('capabilities: resolveMobileHostCapabilities()')) {
throw new Error('mobile shell URL must use resolveMobileHostCapabilities()');
}
if (!shellAppSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
throw new Error('mobile shell URL must use the shared mobile shell host version');
}
if (!dispatchSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
throw new Error('mobile shell runtime response must use the shared mobile shell host version');
}
if (
shellAppSource.includes("hostVersion: '0.1.0'") ||
hostBridgeSource.includes("hostVersion: '0.1.0'")
) {
throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source');
}
if (shellAppSource.includes(`bridge: '${sharedHostBridgeProtocol}'`)) {
throw new Error('mobile shell event injection must use HOST_BRIDGE_PROTOCOL');
}
if (shellAppSource.includes(`version: ${sharedHostBridgeVersion}`)) {
throw new Error('mobile shell event injection must use HOST_BRIDGE_VERSION');
}
if (urlSource.includes(`bridgeVersion', '${sharedHostBridgeVersion}'`)) {
throw new Error('mobile shell URL builder must use HOST_BRIDGE_VERSION');
}
for (const capability of sdkBackedCapabilities) {
if (
shellAppSource.includes(`'${capability}'`) ||
shellAppSource.includes(`"${capability}"`)
) {
throw new Error(
`mobile shell URL must not advertise ${capability} without a real SDK/channel flow`,
);
}
}
for (const capability of [
'host.getRuntime',
'appearance.getColorScheme',
'share.open',
'share.setTarget',
'app.lifecycle',
'network.status',
'network.statusChanged',
'navigation.openNativePage',
'navigation.canGoBack',
'app.reloadWebView',
'app.openExternalUrl',
'clipboard.writeText',
'clipboard.readText',
'file.exportText',
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
'notification.showLocal',
]) {
if (!mobileCapabilitySet.has(capability)) {
throw new Error(`mobile shell capabilities missing ${capability}`);
}
}
if (!iosMobileCapabilitySet.has('app.setBadgeCount')) {
throw new Error('iOS mobile shell capabilities missing app.setBadgeCount');
}
if (mobileCapabilitySet.has('app.setBadgeCount')) {
throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount');
}
if (!dispatchSource.includes('Appearance.getColorScheme()')) {
throw new Error('mobile shell HostBridge must read the native color scheme');
}
if (!dispatchSource.includes('Linking.canOpenURL(url)')) {
throw new Error('mobile shell HostBridge external URL flow must check Linking.canOpenURL');
}
if (!shellAppSource.includes('Linking.canOpenURL(externalUrl)')) {
throw new Error('mobile shell WebView external navigation must check Linking.canOpenURL');
}