e64b73e9b4
移动壳启动URL仅允许生产主站和本机开发入口 补充移动壳URL和deep link测试,防止外域H5获得HostBridge 更新原生壳方案和共享决策记录
727 lines
22 KiB
JavaScript
727 lines
22 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 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) {
|
|
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 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 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 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 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`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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.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');
|
|
}
|
|
|
|
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?.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 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');
|
|
}
|
|
|
|
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',
|
|
]) {
|
|
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');
|
|
}
|