9eee997731
移动壳 HostBridge 协议名和版本改为共享契约常量 移动壳配置检查锁定主动导航和 deep link 宿主上下文补写 桌面壳主窗口 capability 移除 Tauri core 默认权限 桌面壳配置检查拒绝 core 默认权限和插件权限外露 更新原生壳方案与共享决策记录
1178 lines
37 KiB
JavaScript
1178 lines
37 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 shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url);
|
|
const shellAppSource = fs.readFileSync(shellAppPath, 'utf8');
|
|
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
|
|
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
|
|
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
|
|
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
|
|
const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url);
|
|
const bridgeSourceFiles = fs
|
|
.readdirSync(bridgeDirPath, { withFileTypes: true })
|
|
.filter(
|
|
(entry) =>
|
|
entry.isFile() &&
|
|
entry.name.endsWith('.ts') &&
|
|
!entry.name.includes('.test.'),
|
|
)
|
|
.map((entry) => new URL(entry.name, bridgeDirPath))
|
|
.sort((left, right) => left.pathname.localeCompare(right.pathname));
|
|
const hostBridgeSource = bridgeSourceFiles
|
|
.map((file) => fs.readFileSync(file, 'utf8'))
|
|
.join('\n');
|
|
const urlPath = new URL('../src/shell/url.ts', import.meta.url);
|
|
const urlSource = fs.readFileSync(urlPath, 'utf8');
|
|
const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url);
|
|
const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8');
|
|
const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url);
|
|
const navigationSource = fs.readFileSync(navigationPath, 'utf8');
|
|
const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url);
|
|
const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8');
|
|
const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url);
|
|
const webViewHistorySource = fs.readFileSync(webViewHistoryPath, 'utf8');
|
|
const loadFailurePath = new URL('../src/shell/loadFailure.ts', import.meta.url);
|
|
const loadFailureSource = fs.readFileSync(loadFailurePath, 'utf8');
|
|
const runtimePath = new URL('../src/shell/runtime.ts', import.meta.url);
|
|
const runtimeSource = fs.readFileSync(runtimePath, '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 rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url);
|
|
const rootPackageLock = JSON.parse(fs.readFileSync(rootPackageLockPath, '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 blockedMobileChannelLockDependencies = blockedMobileChannelDependencies.filter(
|
|
(dependency) => dependency !== 'expo-application',
|
|
);
|
|
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, seen = new Set()) {
|
|
if (seen.has(exportName)) {
|
|
throw new Error(`cyclic string array export ${exportName}`);
|
|
}
|
|
|
|
const match = source.match(
|
|
new RegExp(`export const ${exportName}[^=]*= \\[([\\s\\S]*?)\\](?: as const)?;`),
|
|
);
|
|
if (!match) {
|
|
throw new Error(`unable to read ${exportName}`);
|
|
}
|
|
|
|
const nextSeen = new Set(seen);
|
|
nextSeen.add(exportName);
|
|
const entries = [];
|
|
for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) {
|
|
if (entry[1]) {
|
|
entries.push(...extractStringArrayExport(source, entry[1], nextSeen));
|
|
} else {
|
|
entries.push(entry[2]);
|
|
}
|
|
}
|
|
|
|
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 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 extractMobileBridgeHandledMethods(source) {
|
|
const match = source.match(
|
|
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
|
|
);
|
|
if (!match) {
|
|
throw new Error('unable to read mobile shell HostBridge handler methods');
|
|
}
|
|
|
|
return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[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 assertNoBlockedMobileChannelLockPackages() {
|
|
const packageNames = Object.keys(rootPackageLock.packages ?? {})
|
|
.filter((packagePath) => packagePath.startsWith('node_modules/'))
|
|
.map((packagePath) => packagePath.replace(/^node_modules\//, ''));
|
|
const dependencyNames = Object.keys(rootPackageLock.dependencies ?? {});
|
|
const lockedPackageNames = new Set([...packageNames, ...dependencyNames]);
|
|
|
|
for (const dependency of blockedMobileChannelLockDependencies) {
|
|
if (lockedPackageNames.has(dependency)) {
|
|
throw new Error(
|
|
`root package-lock must not resolve ${dependency} before the real mobile channel contract exists`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertNoBlockedMobileChannelSnippets() {
|
|
const sources = [
|
|
['app.json', JSON.stringify(appConfig)],
|
|
['App.tsx', appSource],
|
|
['ShellApp.tsx', shellAppSource],
|
|
['src/host-bridge', hostBridgeSource],
|
|
['url.ts', urlSource],
|
|
['runtime.ts', runtimeSource],
|
|
];
|
|
|
|
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 assertPackageScript(packageJson, packageLabel, scriptName, expected) {
|
|
const actual = packageJson.scripts?.[scriptName];
|
|
if (actual !== expected) {
|
|
throw new Error(
|
|
`${packageLabel} script ${scriptName} drifted: expected ${expected} but got ${actual}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertPackageDependencyVersion(
|
|
packageJson,
|
|
packageLabel,
|
|
section,
|
|
dependency,
|
|
expected,
|
|
) {
|
|
const actual = packageJson[section]?.[dependency];
|
|
if (actual !== expected) {
|
|
throw new Error(
|
|
`${packageLabel} ${section}.${dependency} drifted: expected ${expected} but got ${actual}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertPackageLockVersion(dependency, expected) {
|
|
const actual = rootPackageLock.packages?.[`node_modules/${dependency}`]?.version;
|
|
if (actual !== expected) {
|
|
throw new Error(
|
|
`root package-lock ${dependency} resolved version drifted: expected ${expected} but got ${actual}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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');
|
|
assertNoBlockedMobileChannelLockPackages();
|
|
assertNoBlockedMobileChannelSnippets();
|
|
|
|
for (const [scriptName, expected] of Object.entries({
|
|
dev: 'expo start',
|
|
android: 'expo run:android',
|
|
ios: 'expo run:ios',
|
|
test: 'vitest run -c vitest.config.ts',
|
|
'config:smoke': 'node scripts/check-expo-config.mjs',
|
|
'export:smoke': 'node scripts/check-expo-export.mjs',
|
|
typecheck: 'tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs',
|
|
})) {
|
|
assertPackageScript(packageConfig, 'mobile shell package', scriptName, expected);
|
|
}
|
|
|
|
for (const [scriptName, expected] of Object.entries({
|
|
'mobile-shell:dev': 'npm --prefix apps/mobile-shell run dev',
|
|
'mobile-shell:typecheck': 'npm --prefix apps/mobile-shell run typecheck',
|
|
'mobile-shell:test': 'npm --prefix apps/mobile-shell run test',
|
|
'mobile-shell:config': 'npm --prefix apps/mobile-shell run config:smoke',
|
|
'mobile-shell:export': 'npm --prefix apps/mobile-shell run export:smoke',
|
|
})) {
|
|
assertPackageScript(rootPackageConfig, 'root package', scriptName, expected);
|
|
}
|
|
|
|
for (const [dependency, expected] of Object.entries({
|
|
'@expo/metro-runtime': '^56.0.15',
|
|
expo: '^56.0.12',
|
|
'expo-clipboard': '^56.0.4',
|
|
'expo-document-picker': '^56.0.4',
|
|
'expo-file-system': '^56.0.8',
|
|
'expo-haptics': '^56.0.3',
|
|
'expo-image-picker': '^56.0.18',
|
|
'expo-linking': '^56.0.14',
|
|
'expo-network': '^56.0.5',
|
|
'expo-notifications': '^56.0.18',
|
|
'expo-sharing': '^56.0.18',
|
|
'expo-status-bar': '^56.0.4',
|
|
react: '^19.0.0',
|
|
'react-native': '^0.86.0',
|
|
'react-native-safe-area-context': '^5.8.0',
|
|
'react-native-webview': '^13.16.1',
|
|
})) {
|
|
assertPackageDependencyVersion(
|
|
packageConfig,
|
|
'mobile shell package',
|
|
'dependencies',
|
|
dependency,
|
|
expected,
|
|
);
|
|
assertPackageDependencyVersion(
|
|
rootPackageConfig,
|
|
'root package',
|
|
'dependencies',
|
|
dependency,
|
|
expected,
|
|
);
|
|
}
|
|
|
|
for (const [dependency, expected] of Object.entries({
|
|
'@expo/metro-runtime': '56.0.15',
|
|
expo: '56.0.12',
|
|
'expo-clipboard': '56.0.4',
|
|
'expo-document-picker': '56.0.4',
|
|
'expo-file-system': '56.0.8',
|
|
'expo-haptics': '56.0.3',
|
|
'expo-image-picker': '56.0.18',
|
|
'expo-linking': '56.0.14',
|
|
'expo-network': '56.0.5',
|
|
'expo-notifications': '56.0.18',
|
|
'expo-sharing': '56.0.18',
|
|
'expo-status-bar': '56.0.4',
|
|
react: '19.2.4',
|
|
'react-native': '0.86.0',
|
|
'react-native-safe-area-context': '5.8.0',
|
|
'react-native-webview': '13.16.1',
|
|
})) {
|
|
assertPackageLockVersion(dependency, expected);
|
|
}
|
|
|
|
for (const [dependency, expected] of Object.entries({
|
|
typescript: '~5.8.2',
|
|
vitest: '^0.34.6',
|
|
})) {
|
|
assertPackageDependencyVersion(
|
|
packageConfig,
|
|
'mobile shell package',
|
|
'devDependencies',
|
|
dependency,
|
|
expected,
|
|
);
|
|
assertPackageDependencyVersion(
|
|
rootPackageConfig,
|
|
'root package',
|
|
'devDependencies',
|
|
dependency,
|
|
expected,
|
|
);
|
|
}
|
|
|
|
for (const [dependency, expected] of Object.entries({
|
|
typescript: '5.8.3',
|
|
vitest: '0.34.6',
|
|
})) {
|
|
assertPackageLockVersion(dependency, expected);
|
|
}
|
|
|
|
const sharedCapabilities = extractStringArrayExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_CAPABILITIES',
|
|
);
|
|
const sharedMethods = extractStringArrayExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_METHODS',
|
|
);
|
|
const sharedHostBridgeProtocol = extractStringConstExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_PROTOCOL',
|
|
);
|
|
const sharedHostBridgeVersion = extractNumberConstExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_VERSION',
|
|
);
|
|
const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource);
|
|
const mobileCapabilities = extractStringArrayExport(
|
|
hostBridgeSource,
|
|
'MOBILE_HOST_CAPABILITIES',
|
|
);
|
|
const iosMobileCapabilities = extractStringArrayExport(
|
|
hostBridgeSource,
|
|
'IOS_MOBILE_HOST_CAPABILITIES',
|
|
);
|
|
const mobileCapabilitySet = new Set(mobileCapabilities);
|
|
const iosMobileCapabilitySet = new Set(iosMobileCapabilities);
|
|
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
|
|
const unknownHandledMobileMethods = handledMobileMethods.filter(
|
|
(method) => !sharedMethods.includes(method),
|
|
);
|
|
if (unknownHandledMobileMethods.length > 0) {
|
|
throw new Error(
|
|
`mobile shell handles unknown HostBridge methods: ${unknownHandledMobileMethods.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
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`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const missingMobileMethodHandlers = iosMobileCapabilities.filter(
|
|
(capability) =>
|
|
sharedMethods.includes(capability) &&
|
|
!handledMobileMethods.includes(capability),
|
|
);
|
|
if (missingMobileMethodHandlers.length > 0) {
|
|
throw new Error(
|
|
`mobile shell declares request capabilities without HostBridge handlers: ${missingMobileMethodHandlers.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
const undeclaredMobileMethodHandlers = handledMobileMethods.filter(
|
|
(method) =>
|
|
!iosMobileCapabilitySet.has(method) && !sdkBackedCapabilities.includes(method),
|
|
);
|
|
if (undeclaredMobileMethodHandlers.length > 0) {
|
|
throw new Error(
|
|
`mobile shell handles unadvertised HostBridge methods: ${undeclaredMobileMethodHandlers.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
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 mobileHostVersion = extractStringConstExport(
|
|
runtimeSource,
|
|
'MOBILE_SHELL_HOST_VERSION',
|
|
);
|
|
if (mobileHostVersion !== 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');
|
|
}
|
|
|
|
assertSameList(
|
|
appConfig.ios?.associatedDomains ?? [],
|
|
['applinks:app.genarrative.world'],
|
|
'mobile shell iOS associated domains',
|
|
);
|
|
|
|
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 androidFilters = appConfig.android?.intentFilters ?? [];
|
|
if (androidFilters.length !== 1) {
|
|
throw new Error('mobile shell Android app link filter must be the only intent filter');
|
|
}
|
|
|
|
const [androidFilter] = androidFilters;
|
|
if (androidFilter.action !== 'VIEW' || androidFilter.autoVerify !== true) {
|
|
throw new Error('mobile shell Android app link filter must be a verified VIEW filter');
|
|
}
|
|
|
|
assertSameList(
|
|
androidFilter.category ?? [],
|
|
['BROWSABLE', 'DEFAULT'],
|
|
'mobile shell Android app link categories',
|
|
);
|
|
|
|
const androidFilterData = androidFilter.data ?? [];
|
|
if (
|
|
androidFilterData.length !== 1 ||
|
|
androidFilterData[0]?.scheme !== 'https' ||
|
|
androidFilterData[0]?.host !== 'app.genarrative.world' ||
|
|
Object.keys(androidFilterData[0] ?? {}).some(
|
|
(key) => key !== 'scheme' && key !== 'host',
|
|
)
|
|
) {
|
|
throw new Error('mobile shell Android app link data must only bind https://app.genarrative.world');
|
|
}
|
|
|
|
if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) {
|
|
throw new Error('mobile shell extra HostBridge version must match shared HostBridge version');
|
|
}
|
|
|
|
for (const snippet of [
|
|
'Linking.getInitialURL()',
|
|
"Linking.addEventListener('url'",
|
|
'buildMobileShellUrlFromDeepLink',
|
|
'configureMobileHostBridgeNavigation',
|
|
'HOST_BRIDGE_PROTOCOL',
|
|
'HOST_BRIDGE_VERSION',
|
|
'shouldAcceptMobileShellHostBridgeMessage',
|
|
'webViewRef.current?.reload()',
|
|
'const reloadCurrentWebView = useCallback(() => {',
|
|
'reloadWebView: reloadCurrentWebView',
|
|
'onContentProcessDidTerminate={reloadCurrentWebView}',
|
|
'onRenderProcessGone={reloadCurrentWebView}',
|
|
'normalizeMobileShellLoadFailure',
|
|
'handleWebViewLoadError',
|
|
'handleWebViewHttpError',
|
|
'handleRetryLoadFailure',
|
|
'onError={handleWebViewLoadError}',
|
|
'onHttpError={handleWebViewHttpError}',
|
|
'loadFailurePanel',
|
|
'loadFailureButton',
|
|
'AppState.addEventListener',
|
|
'app.lifecycle',
|
|
'network.statusChanged',
|
|
'getMobileNetworkStatus',
|
|
'subscribeMobileNetworkStatus',
|
|
'nativeCanGoBackRef',
|
|
'h5CanGoBackRef',
|
|
'syncNavigationCanGoBack',
|
|
'resetNavigationCanGoBack',
|
|
'injectHostBridgeEvent',
|
|
'injectLifecycleEvent',
|
|
'injectNetworkStatusEvent',
|
|
'handleWebViewLoad',
|
|
'onLoad={handleWebViewLoad}',
|
|
'navigation.canGoBack',
|
|
"syncNavigationCanGoBack('h5', historyState.canGoBack)",
|
|
"syncNavigationCanGoBack('native', event.canGoBack)",
|
|
"webViewRef.current?.injectJavaScript('window.history.back(); true;')",
|
|
'buildHostBridgeMessageScript',
|
|
'parseMobileWebViewHistoryStateMessage',
|
|
'origin: window.location.origin',
|
|
'source: window',
|
|
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
|
|
'shouldBlockMobileWebViewNavigationRequest',
|
|
'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={MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT}',
|
|
'handleBlockedFileDownload',
|
|
'onFileDownload={handleBlockedFileDownload}',
|
|
'setSupportMultipleWindows={false}',
|
|
]) {
|
|
if (!shellAppSource.includes(snippet)) {
|
|
throw new Error(`mobile shell ShellApp missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'MobileShellLoadFailureInput',
|
|
'normalizeMobileShellLoadFailure',
|
|
'shouldShowLoadFailure',
|
|
'sameDocumentUrl',
|
|
'shouldOpenInMobileShellWebView',
|
|
"input.type === 'http'",
|
|
"title: '网络不可用'",
|
|
"retryLabel: '重试'",
|
|
"url.pathname !== '/favicon.ico'",
|
|
]) {
|
|
if (!loadFailureSource.includes(snippet)) {
|
|
throw new Error(`mobile shell load failure policy missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
|
|
'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
|
|
'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT',
|
|
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
|
|
"'blob:'",
|
|
"'data:'",
|
|
"'file:'",
|
|
"'filesystem:'",
|
|
'shouldBlockMobileWebViewDownloadUrl',
|
|
'shouldBlockMobileWebViewNavigationRequest',
|
|
"target.closest('a')",
|
|
'event.stopImmediatePropagation()',
|
|
'window.open = function(url)',
|
|
'HTMLAnchorElement.prototype.click',
|
|
'window.history.pushState = function(state, title, url)',
|
|
'window.history.replaceState = function(state, title, url)',
|
|
"window.addEventListener('popstate'",
|
|
'ReactNativeWebView',
|
|
'genarrative.mobile.historyState',
|
|
'__genarrativeMobileHistoryIndex',
|
|
'__GENARRATIVE_MOBILE_POST_NAVIGATION_STATE__',
|
|
]) {
|
|
if (!webViewPolicySource.includes(snippet)) {
|
|
throw new Error(`mobile shell WebView policy missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'parseMobileWebViewHistoryStateMessage',
|
|
'genarrative.mobile.historyState',
|
|
'typeof candidate.canGoBack !== \'boolean\'',
|
|
]) {
|
|
if (!webViewHistorySource.includes(snippet)) {
|
|
throw new Error(`mobile shell WebView history parser missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (!appSource.includes("import ShellApp from './src/shell/ShellApp';")) {
|
|
throw new Error('mobile shell App must import the shell app facade');
|
|
}
|
|
|
|
if (!appSource.includes('return <ShellApp />;')) {
|
|
throw new Error('mobile shell App must only render the shell app facade');
|
|
}
|
|
|
|
if (appSource.includes('./src/host-bridge/')) {
|
|
throw new Error('mobile shell App must not import HostBridge directly');
|
|
}
|
|
|
|
if (
|
|
shellAppSource.includes('process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL ||')
|
|
) {
|
|
throw new Error(
|
|
'mobile shell ShellApp must normalize EXPO_PUBLIC_GENARRATIVE_WEB_URL',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!urlSource.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 (!urlSource.includes(snippet)) {
|
|
throw new Error(`mobile shell H5 URL allowlist missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'buildMobileShellUrl(',
|
|
'HOST_BRIDGE_VERSION.toString()',
|
|
"url.searchParams.set('clientRuntime', 'native_app')",
|
|
"url.searchParams.set('hostShell', 'expo_mobile')",
|
|
"url.searchParams.set('hostPlatform', options.platform)",
|
|
"url.searchParams.set('bridgeVersion', HOST_BRIDGE_VERSION.toString())",
|
|
"url.searchParams.set('hostCapabilities', options.capabilities.join(','))",
|
|
]) {
|
|
if (!urlSource.includes(snippet)) {
|
|
throw new Error(`mobile shell host-context URL builder missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'resolveMobileShellBaseWebUrl(baseWebUrl)',
|
|
'resolveTargetPath(rawUrl, webOrigin)',
|
|
'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)',
|
|
]) {
|
|
if (!deepLinkSource.includes(snippet)) {
|
|
throw new Error(`mobile shell deep link host-context flow missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'resolveMobileShellWebViewUrl',
|
|
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
|
|
'new URL(rawUrl, allowedOrigin).toString()',
|
|
]) {
|
|
if (!navigationSource.includes(snippet)) {
|
|
throw new Error(`mobile shell native-page navigation policy missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (shellAppSource.includes('127.0.0.1:3000')) {
|
|
throw new Error(
|
|
'mobile shell ShellApp 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 (hostBridgeSource.includes(forbiddenNotificationSnippet)) {
|
|
throw new Error(
|
|
`mobile shell must not register remote or background notification flow: ${forbiddenNotificationSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) {
|
|
if (hostBridgeSource.includes(forbiddenHostBridgeRuntimeSnippet)) {
|
|
throw new Error(
|
|
`mobile shell HostBridge production code must not rely on ${forbiddenHostBridgeRuntimeSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
|
|
dispatchSource,
|
|
)
|
|
) {
|
|
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',
|
|
'MOBILE_AUDIO_DOCUMENT_PICKER_TYPES',
|
|
"'audio/*'",
|
|
'File(asset.uri)',
|
|
'file.base64()',
|
|
'detectImageMimeType',
|
|
'detectAudioMimeType',
|
|
'ensureImageBytesMatchMimeType',
|
|
'ensureAudioBytesMatchMimeType',
|
|
"'image bytes do not match MIME'",
|
|
"'audio bytes do not match MIME'",
|
|
'normalizeExportedImageFileName',
|
|
'normalizeHostBridgeExportFileName',
|
|
'normalizeHostBridgeClipboardText',
|
|
'normalizeHostBridgeHapticsImpactStyle',
|
|
'base64Data',
|
|
'isHostBridgeMethod',
|
|
'normalizeHostBridgeRequestId',
|
|
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
|
|
'completedHostBridgeResponses',
|
|
'inFlightHostBridgeResponses',
|
|
'resolveMobileHostBridgeResponse',
|
|
'rememberHostBridgeResponse',
|
|
'buildMobileShellUrl(webViewUrl, navigation.urlOptions)',
|
|
]) {
|
|
if (!hostBridgeSource.includes(snippet)) {
|
|
throw new Error(`mobile shell HostBridge missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
const capabilityQuerySnippet = "capabilities: MOBILE_HOST_CAPABILITIES";
|
|
if (shellAppSource.includes(capabilityQuerySnippet)) {
|
|
throw new Error('mobile shell URL must resolve platform-aware capabilities');
|
|
}
|
|
|
|
if (!shellAppSource.includes('capabilities: resolveMobileHostCapabilities()')) {
|
|
throw new Error('mobile shell URL must use resolveMobileHostCapabilities()');
|
|
}
|
|
|
|
if (!shellAppSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
|
|
throw new Error('mobile shell URL must use the shared mobile shell host version');
|
|
}
|
|
|
|
if (!dispatchSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
|
|
throw new Error('mobile shell runtime response must use the shared mobile shell host version');
|
|
}
|
|
|
|
if (
|
|
shellAppSource.includes("hostVersion: '0.1.0'") ||
|
|
hostBridgeSource.includes("hostVersion: '0.1.0'")
|
|
) {
|
|
throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source');
|
|
}
|
|
|
|
if (shellAppSource.includes(`bridge: '${sharedHostBridgeProtocol}'`)) {
|
|
throw new Error('mobile shell event injection must use HOST_BRIDGE_PROTOCOL');
|
|
}
|
|
|
|
if (shellAppSource.includes(`version: ${sharedHostBridgeVersion}`)) {
|
|
throw new Error('mobile shell event injection must use HOST_BRIDGE_VERSION');
|
|
}
|
|
|
|
if (urlSource.includes(`bridgeVersion', '${sharedHostBridgeVersion}'`)) {
|
|
throw new Error('mobile shell URL builder must use HOST_BRIDGE_VERSION');
|
|
}
|
|
|
|
for (const capability of sdkBackedCapabilities) {
|
|
if (
|
|
shellAppSource.includes(`'${capability}'`) ||
|
|
shellAppSource.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 (!dispatchSource.includes('Appearance.getColorScheme()')) {
|
|
throw new Error('mobile shell HostBridge must read the native color scheme');
|
|
}
|
|
|
|
if (!dispatchSource.includes('Linking.canOpenURL(url)')) {
|
|
throw new Error('mobile shell HostBridge external URL flow must check Linking.canOpenURL');
|
|
}
|
|
|
|
if (!shellAppSource.includes('Linking.canOpenURL(externalUrl)')) {
|
|
throw new Error('mobile shell WebView external navigation must check Linking.canOpenURL');
|
|
}
|