ff438e8897
移动壳 WebView 注入下载阻断脚本并丢弃 iOS 文件下载事件。 移动壳 Android 包配置阻断外部存储和请求安装包权限。 移动壳配置检查和 Expo public config smoke 锁定下载边界。 新增移动壳 WebView 下载阻断策略测试。 更新宿主壳方案和共享决策日志记录移动端下载只能走 HostBridge 导出。
646 lines
20 KiB
JavaScript
646 lines
20 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 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 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() {
|
|
const dependencySections = [
|
|
'dependencies',
|
|
'devDependencies',
|
|
'optionalDependencies',
|
|
'peerDependencies',
|
|
];
|
|
|
|
for (const dependency of blockedMobileChannelDependencies) {
|
|
for (const section of dependencySections) {
|
|
if (packageConfig[section]?.[dependency]) {
|
|
throw new Error(
|
|
`mobile shell must not depend on ${dependency} before the real 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 };
|
|
}
|
|
|
|
assertNoDevScaffoldTerms(
|
|
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
|
|
);
|
|
assertNoBlockedMobileChannelDependencies();
|
|
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.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 (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?.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',
|
|
'shouldAcceptMobileShellHostBridgeMessage',
|
|
'webViewRef.current?.reload()',
|
|
'AppState.addEventListener',
|
|
'app.lifecycle',
|
|
'network.statusChanged',
|
|
'subscribeMobileNetworkStatus',
|
|
'navigation.canGoBack',
|
|
'buildHostBridgeMessageScript',
|
|
'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');
|
|
}
|
|
|
|
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',
|
|
]) {
|
|
if (bridgeSource.includes(forbiddenNotificationSnippet)) {
|
|
throw new Error(
|
|
`mobile shell must not register remote or background notification flow: ${forbiddenNotificationSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|