Files
Genarrative/apps/mobile-shell/scripts/check-expo-config.mjs
T
kdletters 01394ed238 门禁加固与 Windows 可跑性
- 新增 scripts/check-generated-bindings.mjs 并接入 lint 与 ai-game-creator-shell:check:rust:crates:重跑 export_bindings 前后逐字节比对共享契约绑定。
- check:native-shells 的 AGC 边界改为负向门禁:渲染源码不得出现 fetch(/XHR/EventSource/sendBeacon/WebSocket/plugin-http,客户端 capability 不得授予 http:default。
- check-pingora-gateway-smoke 在 Windows 上找 pingora-gateway.exe,并修掉「API 并发保护」先关客户端连接再读响应的竞态。
- mobile-shell 三个 smoke 改成固定命令串启动 npm(绕开 npm.cmd EINVAL 与 DEP0190),desktop-shell 的 stage-release-binary 改用 fileURLToPath 修掉 F:\F:\… 路径拼接。
- vitest.config.ts 白名单补回现役根用例;三份 nginx 模板的 SPA allowlist 补回 /components 与 /design-system。
2026-09-28 14:15:50 +08:00

474 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
const appConfigPath = new URL('../app.json', import.meta.url);
const packagePath = new URL('../package.json', import.meta.url);
const shellRequire = createRequire(packagePath);
const configPluginsPackagePath = shellRequire.resolve(
'@expo/config-plugins/package.json',
);
const expoPrivacyInfoPluginPath = resolve(
dirname(configPluginsPackagePath),
'build/ios/PrivacyInfo.js',
);
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';
// Node 18.20+/20+/24 在 Windows 上不允许不带 shell 直接执行 .cmd(npm.cmd → EINVAL),
// 因此这里用一条固定命令串交给 shell;参数都是字面量、不含空格或中文,不存在重新解析歧义。
// 不用「shell: true + args 数组」是为了避开 Node 的 DEP0190(args 在 shell 模式下不会被转义)。
const result = spawnSync(
`${npmCommand} exec expo config -- --type public --json`,
{
cwd: shellRoot,
encoding: 'utf8',
shell: true,
},
);
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');