import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; const appConfigPath = new URL('../app.json', import.meta.url); const packagePath = new URL('../package.json', import.meta.url); const configPluginsPackagePath = new URL( '../../../node_modules/@expo/config-plugins/package.json', import.meta.url, ); const expoPrivacyInfoPluginPath = new URL( '../../../node_modules/@expo/config-plugins/build/ios/PrivacyInfo.js', import.meta.url, ); const sharedContractPath = new URL( '../../../packages/shared/src/contracts/hostBridge.ts', import.meta.url, ); const shellRoot = new URL('../', import.meta.url); const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo; const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const configPluginsPackageConfig = JSON.parse( fs.readFileSync(configPluginsPackagePath, 'utf8'), ); const expoPrivacyInfoPluginSource = fs.readFileSync( expoPrivacyInfoPluginPath, 'utf8', ); const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8'); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const result = spawnSync( npmCommand, ['exec', 'expo', 'config', '--', '--type', 'public', '--json'], { cwd: shellRoot, encoding: 'utf8', }, ); if (result.error) { throw new Error(`failed to start Expo config smoke: ${result.error.message}`); } if (result.signal) { throw new Error(`Expo config smoke was terminated by signal ${result.signal}`); } if ((result.status ?? 0) !== 0) { process.stdout.write(result.stdout ?? ''); process.stderr.write(result.stderr ?? ''); process.exit(result.status ?? 1); } function parseExpoConfigOutput(output) { const trimmed = output.trim(); if (trimmed.startsWith('{')) { return JSON.parse(trimmed); } const jsonStart = output.indexOf('{'); if (jsonStart === -1) { throw new Error('Expo config smoke did not print JSON output'); } return JSON.parse(output.slice(jsonStart)); } const expoConfig = parseExpoConfigOutput(result.stdout ?? ''); function assertEqual(actual, expected, label) { if (actual !== expected) { throw new Error( `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, ); } } function assertIncludes(list, expected, label) { if (!Array.isArray(list) || !list.includes(expected)) { throw new Error(`Expo config ${label} missing ${expected}`); } } function assertSameList(actual, expected, label) { if ( !Array.isArray(actual) || actual.length !== expected.length || actual.some((value, index) => value !== expected[index]) ) { throw new Error( `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, ); } } function assertSameSet(actual, expected, label) { if ( !Array.isArray(actual) || actual.length !== expected.length || expected.some((value) => !actual.includes(value)) ) { throw new Error( `Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`, ); } } function assertPrivacyManifest(privacyManifests, label) { if (!privacyManifests) { throw new Error(`${label} missing iOS privacy manifest`); } assertEqual( privacyManifests.NSPrivacyTracking, false, `${label} privacy tracking flag`, ); assertSameList( privacyManifests.NSPrivacyCollectedDataTypes ?? [], [], `${label} collected data types`, ); assertSameList( privacyManifests.NSPrivacyTrackingDomains ?? [], [], `${label} tracking domains`, ); const expectedAccessedApiTypes = new Map([ [ 'NSPrivacyAccessedAPICategoryFileTimestamp', ['0A2A.1', '3B52.1', 'C617.1'], ], ['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']], ['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']], ['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']], ]); const accessedApiTypes = privacyManifests.NSPrivacyAccessedAPITypes ?? []; if (accessedApiTypes.length !== expectedAccessedApiTypes.size) { throw new Error(`${label} accessed API type count drifted`); } for (const [apiType, reasons] of expectedAccessedApiTypes) { const entry = accessedApiTypes.find( (candidate) => candidate.NSPrivacyAccessedAPIType === apiType, ); if (!entry) { throw new Error(`${label} missing ${apiType}`); } assertSameList( entry.NSPrivacyAccessedAPITypeReasons ?? [], reasons, `${label} reasons for ${apiType}`, ); } } function findPlugin(name) { return expoConfig.plugins?.find((plugin) => Array.isArray(plugin) ? plugin[0] === name : plugin === name, ); } 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 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]; } const sharedHostBridgeVersion = extractNumberConstExport( sharedContractSource, 'HOST_BRIDGE_VERSION', ); const sharedPublicWebOrigin = extractStringConstExport( sharedContractSource, 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', ); 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}`; assertEqual(expoConfig.name, 'Genarrative', 'name'); assertEqual(expoConfig.slug, 'genarrative-mobile-shell', 'slug'); assertEqual(expoConfig.scheme, 'genarrative', 'scheme'); assertEqual(expoConfig.version, packageConfig.version, 'version'); assertEqual(expoConfig.version, appConfig.version, 'app version'); assertEqual(expoConfig.orientation, 'default', 'orientation'); assertEqual(expoConfig.userInterfaceStyle, 'automatic', 'userInterfaceStyle'); assertSameList(expoConfig.assetBundlePatterns, ['**/*'], 'asset bundle patterns'); assertEqual(expoConfig.icon, './assets/icon.png', 'icon'); assertEqual(expoConfig.splash?.image, './assets/icon.png', 'splash image'); assertEqual(expoConfig.splash?.resizeMode, 'contain', 'splash resize mode'); assertEqual(expoConfig.splash?.backgroundColor, '#fffdf9', 'splash background'); assertEqual(expoConfig.updates?.enabled, false, 'OTA updates enabled flag'); if (Object.keys(expoConfig.updates ?? {}).some((key) => key !== 'enabled')) { throw new Error('Expo config OTA update metadata must stay empty'); } if ('runtimeVersion' in expoConfig) { throw new Error('Expo config runtimeVersion must not be set without a real OTA release channel'); } if ('releaseChannel' in expoConfig || 'channel' in expoConfig) { throw new Error('Expo config release channel must not be set without a real release process'); } assertEqual( expoConfig.extra?.genarrativeHostBridgeVersion, sharedHostBridgeVersion, 'HostBridge version', ); assertEqual( expoConfig.ios?.bundleIdentifier, 'world.genarrative.mobile', 'iOS bundle identifier', ); assertEqual(expoConfig.ios?.buildNumber, '1', 'iOS build number'); assertSameList( expoConfig.ios?.associatedDomains, [sharedPublicWebAssociatedDomain], 'iOS associated domains', ); assertEqual( expoConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption, false, 'iOS encryption export flag', ); assertEqual( expoConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads, false, 'iOS ATS arbitrary loads', ); assertEqual( expoConfig.ios?.infoPlist?.NSMicrophoneUsageDescription, '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。', 'iOS microphone permission text', ); assertPrivacyManifest(appConfig.ios?.privacyManifests, 'Expo source config'); const configPluginsMajor = Number( String(configPluginsPackageConfig.version).split('.')[0], ); if (!Number.isFinite(configPluginsMajor) || configPluginsMajor < 50) { throw new Error('Expo config plugins must support built-in iOS privacy manifests'); } for (const snippet of [ 'function withPrivacyInfo(config)', 'config.ios?.privacyManifests', 'setPrivacyInfo(projectConfig, privacyManifests)', 'PrivacyInfo.xcprivacy', 'mergePrivacyInfo(existing, privacyManifests)', ]) { if (!expoPrivacyInfoPluginSource.includes(snippet)) { throw new Error(`Expo config plugins PrivacyInfo support missing ${snippet}`); } } assertEqual( expoConfig.android?.package, 'world.genarrative.mobile', 'Android package', ); assertEqual(expoConfig.android?.versionCode, 1, 'Android versionCode'); assertEqual( expoConfig.android?.usesCleartextTraffic, false, 'Android cleartext traffic', ); assertEqual(expoConfig.android?.allowBackup, false, 'Android backup flag'); assertEqual( expoConfig.android?.softwareKeyboardLayoutMode, 'resize', 'Android software keyboard layout mode', ); assertSameSet( expoConfig.android?.permissions ?? [], [ 'android.permission.CAMERA', 'android.permission.POST_NOTIFICATIONS', 'android.permission.RECORD_AUDIO', ], 'Android explicit permissions', ); if (appConfig.android?.permissions?.includes('android.permission.CAMERA')) { throw new Error('Expo source config must not hard-code Android CAMERA permission'); } assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.MANAGE_EXTERNAL_STORAGE', 'Android external download blocked permissions', ); assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.READ_EXTERNAL_STORAGE', 'Android external download blocked permissions', ); if (expoConfig.android?.blockedPermissions?.includes('android.permission.RECORD_AUDIO')) { throw new Error('Expo config Android permissions must not block RECORD_AUDIO needed by same-origin H5 microphone gameplay'); } if (expoConfig.android?.blockedPermissions?.includes('android.permission.POST_NOTIFICATIONS')) { throw new Error('Expo config Android permissions must not block POST_NOTIFICATIONS needed by local notification.showLocal delivery'); } assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.REQUEST_INSTALL_PACKAGES', 'Android external install blocked permissions', ); assertIncludes( expoConfig.android?.blockedPermissions, 'android.permission.WRITE_EXTERNAL_STORAGE', 'Android external download blocked permissions', ); for (const permission of [ 'android.permission.RECEIVE_BOOT_COMPLETED', 'android.permission.SCHEDULE_EXACT_ALARM', 'android.permission.USE_EXACT_ALARM', ]) { assertIncludes( expoConfig.android?.blockedPermissions, permission, 'Android background notification blocked permissions', ); } assertEqual( expoConfig.android?.adaptiveIcon?.foregroundImage, './assets/icon.png', 'Android adaptive icon foreground', ); assertEqual( expoConfig.android?.adaptiveIcon?.backgroundColor, '#fffdf9', 'Android adaptive icon background', ); const appLinkFilters = expoConfig.android?.intentFilters ?? []; if (appLinkFilters.length !== 1) { throw new Error('Expo config Android app link filter must be the only intent filter'); } const [appLinkFilter] = appLinkFilters; assertEqual(appLinkFilter.action, 'VIEW', 'Android app link action'); assertEqual(appLinkFilter.autoVerify, true, 'Android app link autoVerify'); assertSameList( appLinkFilter.category, ['BROWSABLE', 'DEFAULT'], 'Android app link categories', ); if ( !Array.isArray(appLinkFilter.data) || appLinkFilter.data.length !== 1 || appLinkFilter.data[0]?.scheme !== 'https' || appLinkFilter.data[0]?.host !== sharedPublicWebHost || Object.keys(appLinkFilter.data[0] ?? {}).some( (key) => key !== 'scheme' && key !== 'host', ) ) { throw new Error( `Expo config Android app link data must only bind ${sharedPublicWebOrigin}`, ); } const imagePickerPlugin = findPlugin('expo-image-picker'); if (!Array.isArray(imagePickerPlugin)) { throw new Error('Expo config image picker plugin is missing options'); } assertEqual( imagePickerPlugin[1]?.photosPermission, '允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。', 'image picker photo permission text', ); assertEqual( imagePickerPlugin[1]?.cameraPermission, '允许 Genarrative 使用相机拍摄创作素材和参考图。', 'image picker camera permission text', ); assertEqual( imagePickerPlugin[1]?.microphonePermission, '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。', 'image picker microphone permission', ); const cameraPlugin = findPlugin('expo-camera'); if (!Array.isArray(cameraPlugin)) { throw new Error('Expo config camera plugin is missing options'); } assertEqual( cameraPlugin[1]?.cameraPermission, '允许 Genarrative 使用相机扫描二维码。', 'camera permission text', ); assertEqual( cameraPlugin[1]?.microphonePermission, '允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。', 'camera microphone permission', ); assertEqual( cameraPlugin[1]?.recordAudioAndroid, true, 'camera Android record audio flag', ); const notificationsPlugin = findPlugin('expo-notifications'); if (!Array.isArray(notificationsPlugin)) { throw new Error('Expo config notifications plugin is missing options'); } assertEqual( notificationsPlugin[1]?.enableBackgroundRemoteNotifications, false, 'background remote notifications', ); if (findPlugin('expo-updates')) { throw new Error('Expo config must not include expo-updates without a real release channel'); } console.log('[mobile-shell:expo-config] OK');