import fs from 'node:fs'; import { PNG } from 'pngjs'; const appConfigPath = new URL('../app.json', import.meta.url); const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; const appPath = new URL('../App.tsx', import.meta.url); const appSource = fs.readFileSync(appPath, 'utf8'); const bridgePath = new URL('../src/mobileHostBridge.ts', import.meta.url); const bridgeSource = fs.readFileSync(bridgePath, 'utf8'); const mobileShellUrlPath = new URL('../src/mobileShellUrl.ts', import.meta.url); const mobileShellUrlSource = fs.readFileSync(mobileShellUrlPath, 'utf8'); const mobileShellRuntimePath = new URL('../src/mobileShellRuntime.ts', import.meta.url); const mobileShellRuntimeSource = fs.readFileSync(mobileShellRuntimePath, '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 iconPath = new URL('../assets/icon.png', import.meta.url); const icon = PNG.sync.read(fs.readFileSync(iconPath)); const brandBackgroundColor = '#fffdf9'; const productionSourceRoots = [ new URL('../App.tsx', import.meta.url), new URL('../app.json', import.meta.url), new URL('../package.json', import.meta.url), new URL('../src/', import.meta.url), ]; const productionFileExtensions = new Set(['.json', '.mjs', '.ts', '.tsx']); const devScaffoldTerms = [ 'mo' + 'ck', 'fa' + 'ke', 'place' + 'holder', 'st' + 'ub', 'TO' + 'DO', 'FIX' + 'ME', '占' + '位', '模' + '拟', '伪' + '造', ]; const blockedMobileChannelDependencies = [ '@react-native-firebase/analytics', '@react-native-firebase/app', '@segment/analytics-react-native', '@sentry/react-native', 'amplitude-react-native', 'expo-application', 'expo-updates', 'posthog-react-native', 'react-native-code-push', ]; const blockedMobileChannelSnippets = [ '@react-native-firebase/analytics', '@react-native-firebase/app', '@segment/analytics-react-native', '@sentry/react-native', 'Amplitude.getInstance', 'Analytics.screen', 'Analytics.track', 'CodePush.sync', 'PostHogProvider', 'Sentry.init', 'Updates.checkForUpdateAsync', 'Updates.fetchUpdateAsync', 'Updates.reloadAsync', 'analytics().logEvent', 'amplitude.init', 'codePush(', 'posthog.capture', 'posthog.init', ]; const blockedScheduledNotificationSnippets = [ 'SchedulableTriggerInputTypes', 'cancelScheduledNotificationAsync', 'getAllScheduledNotificationsAsync', 'getNextTriggerDateAsync', 'seconds:', 'repeats:', "type: 'calendar'", "type: 'daily'", "type: 'date'", "type: 'monthly'", "type: 'timeInterval'", "type: 'weekly'", "type: 'yearly'", 'DateTriggerInput', 'TimeIntervalTriggerInput', ]; const blockedAndroidPermissions = [ 'android.permission.MANAGE_EXTERNAL_STORAGE', 'android.permission.READ_EXTERNAL_STORAGE', 'android.permission.RECORD_AUDIO', 'android.permission.RECEIVE_BOOT_COMPLETED', 'android.permission.REQUEST_INSTALL_PACKAGES', 'android.permission.SCHEDULE_EXACT_ALARM', 'android.permission.USE_EXACT_ALARM', 'android.permission.WRITE_EXTERNAL_STORAGE', ]; function extractStringArrayExport(source, exportName, seen = new Set()) { if (seen.has(exportName)) { throw new Error(`cyclic string array export ${exportName}`); } const match = source.match( new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`), ); 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 extractMobileBridgeHandledMethods(source) { const match = source.match( /async function handleRequest\(request: HostBridgeRequest\)[^{]*\{[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/, ); if (!match) { throw new Error('unable to read mobile shell HostBridge handler methods'); } return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]); } function assertNoBlockedMobileChannelDependencies(packageJson, packageLabel) { const dependencySections = [ 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', ]; for (const dependency of blockedMobileChannelDependencies) { for (const section of dependencySections) { if (packageJson[section]?.[dependency]) { throw new Error( `${packageLabel} must not depend on ${dependency} before the real mobile channel contract exists`, ); } } } } function assertNoBlockedMobileChannelSnippets() { const sources = [ ['app.json', JSON.stringify(appConfig)], ['App.tsx', appSource], ['mobileHostBridge.ts', bridgeSource], ['mobileShellUrl.ts', mobileShellUrlSource], ['mobileShellRuntime.ts', mobileShellRuntimeSource], ]; 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 collectProductionSourceFiles(entry) { const stats = fs.statSync(entry); if (stats.isDirectory()) { const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`); return fs .readdirSync(entry, { withFileTypes: true }) .flatMap((child) => collectProductionSourceFiles( new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory), ), ); } const path = entry.pathname; const extension = path.match(/\.[^.]+$/)?.[0] ?? ''; if (!productionFileExtensions.has(extension)) { return []; } if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) { return []; } return [entry]; } function assertNoDevScaffoldTerms(files) { for (const file of files) { const source = fs.readFileSync(file, 'utf8'); const lineStarts = [0]; for (let index = 0; index < source.length; index += 1) { if (source[index] === '\n') { lineStarts.push(index + 1); } } for (const term of devScaffoldTerms) { const matchIndex = source.toLowerCase().indexOf(term.toLowerCase()); if (matchIndex === -1) { continue; } const line = lineStarts.filter((start) => start <= matchIndex).length; throw new Error( `mobile shell production source must not include ${term}: ${file.pathname}:${line}`, ); } } } function countAlphaPixels(png) { let transparent = 0; let translucent = 0; let opaque = 0; for (let index = 3; index < png.data.length; index += 4) { const alpha = png.data[index]; if (alpha === 0) { transparent += 1; } else if (alpha === 255) { opaque += 1; } else { translucent += 1; } } return { transparent, translucent, opaque }; } function assertSameList(actual, expected, label) { if ( actual.length !== expected.length || actual.some((value, index) => value !== expected[index]) ) { throw new Error( `${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`, ); } } assertNoDevScaffoldTerms( productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)), ); assertNoBlockedMobileChannelDependencies(packageConfig, 'mobile shell package'); assertNoBlockedMobileChannelDependencies(rootPackageConfig, 'root H5 package'); assertNoBlockedMobileChannelSnippets(); const sharedCapabilities = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_CAPABILITIES', ); const sharedMethods = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_METHODS', ); const handledMobileMethods = extractMobileBridgeHandledMethods(bridgeSource); const mobileCapabilities = extractStringArrayExport( bridgeSource, 'MOBILE_HOST_CAPABILITIES', ); const iosMobileCapabilities = extractStringArrayExport( bridgeSource, 'IOS_MOBILE_HOST_CAPABILITIES', ); const mobileCapabilitySet = new Set(mobileCapabilities); const iosMobileCapabilitySet = new Set(iosMobileCapabilities); const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request']; 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 mobileShellHostVersion = extractStringConstExport( mobileShellRuntimeSource, 'MOBILE_SHELL_HOST_VERSION', ); if (mobileShellHostVersion !== appConfig.version) { throw new Error('mobile shell HostBridge host version must match app.json version'); } if (appConfig.orientation !== 'default') { throw new Error('mobile shell must allow device orientation for landscape-capable H5 games'); } if (packageConfig.version !== appConfig.version) { throw new Error('mobile shell package version must match app.json version'); } if (appConfig.icon !== './assets/icon.png') { throw new Error('mobile shell must use the real brand icon asset'); } if (appConfig.userInterfaceStyle !== 'automatic') { throw new Error('mobile shell must follow the native system appearance setting'); } assertSameList( appConfig.assetBundlePatterns ?? [], ['**/*'], 'mobile shell asset bundle patterns', ); if (icon.width < 512 || icon.height < 512) { throw new Error('mobile shell icon must be a production-size brand asset'); } const iconAlpha = countAlphaPixels(icon); if (iconAlpha.transparent === 0 || iconAlpha.opaque === 0) { throw new Error('mobile shell adaptive icon foreground must use the real transparent brand asset'); } if ( appConfig.splash?.image !== './assets/icon.png' || appConfig.splash?.resizeMode !== 'contain' || appConfig.splash?.backgroundColor !== brandBackgroundColor ) { throw new Error('mobile shell splash must use the real brand icon and brand background'); } if (appConfig.updates?.enabled !== false) { throw new Error('mobile shell OTA updates must stay disabled until a real release channel exists'); } if (Object.keys(appConfig.updates ?? {}).some((key) => key !== 'enabled')) { throw new Error('mobile shell must not configure OTA update metadata without a real release channel'); } if ('runtimeVersion' in appConfig) { throw new Error('mobile shell must not configure runtimeVersion without a real OTA release channel'); } if ('releaseChannel' in appConfig || 'channel' in appConfig) { throw new Error('mobile shell must not configure an app release channel without a real release process'); } assertSameList( appConfig.ios?.associatedDomains ?? [], ['applinks:app.genarrative.world'], 'mobile shell iOS associated domains', ); if (appConfig.ios?.bundleIdentifier !== 'world.genarrative.mobile') { throw new Error('mobile shell iOS bundle identifier must be world.genarrative.mobile'); } if (appConfig.ios?.buildNumber !== '1') { throw new Error('mobile shell iOS build number must start at 1'); } if (appConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption !== false) { throw new Error('mobile shell iOS encryption export flag must be explicit'); } if ( appConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads !== false ) { throw new Error('mobile shell iOS ATS must not allow arbitrary network loads'); } if (appConfig.android?.package !== 'world.genarrative.mobile') { throw new Error('mobile shell Android package must be world.genarrative.mobile'); } if (appConfig.android?.versionCode !== 1) { throw new Error('mobile shell Android versionCode must start at 1'); } if (appConfig.android?.usesCleartextTraffic !== false) { throw new Error('mobile shell Android package must disable cleartext traffic'); } if (appConfig.android?.allowBackup !== false) { throw new Error('mobile shell Android package must disable app data backup'); } if (appConfig.android?.softwareKeyboardLayoutMode !== 'resize') { throw new Error('mobile shell Android keyboard layout must resize the WebView'); } for (const permission of blockedAndroidPermissions) { if (!appConfig.android?.blockedPermissions?.includes(permission)) { throw new Error(`mobile shell Android package must block ${permission}`); } } for (const permission of blockedAndroidPermissions) { if (appConfig.android?.permissions?.includes(permission)) { throw new Error(`mobile shell Android package must not request ${permission}`); } } if (appConfig.android?.permissions && appConfig.android.permissions.length > 0) { throw new Error('mobile shell Android package must not request explicit permissions by default'); } if ( appConfig.android?.adaptiveIcon?.foregroundImage !== './assets/icon.png' || appConfig.android?.adaptiveIcon?.backgroundColor !== brandBackgroundColor ) { throw new Error('mobile shell Android adaptive icon must use the real brand icon and brand background'); } const androidFilters = appConfig.android?.intentFilters ?? []; if (androidFilters.length !== 1) { throw new Error('mobile shell Android app link filter must be the only intent filter'); } const [androidFilter] = androidFilters; if (androidFilter.action !== 'VIEW' || androidFilter.autoVerify !== true) { throw new Error('mobile shell Android app link filter must be a verified VIEW filter'); } assertSameList( androidFilter.category ?? [], ['BROWSABLE', 'DEFAULT'], 'mobile shell Android app link categories', ); const androidFilterData = androidFilter.data ?? []; if ( androidFilterData.length !== 1 || androidFilterData[0]?.scheme !== 'https' || androidFilterData[0]?.host !== 'app.genarrative.world' || Object.keys(androidFilterData[0] ?? {}).some( (key) => key !== 'scheme' && key !== 'host', ) ) { throw new Error('mobile shell Android app link data must only bind https://app.genarrative.world'); } if (appConfig.extra?.genarrativeHostBridgeVersion !== 1) { throw new Error('mobile shell extra HostBridge version must be 1'); } for (const snippet of [ 'Linking.getInitialURL()', "Linking.addEventListener('url'", 'buildMobileShellUrlFromDeepLink', 'configureMobileHostBridgeNavigation', 'shouldAcceptMobileShellHostBridgeMessage', 'webViewRef.current?.reload()', 'AppState.addEventListener', 'app.lifecycle', 'network.statusChanged', 'subscribeMobileNetworkStatus', 'navigation.canGoBack', 'buildHostBridgeMessageScript', 'origin: window.location.origin', 'source: window', 'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', 'SafeAreaProvider', 'SafeAreaView', 'MOBILE_SHELL_SAFE_AREA_EDGES', 'resolveMobileShellBaseWebUrl', 'javaScriptCanOpenWindowsAutomatically={false}', 'mixedContentMode="never"', 'allowFileAccess={false}', 'allowFileAccessFromFileURLs={false}', 'allowUniversalAccessFromFileURLs={false}', 'allowsFullscreenVideo', 'allowsInlineMediaPlayback', 'mediaPlaybackRequiresUserAction', 'thirdPartyCookiesEnabled={false}', 'sharedCookiesEnabled={false}', 'webviewDebuggingEnabled={false}', 'injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT}', 'onFileDownload={() => undefined}', 'setSupportMultipleWindows={false}', ]) { if (!appSource.includes(snippet)) { throw new Error(`mobile shell App missing ${snippet}`); } } if (appSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||')) { throw new Error('mobile shell App must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL'); } if ( !mobileShellUrlSource.includes( "DEFAULT_MOBILE_SHELL_WEB_URL = 'https://app.genarrative.world/'", ) ) { throw new Error('mobile shell default H5 URL must point to the production web app'); } for (const snippet of [ "ALLOWED_PRODUCTION_WEB_ORIGIN = 'https://app.genarrative.world'", 'LOCAL_DEVELOPMENT_WEB_HOSTS', "'127.0.0.1'", "'localhost'", "'[::1]'", 'isAllowedMobileShellBaseUrl', ]) { if (!mobileShellUrlSource.includes(snippet)) { throw new Error(`mobile shell H5 URL allowlist missing ${snippet}`); } } if (appSource.includes('127.0.0.1:3000')) { throw new Error('mobile shell App must not hard-code localhost as the default H5 URL'); } for (const dependency of [ 'expo-file-system', 'expo-document-picker', 'expo-image-picker', 'expo-network', 'expo-notifications', 'expo-sharing', 'react-native-safe-area-context', ]) { if (!packageConfig.dependencies?.[dependency]) { throw new Error(`mobile shell package missing ${dependency}`); } } const imagePickerPlugin = appConfig.plugins?.find((plugin) => Array.isArray(plugin) ? plugin[0] === 'expo-image-picker' : plugin === 'expo-image-picker', ); if (!imagePickerPlugin) { throw new Error('mobile shell image picker plugin is missing'); } if (Array.isArray(imagePickerPlugin)) { const pluginOptions = imagePickerPlugin[1] ?? {}; if (typeof pluginOptions.photosPermission !== 'string') { throw new Error('mobile shell image picker photo permission text is missing'); } if (typeof pluginOptions.cameraPermission !== 'string') { throw new Error('mobile shell image picker camera permission text is missing'); } if (pluginOptions.microphonePermission !== false) { throw new Error('mobile shell image picker must not request microphone'); } } const notificationsPlugin = appConfig.plugins?.find((plugin) => Array.isArray(plugin) ? plugin[0] === 'expo-notifications' : plugin === 'expo-notifications', ); if (!notificationsPlugin) { throw new Error('mobile shell notifications plugin is missing'); } if ( appConfig.plugins?.some((plugin) => Array.isArray(plugin) ? plugin[0] === 'expo-updates' : plugin === 'expo-updates', ) ) { throw new Error('mobile shell must not install expo-updates without a real release channel'); } if (Array.isArray(notificationsPlugin)) { const pluginOptions = notificationsPlugin[1] ?? {}; if (pluginOptions.enableBackgroundRemoteNotifications !== false) { throw new Error('mobile shell must not enable background remote notifications'); } } for (const forbiddenNotificationSnippet of [ 'getExpoPushTokenAsync', 'getDevicePushTokenAsync', 'addPushTokenListener', 'addNotificationResponseReceivedListener', ...blockedScheduledNotificationSnippets, ]) { if (bridgeSource.includes(forbiddenNotificationSnippet)) { throw new Error( `mobile shell must not register remote or background notification flow: ${forbiddenNotificationSnippet}`, ); } } if ( !/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test( bridgeSource, ) ) { throw new Error('mobile shell local notifications must stay immediate and channel-only'); } for (const snippet of [ 'file.exportText', 'file.importText', 'file.exportImage', 'file.importImage', 'file.captureImage', 'file.importAudio', 'file.exportAudio', 'clipboard.readText', 'notification.showLocal', 'network.status', 'app.reloadWebView', 'getMobileNetworkStatus', 'Notifications.scheduleNotificationAsync', 'Notifications.setNotificationChannelAsync', 'Notifications.getPermissionsAsync', 'Notifications.requestPermissionsAsync', 'Sharing.shareAsync', 'DocumentPicker.getDocumentAsync', 'Clipboard.getStringAsync', 'ImagePicker.launchImageLibraryAsync', 'ImagePicker.launchCameraAsync', 'ImagePicker.requestMediaLibraryPermissionsAsync', 'ImagePicker.requestCameraPermissionsAsync', 'File(asset.uri)', 'file.base64()', 'normalizeHostBridgeExportFileName', 'normalizeHostBridgeClipboardText', 'base64Data', 'isHostBridgeMethod', 'normalizeHostBridgeRequestId', 'HOST_BRIDGE_RESPONSE_CACHE_MAX', 'completedHostBridgeResponses', 'inFlightHostBridgeResponses', 'resolveMobileHostBridgeResponse', 'rememberHostBridgeResponse', ]) { if (!bridgeSource.includes(snippet)) { throw new Error(`mobile shell HostBridge missing ${snippet}`); } } const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES"; if (appSource.includes(capabilityQuerySnippet)) { throw new Error('mobile shell URL must resolve platform-aware capabilities'); } if (!appSource.includes('capabilities: resolveMobileHostCapabilities()')) { throw new Error('mobile shell URL must use resolveMobileHostCapabilities()'); } if (!appSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { throw new Error('mobile shell URL must use the shared mobile shell host version'); } if (!bridgeSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) { throw new Error('mobile shell runtime response must use the shared mobile shell host version'); } if (appSource.includes("hostVersion: '0.1.0'") || bridgeSource.includes("hostVersion: '0.1.0'")) { throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source'); } for (const capability of sdkBackedCapabilities) { if (appSource.includes(`'${capability}'`) || appSource.includes(`"${capability}"`)) { throw new Error( `mobile shell URL must not advertise ${capability} without a real SDK/channel flow`, ); } } for (const capability of [ 'host.getRuntime', 'appearance.getColorScheme', 'share.open', 'share.setTarget', 'app.lifecycle', 'network.status', 'network.statusChanged', 'navigation.openNativePage', 'navigation.canGoBack', 'app.reloadWebView', 'app.openExternalUrl', 'clipboard.writeText', 'clipboard.readText', 'file.exportText', 'file.importText', 'file.exportImage', 'file.importImage', 'file.captureImage', 'file.importAudio', 'file.exportAudio', 'haptics.impact', 'notification.showLocal', ]) { if (!mobileCapabilitySet.has(capability)) { throw new Error(`mobile shell capabilities missing ${capability}`); } } if (!iosMobileCapabilitySet.has('app.setBadgeCount')) { throw new Error('iOS mobile shell capabilities missing app.setBadgeCount'); } if (mobileCapabilitySet.has('app.setBadgeCount')) { throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount'); } if (!bridgeSource.includes('Appearance.getColorScheme()')) { throw new Error('mobile shell HostBridge must read the native color scheme'); }