Files
Genarrative/apps/mobile-shell/scripts/check-config.mjs
T
kdletters 6c0d929e91 补齐移动壳扫码真实链路测试
新增 ShellApp 级扫码 HostBridge 测试覆盖 WebView 请求到相机扫码回注

移动壳 Vitest 纳入 TSX 组件测试

单端与根级原生壳门禁反查扫码链路测试存在
2026-06-20 05:17:13 +08:00

2156 lines
72 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 shellAppTestPath = new URL('../src/shell/ShellApp.test.tsx', import.meta.url);
const shellAppTestSource = fs.readFileSync(shellAppTestPath, 'utf8');
const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import.meta.url);
const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8');
const appearancePath = new URL('../src/host-bridge/appearance.ts', import.meta.url);
const appearanceSource = fs.readFileSync(appearancePath, 'utf8');
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const bridgeTestPath = new URL('../src/host-bridge/bridge.test.ts', import.meta.url);
const bridgeTestSource = fs.readFileSync(bridgeTestPath, 'utf8');
const badgePath = new URL('../src/host-bridge/badge.ts', import.meta.url);
const badgeSource = fs.readFileSync(badgePath, 'utf8');
const clipboardPath = new URL('../src/host-bridge/clipboard.ts', import.meta.url);
const clipboardSource = fs.readFileSync(clipboardPath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
const filesPath = new URL('../src/host-bridge/files.ts', import.meta.url);
const filesSource = fs.readFileSync(filesPath, 'utf8');
const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url);
const hapticsSource = fs.readFileSync(hapticsPath, 'utf8');
const hostBridgeNavigationPath = new URL(
'../src/host-bridge/navigation.ts',
import.meta.url,
);
const hostBridgeNavigationSource = fs.readFileSync(
hostBridgeNavigationPath,
'utf8',
);
const hostBridgeNetworkPath = new URL(
'../src/host-bridge/network.ts',
import.meta.url,
);
const hostBridgeNetworkSource = fs.readFileSync(
hostBridgeNetworkPath,
'utf8',
);
const notificationsPath = new URL('../src/host-bridge/notifications.ts', import.meta.url);
const notificationsSource = fs.readFileSync(notificationsPath, 'utf8');
const protocolPath = new URL('../src/host-bridge/protocol.ts', import.meta.url);
const protocolSource = fs.readFileSync(protocolPath, 'utf8');
const scannerPath = new URL('../src/host-bridge/scanner.ts', import.meta.url);
const scannerSource = fs.readFileSync(scannerPath, 'utf8');
const hostBridgeRuntimePath = new URL('../src/host-bridge/runtime.ts', import.meta.url);
const hostBridgeRuntimeSource = fs.readFileSync(hostBridgeRuntimePath, '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 requiredMobileShellSourceModules = [
'env.d.ts',
'host-bridge/appearance.ts',
'host-bridge/badge.ts',
'host-bridge/bridge.ts',
'host-bridge/capabilities.ts',
'host-bridge/clipboard.ts',
'host-bridge/dispatch.ts',
'host-bridge/files.ts',
'host-bridge/haptics.ts',
'host-bridge/navigation.ts',
'host-bridge/network.ts',
'host-bridge/notifications.ts',
'host-bridge/protocol.ts',
'host-bridge/runtime.ts',
'host-bridge/scanner.ts',
'host-bridge/share.ts',
'shell/QrScannerOverlay.tsx',
'shell/ShellApp.tsx',
'shell/deepLink.ts',
'shell/lifecycle.ts',
'shell/loadFailure.ts',
'shell/navigation.ts',
'shell/network.ts',
'shell/runtime.ts',
'shell/safeArea.ts',
'shell/url.ts',
'shell/webViewGlobals.d.ts',
'shell/webViewHistory.ts',
'shell/webViewPolicy.ts',
];
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.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 extractStringObjectConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return Object.fromEntries(
[...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [
entry[1],
entry[2],
]),
);
}
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 extractFunctionBody(source, functionName) {
const start = source.indexOf(`function ${functionName}`);
if (start === -1) {
throw new Error(`unable to read function ${functionName}`);
}
const openBrace = source.indexOf('{', start);
if (openBrace === -1) {
throw new Error(`unable to read function body ${functionName}`);
}
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
const character = source[index];
if (character === '{') {
depth += 1;
} else if (character === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBrace + 1, index);
}
}
}
throw new Error(`unable to read complete function body ${functionName}`);
}
function assertMobileDocumentPickerBoundary(
functionName,
expectedTypeExpression,
) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
if (!functionBody.includes('DocumentPicker.getDocumentAsync({')) {
throw new Error(`mobile shell ${functionName} must use DocumentPicker`);
}
for (const requiredPickerOption of [
'copyToCacheDirectory: true',
'multiple: false',
`type: ${expectedTypeExpression}`,
]) {
if (!functionBody.includes(requiredPickerOption)) {
throw new Error(
`mobile shell ${functionName} picker options missing ${requiredPickerOption}`,
);
}
}
}
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(', ')}`,
);
}
}
function collectMobileShellSourceRelativePaths(files) {
const sourceRootPath = new URL('../src/', import.meta.url).pathname;
return files
.map((file) => file.pathname.replace(sourceRootPath, ''))
.filter((filePath) => !filePath.includes('.test.'))
.sort();
}
assertSameList(
collectMobileShellSourceRelativePaths(collectProductionSourceFiles(new URL('../src/', import.meta.url))),
requiredMobileShellSourceModules,
'mobile shell source modules',
);
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-camera': '56.0.8',
'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-camera': '56.0.8',
'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 sharedEvents = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
const sharedBlockedDownloadProtocols = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
);
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 sharedMobileLocalNotificationChannelId = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
);
const sharedNativeAppQueryKeys = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS',
);
const sharedNativeAppQueryKey = extractStringObjectConstExport(
sharedContractSource,
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY',
);
const sharedNativeAppQuery = extractStringObjectConstExport(
sharedContractSource,
'HOST_BRIDGE_NATIVE_APP_QUERY',
);
const sharedPublicWebOriginUrl = new URL(sharedPublicWebOrigin);
if (sharedPublicWebOriginUrl.protocol !== 'https:') {
throw new Error('shared HostBridge public web origin must use https for mobile app links');
}
const sharedPublicWebHost = sharedPublicWebOriginUrl.hostname;
const sharedPublicWebAssociatedDomain = `applinks:${sharedPublicWebHost}`;
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_BADGE_COUNT_MAX',
'HOST_BRIDGE_DOCUMENT_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_DOCUMENT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT',
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
'normalizeHostBridgeImportFileName',
'normalizeHostBridgeQrCodeValue',
'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}`,
);
}
}
if (
!hostBridgeSource.includes(
'HOST_BRIDGE_TEXT_MIME_TYPE_SET.has(mimeType as HostBridgeTextMimeType)',
)
) {
throw new Error(
'mobile shell file.exportText must validate MIME against the shared text MIME set',
);
}
const forbiddenLocalPayloadBoundaryDeclarations = [
'EXPORT_TEXT_MAX_BYTES',
'IMPORT_TEXT_MAX_BYTES',
'IMPORT_DOCUMENT_MAX_BYTES',
'EXPORT_IMAGE_MAX_BYTES',
'IMPORT_IMAGE_MAX_BYTES',
'EXPORT_AUDIO_MAX_BYTES',
'IMPORT_AUDIO_MAX_BYTES',
'HOST_BRIDGE_BADGE_COUNT_MAX',
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
'HOST_BRIDGE_IMPORT_DOCUMENT_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_QR_CODE_VALUE_MAX_LENGTH',
'HOST_BRIDGE_TEXT_MIME_TYPES',
'HOST_BRIDGE_DOCUMENT_MIME_TYPES',
'HOST_BRIDGE_IMAGE_MIME_TYPES',
'HOST_BRIDGE_AUDIO_MIME_TYPES',
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
];
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}`,
);
}
}
if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) {
throw new Error('mobile shell must centralize imported file size checks');
}
if (!bridgeSource.includes('HOST_BRIDGE_RESPONSE_CACHE_MAX,')) {
throw new Error('mobile shell bridge must import shared HostBridge response cache limit');
}
if (/const HOST_BRIDGE_RESPONSE_CACHE_MAX\s*=/.test(protocolSource)) {
throw new Error('mobile shell protocol must not redeclare HostBridge response cache limit');
}
for (const expectedErrorCode of [
'invalid_request',
'unsupported_method',
'unsupported_capability',
'timeout',
'cancelled',
'host_error',
]) {
if (!protocolSource.includes(`'${expectedErrorCode}'`)) {
throw new Error(
`mobile shell protocol error code allowlist missing ${expectedErrorCode}`,
);
}
}
if (
!protocolSource.includes('HOST_BRIDGE_ERROR_CODES.has(') ||
!protocolSource.includes('mobile host bridge request failed')
) {
throw new Error('mobile shell protocol must normalize non-contract host errors');
}
for (const [functionName, readCall] of [
['importTextFile', 'file.text()'],
['importDocumentFile', 'file.base64()'],
['importAudioFile', 'file.base64()'],
]) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
const sizeCheckIndex = functionBody.indexOf('assertImportedFileSizeWithinLimit(');
const readIndex = functionBody.indexOf(readCall);
if (sizeCheckIndex === -1 || readIndex === -1 || sizeCheckIndex > readIndex) {
throw new Error(
`mobile shell ${functionName} must check file size before ${readCall}`,
);
}
}
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');
}
for (const snippet of [
'HOST_BRIDGE_METHODS',
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
'const IOS_UNDECLARED_HOST_BRIDGE_METHODS = HOST_BRIDGE_METHODS.filter(',
'(method) => !HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)',
'test.each(IOS_UNDECLARED_HOST_BRIDGE_METHODS)',
"expect(failedResponse.error.code).toBe('unsupported_method')",
]) {
if (!bridgeTestSource.includes(snippet)) {
throw new Error(
`mobile shell bridge tests must derive unsupported method coverage from shared profiles: ${snippet}`,
);
}
}
const unknownSharedEvents = sharedEvents.filter(
(eventName) => !sharedCapabilities.includes(eventName),
);
if (unknownSharedEvents.length > 0) {
throw new Error(
`shared HostBridge events must also be capabilities: ${unknownSharedEvents.join(', ')}`,
);
}
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_FALLBACK',
);
if (mobileHostVersion !== appConfig.version) {
throw new Error('mobile shell HostBridge fallback 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 ?? [],
[sharedPublicWebAssociatedDomain],
'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.ios?.infoPlist?.NSMicrophoneUsageDescription !==
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。'
) {
throw new Error('mobile shell iOS microphone permission text must describe same-origin H5 gameplay input');
}
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');
}
if (
!Array.isArray(appConfig.android?.permissions) ||
appConfig.android.permissions.length !== 1 ||
appConfig.android.permissions[0] !== 'android.permission.RECORD_AUDIO'
) {
throw new Error('mobile shell Android package must request only RECORD_AUDIO for same-origin H5 microphone gameplay');
}
if (appConfig.android?.permissions?.includes('android.permission.CAMERA')) {
throw new Error('mobile shell Android CAMERA permission must come from real camera plugins only');
}
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?.blockedPermissions?.includes('android.permission.RECORD_AUDIO')) {
throw new Error('mobile shell Android package must not block RECORD_AUDIO needed by same-origin H5 microphone gameplay');
}
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 !== sharedPublicWebHost ||
Object.keys(androidFilterData[0] ?? {}).some(
(key) => key !== 'scheme' && key !== 'host',
)
) {
throw new Error(
`mobile shell Android app link data must only bind ${sharedPublicWebOrigin}`,
);
}
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',
'mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"',
'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 [
'type HostBridgeEventName',
'isHostBridgeEventName',
'function buildHostBridgeEventScript(event: HostBridgeEventName, payload: unknown)',
'if (!isHostBridgeEventName(event))',
'throw new Error(`unsupported HostBridge event ${event}`)',
'return buildHostBridgeMessageScript({',
'(event: HostBridgeEventName, payload: unknown)',
'buildHostBridgeEventScript(event, payload)',
]) {
if (!shellAppSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge event injection missing ${snippet}`);
}
}
for (const eventName of sharedEvents) {
if (
mobileCapabilitySet.has(eventName) &&
!shellAppSource.includes(`'${eventName}'`)
) {
throw new Error(
`mobile shell advertises HostBridge event ${eventName} but ShellApp does not inject it`,
);
}
}
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',
'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
'mobileWebViewBlockedDownloadProtocolMapScript',
'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT',
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
'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}`);
}
}
if (
webViewPolicySource.includes("['blob:'") ||
webViewPolicySource.includes("'filesystem:'")
) {
throw new Error(
'mobile shell WebView blocked download protocols must come from shared contract',
);
}
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 [
'normalizeHostBridgeShareOpenPayload',
'type HostBridgeRequest',
'const explicitPayload = normalizeHostBridgeShareOpenPayload(request.payload);',
'normalizeHostBridgeShareOpenPayload(currentShareTarget)',
'setMobileHostBridgeShareTarget',
'request: HostBridgeRequest',
"throw invalidRequest('target is required')",
'const normalizedTarget = normalizeHostBridgeShareOpenPayload(target);',
"'share target is invalid'",
'ok(request, true)',
'resetMobileHostBridgeShareTargetForTest',
]) {
if (!shareSource.includes(snippet)) {
throw new Error(`mobile shell share URL policy missing ${snippet}`);
}
}
if (
!dispatchSource.includes('setMobileHostBridgeShareTarget(request)') ||
!dispatchSource.includes('openShare(request)') ||
dispatchSource.includes('ok(request, await openShare') ||
dispatchSource.includes('ok(request, setMobileHostBridgeShareTarget') ||
dispatchSource.includes('let currentShareTarget') ||
dispatchSource.includes('normalizeHostBridgeShareOpenPayload(currentShareTarget)')
) {
throw new Error('mobile shell share HostBridge methods must delegate to share module');
}
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_NATIVE_APP_QUERY',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY',
'HOST_BRIDGE_VERSION.toString()',
'HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime',
'HOST_BRIDGE_NATIVE_APP_QUERY.clientType',
'HOST_BRIDGE_NATIVE_APP_QUERY.hostShellExpoMobile',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientType',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostShell',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostPlatform',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostVersion',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.bridgeVersion',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY.hostCapabilities',
]) {
if (!urlSource.includes(snippet)) {
throw new Error(`mobile shell host-context URL builder missing ${snippet}`);
}
}
assertSameList(
Object.values(sharedNativeAppQueryKey),
sharedNativeAppQueryKeys,
'shared native app query key map',
);
if (
sharedNativeAppQuery.clientRuntime !== 'native_app' ||
sharedNativeAppQuery.clientType !== 'native_app' ||
sharedNativeAppQuery.hostShellExpoMobile !== 'expo_mobile'
) {
throw new Error('shared native app mobile query values drifted');
}
for (const hardcodedHostContextSnippet of [
"url.searchParams.set('clientRuntime'",
"url.searchParams.set('clientType'",
"url.searchParams.set('hostShell'",
"url.searchParams.set('hostPlatform'",
"url.searchParams.set('hostVersion'",
"url.searchParams.set('bridgeVersion'",
"url.searchParams.set('hostCapabilities'",
]) {
if (urlSource.includes(hardcodedHostContextSnippet)) {
throw new Error(
'mobile shell host-context URL builder must use shared query key constants',
);
}
}
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 [
'normalizeHostBridgeExternalUrl',
'MobileShellExternalNavigator',
'openMobileShellExternalNavigation',
'resolveMobileShellWebViewUrl',
'return normalizeHostBridgeExternalUrl(rawUrl)',
'navigator.canOpenURL(externalUrl)',
'navigator.openURL(externalUrl)',
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
'new URL(rawUrl, allowedOrigin).toString()',
]) {
if (!navigationSource.includes(snippet)) {
throw new Error(`mobile shell native-page navigation policy missing ${snippet}`);
}
}
if (
navigationSource.includes('javascript:') ||
navigationSource.includes('mailto:') ||
navigationSource.includes('tel:+') ||
navigationSource.includes("protocol === '")
) {
throw new Error(
'mobile shell WebView external protocol policy must use shared HostBridge normalizer',
);
}
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-camera',
'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 (
pluginOptions.photosPermission !==
'允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。'
) {
throw new Error('mobile shell image picker photo permission text must describe selected creative image import');
}
if (
pluginOptions.cameraPermission !==
'允许 Genarrative 使用相机拍摄创作素材和参考图。'
) {
throw new Error('mobile shell image picker camera permission text must describe creative image capture');
}
if (
pluginOptions.microphonePermission !==
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。'
) {
throw new Error('mobile shell image picker microphone text must describe same-origin H5 gameplay input');
}
}
const cameraPlugin = appConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === 'expo-camera' : plugin === 'expo-camera',
);
if (!cameraPlugin) {
throw new Error('mobile shell camera plugin is missing');
}
if (Array.isArray(cameraPlugin)) {
const pluginOptions = cameraPlugin[1] ?? {};
if (
pluginOptions.cameraPermission !==
'允许 Genarrative 使用相机扫描二维码。'
) {
throw new Error('mobile shell camera permission text must describe QR scanning');
}
if (
pluginOptions.microphonePermission !==
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。'
) {
throw new Error('mobile shell camera microphone text must describe same-origin H5 gameplay input');
}
if (pluginOptions.recordAudioAndroid !== true) {
throw new Error('mobile shell camera plugin must enable Android record audio for same-origin H5 microphone gameplay');
}
}
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 (
hostBridgeSource.includes("const LOCAL_NOTIFICATION_CHANNEL_ID = '") ||
hostBridgeSource.includes('"genarrative-local"') ||
hostBridgeSource.includes("'genarrative-local'")
) {
throw new Error(
'mobile shell local notification channel id must come from shared contract',
);
}
if (sharedMobileLocalNotificationChannelId !== 'genarrative-local') {
throw new Error('shared mobile local notification channel id drifted');
}
if (!notificationsSource.includes('HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID')) {
throw new Error('mobile shell must use the shared local notification channel id');
}
if (
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
notificationsSource,
)
) {
throw new Error('mobile shell local notifications must stay immediate and channel-only');
}
if (
!dispatchSource.includes('showMobileHostBridgeLocalNotification(request)') ||
dispatchSource.includes('ok(request, await showMobileHostBridgeLocalNotification') ||
dispatchSource.includes('normalizeHostBridgeLocalNotification') ||
dispatchSource.includes("from 'expo-notifications'") ||
dispatchSource.includes('Notifications.scheduleNotificationAsync') ||
dispatchSource.includes('Notifications.requestPermissionsAsync') ||
dispatchSource.includes('Notifications.setNotificationChannelAsync')
) {
throw new Error(
'mobile shell dispatch must delegate local notification delivery to notifications.ts',
);
}
for (const notificationSnippet of [
'showMobileHostBridgeLocalNotification',
'type HostBridgeRequest',
'request: HostBridgeRequest',
'normalizeHostBridgeLocalNotification(request.payload)',
"invalidRequest('title is required')",
'showMobileLocalNotification(notification)',
'ok(request, await showMobileLocalNotification(notification))',
'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_RESULT',
]) {
if (!notificationsSource.includes(notificationSnippet)) {
throw new Error(`mobile shell notifications module is missing ${notificationSnippet}`);
}
}
for (const snippet of [
'file.exportText',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'clipboard.readText',
'notification.showLocal',
'showMobileLocalNotification',
'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',
'scanQrCode',
'normalizeHostBridgeQrCodeValue',
'completeQrCodeScan',
'cancelQrCodeScan',
'failQrCodeScan',
'subscribeQrScannerState',
'MOBILE_DOCUMENT_PICKER_TYPES',
'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',
'normalizeHostBridgeExternalUrlPayload',
'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}`);
}
}
for (const [functionName, pickerCall] of [
['importImageFile', 'ImagePicker.launchImageLibraryAsync({'],
['captureImageFile', 'ImagePicker.launchCameraAsync({'],
]) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
for (const requiredPickerOption of [
pickerCall,
'allowsEditing: false',
'base64: true',
'exif: false',
"mediaTypes: ['images']",
'quality: 1',
]) {
if (!functionBody.includes(requiredPickerOption)) {
throw new Error(
`mobile shell ${functionName} picker options missing ${requiredPickerOption}`,
);
}
}
}
assertMobileDocumentPickerBoundary(
'importTextFile',
"['text/*', 'application/json']",
);
assertMobileDocumentPickerBoundary(
'importDocumentFile',
'MOBILE_DOCUMENT_PICKER_TYPES',
);
assertMobileDocumentPickerBoundary(
'importAudioFile',
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
);
const exportImageFileBody = extractFunctionBody(filesSource, 'exportImageFile');
if (
!exportImageFileBody.includes(
'bytes <= 0 || bytes > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
)
) {
throw new Error('mobile shell image export must reject empty and oversized bytes');
}
for (const [wrapperName, fileCall] of [
['exportMobileHostBridgeTextFile', 'ok(request, await exportTextFile(request.payload))'],
['importMobileHostBridgeTextFile', 'ok(request, await importTextFile())'],
['importMobileHostBridgeDocumentFile', 'ok(request, await importDocumentFile())'],
['exportMobileHostBridgeImageFile', 'ok(request, await exportImageFile(request.payload))'],
['importMobileHostBridgeImageFile', 'ok(request, await importImageFile())'],
['captureMobileHostBridgeImageFile', 'ok(request, await captureImageFile())'],
['exportMobileHostBridgeAudioFile', 'ok(request, await exportAudioFile(request.payload))'],
['importMobileHostBridgeAudioFile', 'ok(request, await importAudioFile())'],
]) {
const wrapperBody = extractFunctionBody(filesSource, wrapperName);
if (!wrapperBody.includes(fileCall)) {
throw new Error(`mobile shell file HostBridge wrapper missing ${fileCall}`);
}
}
if (
!dispatchSource.includes('exportMobileHostBridgeTextFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeTextFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeDocumentFile(request)') ||
!dispatchSource.includes('exportMobileHostBridgeImageFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeImageFile(request)') ||
!dispatchSource.includes('captureMobileHostBridgeImageFile(request)') ||
!dispatchSource.includes('exportMobileHostBridgeAudioFile(request)') ||
!dispatchSource.includes('importMobileHostBridgeAudioFile(request)') ||
dispatchSource.includes('ok(request, await exportTextFile(request.payload))') ||
dispatchSource.includes('ok(request, await importTextFile())') ||
dispatchSource.includes('ok(request, await importDocumentFile())') ||
dispatchSource.includes('ok(request, await exportImageFile(request.payload))') ||
dispatchSource.includes('ok(request, await importImageFile())') ||
dispatchSource.includes('ok(request, await captureImageFile())') ||
dispatchSource.includes('ok(request, await exportAudioFile(request.payload))') ||
dispatchSource.includes('ok(request, await importAudioFile())')
) {
throw new Error('mobile shell dispatch must delegate file requests to files.ts');
}
if (
!hostBridgeNavigationSource.includes(
'const externalUrlPayload = normalizeHostBridgeExternalUrlPayload(',
) ||
!hostBridgeNavigationSource.includes(
'openMobileShellExternalNavigation(Linking, externalUrlPayload.url)',
)
) {
throw new Error(
'mobile shell app.openExternalUrl must normalize payloads and use the shared external navigation helper',
);
}
if (
!clipboardSource.includes(
'const clipboardText = normalizeHostBridgeClipboardText(',
) ||
!clipboardSource.includes('Clipboard.setStringAsync(clipboardText.text)') ||
!clipboardSource.includes('await Clipboard.getStringAsync()')
) {
throw new Error(
'mobile shell clipboard bridge must normalize text with the shared HostBridge clipboard boundary',
);
}
if (
dispatchSource.includes("from 'expo-clipboard'") ||
dispatchSource.includes('Clipboard.setStringAsync') ||
dispatchSource.includes('Clipboard.getStringAsync') ||
dispatchSource.includes('ClipboardWriteTextPayload') ||
!dispatchSource.includes('writeMobileHostBridgeClipboardText(request)') ||
!dispatchSource.includes('readMobileHostBridgeClipboardText(request)') ||
dispatchSource.includes('ok(request, await writeMobileHostBridgeClipboardText') ||
dispatchSource.includes('ok(request, await readMobileHostBridgeClipboardText')
) {
throw new Error(
'mobile shell dispatch must delegate clipboard IO to clipboard.ts',
);
}
for (const snippet of [
'writeMobileHostBridgeClipboardText',
'type ClipboardWriteTextPayload',
'request: HostBridgeRequest',
'ok(request, true)',
'writeMobileClipboardText(text)',
'readMobileHostBridgeClipboardText',
'ok(request, await readMobileClipboardText())',
]) {
if (!clipboardSource.includes(snippet)) {
throw new Error(`mobile shell clipboard module is missing ${snippet}`);
}
}
if (!bridgeTestSource.includes("'猫'.repeat(100000)")) {
throw new Error('mobile shell clipboard tests must cover Unicode truncation');
}
if (
!hapticsSource.includes('normalizeHostBridgeHapticsImpactStyle(rawStyle)') ||
!hapticsSource.includes('type HapticsImpactPayload') ||
!hapticsSource.includes('type HostBridgeRequest') ||
!hapticsSource.includes('runMobileHostBridgeHapticsImpact(') ||
!hapticsSource.includes('request: HostBridgeRequest') ||
!hapticsSource.includes('(request.payload as HapticsImpactPayload | undefined)?.style') ||
!hapticsSource.includes("invalidRequest('haptics impact style must be light, medium, or heavy')") ||
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Heavy') ||
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Medium') ||
!hapticsSource.includes('Haptics.ImpactFeedbackStyle.Light') ||
!hapticsSource.includes('await Haptics.impactAsync(toExpoImpactStyle(style))') ||
!hapticsSource.includes('ok(request, true)')
) {
throw new Error(
'mobile shell haptics bridge must normalize impact style with the shared HostBridge boundary',
);
}
if (
dispatchSource.includes("from 'expo-haptics'") ||
dispatchSource.includes('Haptics.impactAsync') ||
dispatchSource.includes('Haptics.ImpactFeedbackStyle') ||
dispatchSource.includes('HapticsImpactPayload') ||
dispatchSource.includes('haptics impact style must be light, medium, or heavy') ||
dispatchSource.includes('ok(request, await runMobileHostBridgeHapticsImpact') ||
!dispatchSource.includes('runMobileHostBridgeHapticsImpact(request)')
) {
throw new Error(
'mobile shell dispatch must delegate haptics payload and IO to haptics.ts',
);
}
for (const snippet of [
'CameraView',
'Camera.requestCameraPermissionsAsync',
'type BarcodeScanningResult',
'onBarcodeScanned',
'barcodeScannerSettings',
"barcodeTypes: ['qr']",
'completeQrCodeScan(result.data)',
'failQrCodeScan',
'cancelQrCodeScan',
'subscribeQrScannerState',
]) {
if (!qrScannerOverlaySource.includes(snippet)) {
throw new Error(`mobile shell QR scanner overlay missing ${snippet}`);
}
}
if (!shellAppSource.includes('<QrScannerOverlay />')) {
throw new Error('mobile shell ShellApp must render the QR scanner overlay');
}
for (const snippet of [
'scanner.scanQrCode',
'requestCameraPermissionsAsync',
'onBarcodeScanned',
'injectJavaScript',
'scan-request-1',
]) {
if (!shellAppTestSource.includes(snippet)) {
throw new Error(`mobile shell ShellApp QR scanner test missing ${snippet}`);
}
}
if (
!dispatchSource.includes('scanMobileHostBridgeQrCode(request)') ||
dispatchSource.includes('ok(request, await scanQrCode())')
) {
throw new Error('mobile shell QR scanner HostBridge method must delegate to scanner module');
}
for (const scannerSnippet of [
'scanMobileHostBridgeQrCode(request: HostBridgeRequest)',
'ok(request, await scanQrCode())',
'normalizeHostBridgeQrCodeValue',
"hostBridgeError('cancelled', 'qr scan cancelled')",
]) {
if (!scannerSource.includes(scannerSnippet)) {
throw new Error(`mobile shell QR scanner module missing ${scannerSnippet}`);
}
}
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 (!hostBridgeRuntimeSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
throw new Error('mobile shell runtime response must use the shared mobile shell host version');
}
for (const runtimeSnippet of [
'export function getMobileRuntimePlatform()',
"return Platform.OS === 'ios' ? 'ios' : 'android'",
'export function getMobileHostBridgeRuntime(): HostBridgeRuntimeResult',
'const platform = getMobileRuntimePlatform();',
'platform,',
'capabilities: resolveMobileHostCapabilities(platform)',
'getMobileHostBridgeRuntimeResponse(request: HostBridgeRequest)',
'ok(request, getMobileHostBridgeRuntime())',
]) {
if (!hostBridgeRuntimeSource.includes(runtimeSnippet)) {
throw new Error(`mobile shell runtime response missing ${runtimeSnippet}`);
}
}
if (
!dispatchSource.includes('getMobileHostBridgeRuntimeResponse(request)') ||
dispatchSource.includes('ok(request, getMobileHostBridgeRuntime') ||
dispatchSource.includes('MOBILE_SHELL_HOST_VERSION') ||
dispatchSource.includes('HOST_BRIDGE_VERSION') ||
dispatchSource.includes('resolveMobileHostCapabilities(') ||
dispatchSource.includes('getMobileRuntimePlatform') ||
dispatchSource.includes("shell: 'expo_mobile'")
) {
throw new Error('mobile shell dispatch must delegate host.getRuntime to runtime.ts');
}
if (hostBridgeRuntimeSource.includes('capabilities: resolveMobileHostCapabilities(),')) {
throw new Error(
'mobile shell runtime capabilities must use the same normalized platform reported in host.getRuntime',
);
}
for (const snippet of [
"import appConfig from '../../app.json'",
'resolveMobileShellHostVersion(',
'appConfig.expo?.version',
'MOBILE_SHELL_HOST_VERSION_FALLBACK',
]) {
if (!runtimeSource.includes(snippet)) {
throw new Error(`mobile shell runtime version source missing ${snippet}`);
}
}
if (/export const MOBILE_SHELL_HOST_VERSION\s*=\s*'/.test(runtimeSource)) {
throw new Error('mobile shell host version must be resolved from Expo runtime config');
}
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.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'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('setMobileAppBadgeCount(request)') ||
dispatchSource.includes('ok(request, setMobileAppBadgeCount') ||
dispatchSource.includes('PushNotificationIOS') ||
dispatchSource.includes('normalizeHostBridgeBadgeCount') ||
dispatchSource.includes('HOST_BRIDGE_BADGE_COUNT_MAX')
) {
throw new Error('mobile shell badge HostBridge method must delegate to badge module');
}
for (const snippet of [
'HOST_BRIDGE_BADGE_COUNT_MAX',
'request: HostBridgeRequest',
'normalizeHostBridgeBadgeCount',
'PushNotificationIOS.setApplicationIconBadgeNumber(count)',
'ok(request, true)',
'app badge count is only supported on iOS mobile shell',
]) {
if (!badgeSource.includes(snippet)) {
throw new Error(`mobile shell badge module is missing ${snippet}`);
}
}
for (const snippet of [
"test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported'",
"setPlatformOS('android')",
"request('app.setBadgeCount',",
"expect(expectFailed(unsupported).error.code).toBe('unsupported_capability')",
'expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled()',
]) {
if (!bridgeTestSource.includes(snippet)) {
throw new Error(
`mobile shell bridge tests must cover Android app.setBadgeCount unsupported semantics: ${snippet}`,
);
}
}
if (
!dispatchSource.includes('getMobileHostBridgeAppearanceColorScheme(request)') ||
dispatchSource.includes('ok(request, getMobileAppearanceColorScheme())') ||
dispatchSource.includes('Appearance.getColorScheme()') ||
dispatchSource.includes('normalizeHostBridgeColorScheme')
) {
throw new Error('mobile shell appearance HostBridge method must delegate to appearance module');
}
for (const snippet of [
'Appearance.getColorScheme()',
'normalizeHostBridgeColorScheme',
'getMobileAppearanceColorScheme',
'getMobileHostBridgeAppearanceColorScheme',
'request: HostBridgeRequest',
'ok(request, getMobileAppearanceColorScheme())',
]) {
if (!appearanceSource.includes(snippet)) {
throw new Error(`mobile shell appearance module is missing ${snippet}`);
}
}
if (
!dispatchSource.includes('openMobileHostBridgeExternalUrl(request)') ||
!dispatchSource.includes('openMobileHostBridgeNativePage(request, navigation)') ||
!dispatchSource.includes('reloadMobileHostBridgeWebView(request, navigation)') ||
dispatchSource.includes('ok(request, await openMobileHostBridgeExternalUrl') ||
dispatchSource.includes('ok(request, reloadMobileHostBridgeWebView') ||
dispatchSource.includes('ok(\\n request,\\n openMobileHostBridgeNativePage') ||
dispatchSource.includes("from 'expo-linking'") ||
dispatchSource.includes('Linking.') ||
dispatchSource.includes('openMobileShellExternalNavigation') ||
dispatchSource.includes('resolveMobileShellWebViewUrl') ||
dispatchSource.includes('normalizeHostBridgeExternalUrlPayload') ||
dispatchSource.includes('buildMobileShellUrl')
) {
throw new Error('mobile shell navigation HostBridge methods must delegate to navigation module');
}
for (const snippet of [
'openMobileHostBridgeExternalUrl',
'request: HostBridgeRequest',
"import * as Linking from 'expo-linking'",
'openMobileShellExternalNavigation(Linking, externalUrlPayload.url)',
'(request.payload as OpenExternalUrlPayload | undefined)?.url',
'normalizeHostBridgeExternalUrlPayload',
'openMobileHostBridgeNativePage',
'(request.payload as NavigateNativePagePayload | undefined)?.url',
'resolveMobileShellWebViewUrl',
'buildMobileShellUrl(webViewUrl, navigation.urlOptions)',
'reloadMobileHostBridgeWebView',
'ok(request, true)',
]) {
if (!hostBridgeNavigationSource.includes(snippet)) {
throw new Error(`mobile shell navigation module is missing ${snippet}`);
}
}
if (!hostBridgeNavigationSource.includes('openMobileShellExternalNavigation(Linking, externalUrlPayload.url)')) {
throw new Error(
'mobile shell HostBridge external URL flow must use the shared external navigation helper',
);
}
if (
!dispatchSource.includes('getMobileHostBridgeNetworkStatus(request)') ||
dispatchSource.includes('../shell/network') ||
dispatchSource.includes('getMobileNetworkStatus()') ||
dispatchSource.includes('ok(request, await getMobileHostBridgeNetworkStatus())')
) {
throw new Error('mobile shell network HostBridge method must delegate to network module');
}
for (const snippet of [
'getMobileHostBridgeNetworkStatus',
'request: HostBridgeRequest',
'ok(request, await getMobileNetworkStatus())',
'getMobileNetworkStatus()',
]) {
if (!hostBridgeNetworkSource.includes(snippet)) {
throw new Error(`mobile shell network module is missing ${snippet}`);
}
}
if (!shellAppSource.includes('openMobileShellExternalNavigation(Linking, request.url)')) {
throw new Error(
'mobile shell WebView external navigation must use the tested external navigation helper',
);
}
if (
shellAppSource.includes('Linking.canOpenURL(externalUrl)') ||
shellAppSource.includes('Linking.openURL(externalUrl)')
) {
throw new Error(
'mobile shell ShellApp must not inline external navigation Link handling',
);
}