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 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)); 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; } 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.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'); } if (!appConfig.ios?.associatedDomains?.includes('applinks:app.genarrative.world')) { throw new Error('mobile shell iOS associated domain is missing'); } 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', 'AppState.addEventListener', 'app.lifecycle', 'navigation.canGoBack', 'buildHostBridgeMessageScript', ]) { if (!appSource.includes(snippet)) { throw new Error(`mobile shell App missing ${snippet}`); } } for (const dependency of ['expo-file-system', 'expo-sharing']) { if (!packageConfig.dependencies?.[dependency]) { throw new Error(`mobile shell package missing ${dependency}`); } } for (const snippet of [ 'file.exportText', 'file.exportImage', 'Sharing.shareAsync', 'normalizeHostBridgeExportFileName', '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', 'navigation.openNativePage', 'navigation.canGoBack', 'app.openExternalUrl', 'clipboard.writeText', 'file.exportText', 'file.exportImage', 'haptics.impact', ]) { 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'); }