移动壳 Android 包配置通过 blockedPermissions 移除 RECORD_AUDIO 移动壳检查脚本拒绝麦克风权限缺失拦截或被重新声明 宿主壳方案和共享决策记录移动端权限边界
448 lines
13 KiB
JavaScript
448 lines
13 KiB
JavaScript
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 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 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',
|
|
'占' + '位',
|
|
'模' + '拟',
|
|
'伪' + '造',
|
|
];
|
|
|
|
function extractStringArrayExport(source, exportName) {
|
|
const match = source.match(
|
|
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
|
|
);
|
|
if (!match) {
|
|
throw new Error(`unable to read ${exportName}`);
|
|
}
|
|
|
|
const entries = [...match[1].matchAll(/'([^']+)'/g)].map(
|
|
(entry) => entry[1],
|
|
);
|
|
if (match[1].includes('...HOST_BRIDGE_METHODS')) {
|
|
return [
|
|
...extractStringArrayExport(source, 'HOST_BRIDGE_METHODS'),
|
|
...entries,
|
|
];
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
assertNoDevScaffoldTerms(
|
|
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
|
|
);
|
|
|
|
const sharedCapabilities = extractStringArrayExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_CAPABILITIES',
|
|
);
|
|
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 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 iosMobileCapabilities) {
|
|
const switchCase = `case '${capability}':`;
|
|
if (
|
|
capability !== 'host.events' &&
|
|
capability !== 'app.lifecycle' &&
|
|
capability !== 'navigation.canGoBack' &&
|
|
!bridgeSource.includes(switchCase)
|
|
) {
|
|
throw new Error(
|
|
`mobile shell declares ${capability} but does not handle it in HostBridge`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (appConfig.scheme !== 'genarrative') {
|
|
throw new Error('mobile shell scheme must be genarrative');
|
|
}
|
|
|
|
if (appConfig.version !== '0.1.0') {
|
|
throw new Error('mobile shell app version must be 0.1.0');
|
|
}
|
|
|
|
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 (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.ios?.associatedDomains?.includes('applinks:app.genarrative.world')) {
|
|
throw new Error('mobile shell iOS associated domain is missing');
|
|
}
|
|
|
|
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?.blockedPermissions?.includes(
|
|
'android.permission.RECORD_AUDIO',
|
|
)
|
|
) {
|
|
throw new Error('mobile shell Android package must block microphone permission');
|
|
}
|
|
|
|
if (
|
|
appConfig.android?.permissions?.includes('android.permission.RECORD_AUDIO')
|
|
) {
|
|
throw new Error('mobile shell Android package must not request microphone');
|
|
}
|
|
|
|
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 androidFilter = appConfig.android?.intentFilters?.find((filter) =>
|
|
filter?.data?.some(
|
|
(entry) =>
|
|
entry?.scheme === 'https' &&
|
|
entry?.host === 'app.genarrative.world',
|
|
),
|
|
);
|
|
if (!androidFilter) {
|
|
throw new Error('mobile shell Android app link filter is missing');
|
|
}
|
|
|
|
for (const snippet of [
|
|
'Linking.getInitialURL()',
|
|
"Linking.addEventListener('url'",
|
|
'buildMobileShellUrlFromDeepLink',
|
|
'configureMobileHostBridgeNavigation',
|
|
'webViewRef.current?.reload()',
|
|
'AppState.addEventListener',
|
|
'app.lifecycle',
|
|
'network.statusChanged',
|
|
'subscribeMobileNetworkStatus',
|
|
'navigation.canGoBack',
|
|
'buildHostBridgeMessageScript',
|
|
'SafeAreaProvider',
|
|
'SafeAreaView',
|
|
'MOBILE_SHELL_SAFE_AREA_EDGES',
|
|
'resolveMobileShellBaseWebUrl',
|
|
]) {
|
|
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');
|
|
}
|
|
|
|
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 (Array.isArray(notificationsPlugin)) {
|
|
const pluginOptions = notificationsPlugin[1] ?? {};
|
|
if (pluginOptions.enableBackgroundRemoteNotifications !== false) {
|
|
throw new Error('mobile shell must not enable background remote notifications');
|
|
}
|
|
}
|
|
|
|
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',
|
|
]) {
|
|
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()');
|
|
}
|
|
|
|
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');
|
|
}
|