Files
Genarrative/apps/mobile-shell/scripts/check-expo-config.mjs
T
kdletters e8bcd3ba88 收口移动壳系统深链声明
移动壳源配置门禁锁定唯一 iOS associated domain

Expo public config 烟测锁定唯一 Android App Link 过滤器

宿主壳方案文档补充深链接管范围约束

共享决策日志记录移动壳系统深链声明边界
2026-06-18 13:52:37 +08:00

259 lines
7.9 KiB
JavaScript

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 shellRoot = new URL('../', import.meta.url);
const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo;
const packageConfig = JSON.parse(fs.readFileSync(packagePath, '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 findPlugin(name) {
return expoConfig.plugins?.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === name : plugin === name,
);
}
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,
1,
'HostBridge version',
);
assertEqual(
expoConfig.ios?.bundleIdentifier,
'world.genarrative.mobile',
'iOS bundle identifier',
);
assertEqual(expoConfig.ios?.buildNumber, '1', 'iOS build number');
assertSameList(
expoConfig.ios?.associatedDomains,
['applinks:app.genarrative.world'],
'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.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',
);
assertSameList(expoConfig.android?.permissions ?? [], [], 'Android explicit permissions');
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',
);
assertIncludes(
expoConfig.android?.blockedPermissions,
'android.permission.RECORD_AUDIO',
'Android blocked permissions',
);
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 !== 'app.genarrative.world' ||
Object.keys(appLinkFilter.data[0] ?? {}).some(
(key) => key !== 'scheme' && key !== 'host',
)
) {
throw new Error('Expo config Android app link data must only bind https://app.genarrative.world');
}
const imagePickerPlugin = findPlugin('expo-image-picker');
if (!Array.isArray(imagePickerPlugin)) {
throw new Error('Expo config image picker plugin is missing options');
}
assertEqual(
typeof imagePickerPlugin[1]?.photosPermission,
'string',
'image picker photo permission text type',
);
assertEqual(
typeof imagePickerPlugin[1]?.cameraPermission,
'string',
'image picker camera permission text type',
);
assertEqual(
imagePickerPlugin[1]?.microphonePermission,
false,
'image picker microphone permission',
);
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');