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 easConfigPath = new URL('../eas.json', import.meta.url); const easConfig = JSON.parse(fs.readFileSync(easConfigPath, '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('../scripts/', 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 requiredMobileShellScriptModules = [ 'check-eas-build-config.mjs', 'check-expo-config.mjs', 'check-expo-export.mjs', ]; 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', ); assertSameList( fs .readdirSync(new URL('../scripts/', import.meta.url), { withFileTypes: true, }) .filter((entry) => entry.isFile() && entry.name !== 'check-config.mjs') .map((entry) => entry.name) .sort(), requiredMobileShellScriptModules, 'mobile shell smoke scripts', ); 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', 'build:android': 'eas build --local --profile production --platform android', 'build:ios': 'eas build --local --profile production-simulator --platform ios', 'build-config:smoke': 'node scripts/check-eas-build-config.mjs', '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:build-config': 'npm --prefix apps/mobile-shell run build-config:smoke', 'mobile-shell:build:android': 'npm --prefix apps/mobile-shell run build:android', 'mobile-shell:build:ios': 'npm --prefix apps/mobile-shell run build:ios', '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, ); } assertPackageDependencyVersion( rootPackageConfig, 'root package', 'devDependencies', 'eas-cli', '^20.3.0', ); assertPackageLockVersion('eas-cli', '20.3.0'); 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'); } if (easConfig.cli?.version !== '>= 20.3.0' || easConfig.cli?.appVersionSource !== 'local') { throw new Error('mobile shell EAS builds must pin CLI floor and use local app version fields'); } if ( easConfig.build?.production?.distribution !== 'internal' || easConfig.build?.production?.channel !== 'production' || easConfig.build?.production?.android?.buildType !== 'apk' || easConfig.build?.production?.env?.EXPO_NO_DOTENV !== '1' ) { throw new Error('mobile shell EAS Android production profile must build an internal APK without local dotenv'); } if ( easConfig.build?.['production-simulator']?.distribution !== 'internal' || easConfig.build?.['production-simulator']?.channel !== 'production' || easConfig.build?.['production-simulator']?.ios?.simulator !== true || easConfig.build?.['production-simulator']?.env?.EXPO_NO_DOTENV !== '1' ) { throw new Error('mobile shell EAS iOS production smoke profile must build an internal simulator package without local dotenv'); } for (const [profileName, profile] of Object.entries(easConfig.build ?? {})) { for (const blockedKey of ['credentialsSource', 'autoIncrement', 'submit', 'releaseChannel']) { if (blockedKey in profile) { throw new Error(`mobile shell EAS ${profileName} profile must not configure ${blockedKey}`); } } } if ('submit' in easConfig) { throw new Error('mobile shell EAS config must not include store submit profiles yet'); } 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'); } const iosPrivacyManifests = appConfig.ios?.privacyManifests; if (!iosPrivacyManifests) { throw new Error('mobile shell iOS privacy manifests must be configured'); } if (iosPrivacyManifests.NSPrivacyTracking !== false) { throw new Error('mobile shell iOS privacy manifest must not enable tracking'); } assertSameList( iosPrivacyManifests.NSPrivacyCollectedDataTypes ?? [], [], 'mobile shell iOS privacy collected data types', ); assertSameList( iosPrivacyManifests.NSPrivacyTrackingDomains ?? [], [], 'mobile shell iOS privacy tracking domains', ); const requiredPrivacyAccessedApiTypes = new Map([ [ 'NSPrivacyAccessedAPICategoryFileTimestamp', ['0A2A.1', '3B52.1', 'C617.1'], ], ['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']], ['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']], ['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']], ]); const privacyAccessedApiTypes = iosPrivacyManifests.NSPrivacyAccessedAPITypes ?? []; if (privacyAccessedApiTypes.length !== requiredPrivacyAccessedApiTypes.size) { throw new Error('mobile shell iOS privacy manifest accessed API type count drifted'); } for (const [apiType, reasons] of requiredPrivacyAccessedApiTypes) { const entry = privacyAccessedApiTypes.find( (candidate) => candidate.NSPrivacyAccessedAPIType === apiType, ); if (!entry) { throw new Error(`mobile shell iOS privacy manifest missing ${apiType}`); } assertSameList( entry.NSPrivacyAccessedAPITypeReasons ?? [], reasons, `mobile shell iOS privacy reasons for ${apiType}`, ); } 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', 'logMobileHostEventFailure', "logMobileHostEventFailure('network.statusChanged', error)", '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 ;')) { 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('')) { 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}`); } } for (const snippet of [ "hostBridgeEvent('app.lifecycle')", "hostBridgeEvent('network.statusChanged')", "lastHostBridgeEvent('navigation.canGoBack')", "shellHarness.appStateListeners[0]?.('background')", 'shellHarness.networkListeners[0]?.({', "type: 'genarrative.mobile.historyState'", 'mobile host event failed for network.statusChanged', ]) { if (!shellAppTestSource.includes(snippet)) { throw new Error(`mobile shell ShellApp HostBridge event 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', ); }