新增 HostBridge app.setBadgeCount 契约和 H5 能力门控 Expo 壳按平台声明能力并在 iOS 调用系统角标 API Tauri 壳通过主窗口设置任务栏角标并校验 payload 补齐角标能力测试、漂移检查和架构文档
178 lines
5.3 KiB
JavaScript
178 lines
5.3 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 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 !== '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',
|
|
'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',
|
|
'share.open',
|
|
'share.setTarget',
|
|
'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');
|
|
}
|