6bcd176bdf
合入 master 的 BgFilter、CI、运维与现役平台改造。 保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。 修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。 同步冲突文档、锁文件和开发脚本。
4967 lines
154 KiB
JavaScript
4967 lines
154 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { spawnSync } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import vm from 'node:vm';
|
||
|
||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
const nativeShellPlanPath =
|
||
'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md';
|
||
const hostBridgeProtocolDocPath =
|
||
'docs/【前端架构】宿主壳能力统一协议-2026-06-17.md';
|
||
const developmentWorkflowDocPath =
|
||
'docs/project-memory/shared-memory/development-workflow.md';
|
||
const decisionLogDocPath = 'docs/project-memory/shared-memory/decision-log.md';
|
||
const rootPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||
const mobileShellConfigCheckSource = fs.readFileSync(
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'utf8',
|
||
);
|
||
const desktopShellConfigCheckSource = fs.readFileSync(
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
'utf8',
|
||
);
|
||
const aiGameCreatorShellAppSource = fs.readFileSync(
|
||
'apps/ai-game-creator-shell/src/App.tsx',
|
||
'utf8',
|
||
);
|
||
const aiGameCreatorShellModelSource = fs.readFileSync(
|
||
'apps/ai-game-creator-shell/src/features/app-shell/model.ts',
|
||
'utf8',
|
||
);
|
||
const aiGameCreatorShellChatPaneSource = fs.readFileSync(
|
||
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx',
|
||
'utf8',
|
||
);
|
||
const aiGameCreatorProjectDevelopmentSource = fs.readFileSync(
|
||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||
'utf8',
|
||
);
|
||
const aiGameCreatorPreviewRustSource = fs.readFileSync(
|
||
'apps/ai-game-creator-shell/src-tauri/src/preview.rs',
|
||
'utf8',
|
||
);
|
||
const aiGameCreatorShellTauriConfig = JSON.parse(
|
||
fs.readFileSync(
|
||
'apps/ai-game-creator-shell/src-tauri/tauri.conf.json',
|
||
'utf8',
|
||
),
|
||
);
|
||
const aiGameCreatorShellTauriSource = readSourceTree(
|
||
'apps/ai-game-creator-shell/src-tauri/src',
|
||
'.rs',
|
||
);
|
||
|
||
const productionShellScanRoots = [
|
||
'apps/mobile-shell',
|
||
'apps/desktop-shell',
|
||
'miniprogram',
|
||
'packages/shared/src/contracts/hostBridge.ts',
|
||
'src/services/host-bridge',
|
||
];
|
||
const h5HostBridgeFacadeModule = 'src/services/host-bridge/hostBridge';
|
||
const h5NativeAppHostBridgeTransportModule =
|
||
'src/services/host-bridge/nativeAppHostBridge';
|
||
const h5HostBridgeScannedFacadeImports = new Set([
|
||
'canUseHostShareGrid',
|
||
'captureHostImageFile',
|
||
'exportHostAudioFile',
|
||
'exportHostImageFile',
|
||
'exportHostTextFile',
|
||
'getHostAppearanceColorScheme',
|
||
'getHostNetworkStatus',
|
||
'getNativeAppHostRuntime',
|
||
'importHostAudioFile',
|
||
'importHostDocumentFile',
|
||
'importHostImageFile',
|
||
'importHostTextFile',
|
||
'navigateHostNativePage',
|
||
'openHostExternalUrl',
|
||
'openHostShare',
|
||
'openHostShareGrid',
|
||
'readHostClipboardText',
|
||
'refreshNativeAppHostRuntime',
|
||
'reloadHostWebView',
|
||
'requestHostHapticsImpact',
|
||
'requestHostLogin',
|
||
'requestHostPayment',
|
||
'scanHostQrCode',
|
||
'setHostAppBadgeCount',
|
||
'setHostAppTitle',
|
||
'setHostShareTarget',
|
||
'showHostLocalNotification',
|
||
'subscribeHostAppLifecycle',
|
||
'subscribeHostImageDrop',
|
||
'subscribeHostNavigationCanGoBack',
|
||
'subscribeHostNetworkStatusChange',
|
||
'subscribeHostRuntimeChange',
|
||
'writeHostClipboardText',
|
||
]);
|
||
|
||
function readSourceTree(entryPath, extension) {
|
||
const stats = fs.statSync(entryPath);
|
||
if (stats.isDirectory()) {
|
||
return fs
|
||
.readdirSync(entryPath, { withFileTypes: true })
|
||
.sort((left, right) => left.name.localeCompare(right.name))
|
||
.map((entry) =>
|
||
readSourceTree(path.join(entryPath, entry.name), extension),
|
||
)
|
||
.join('\n');
|
||
}
|
||
if (path.extname(entryPath) !== extension) {
|
||
return '';
|
||
}
|
||
return fs.readFileSync(entryPath, 'utf8');
|
||
}
|
||
|
||
function assertRootNativeShellCheckScripts() {
|
||
if (
|
||
rootPackageJson.scripts?.['check:native-shells'] !==
|
||
'node scripts/check-native-shells.mjs'
|
||
) {
|
||
throw new Error(
|
||
'root check:native-shells script must run scripts/check-native-shells.mjs',
|
||
);
|
||
}
|
||
if (
|
||
rootPackageJson.scripts?.check !==
|
||
'npm run lint && npm run test && npm run build && npm run check:content && npm run check:native-shells'
|
||
) {
|
||
throw new Error(
|
||
'root check script must include check:native-shells after build and content checks',
|
||
);
|
||
}
|
||
}
|
||
|
||
assertRootNativeShellCheckScripts();
|
||
function assertNativeShellDependencyVersionGuardrails() {
|
||
for (const snippet of [
|
||
"const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)",
|
||
'function assertPackageDependencyVersion(',
|
||
'function assertPackageLockVersion(',
|
||
"'@expo/metro-runtime': '^56.0.15'",
|
||
"expo: '^56.0.12'",
|
||
"'react-native': '^0.86.0'",
|
||
"'react-native-webview': '^13.16.1'",
|
||
"'eas-cli': '^20.3.0'",
|
||
"assertPackageLockVersion('eas-cli', '20.3.0')",
|
||
]) {
|
||
if (!mobileShellConfigCheckSource.includes(snippet)) {
|
||
throw new Error(
|
||
`mobile shell dependency guardrail drifted: missing ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const snippet of [
|
||
"const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)",
|
||
"const cargoLockPath = new URL('../src-tauri/Cargo.lock', import.meta.url)",
|
||
'function assertPackageDependencyVersion(',
|
||
'function assertPackageLockVersion(',
|
||
'function assertCargoDependencyLine(',
|
||
'function assertCargoLockPackageVersion(',
|
||
'function assertCargoLockDirectDependency(',
|
||
"'@tauri-apps/cli': '^2.11.2'",
|
||
"'@tauri-apps/cli': '2.11.2'",
|
||
'tauri = { version = "2.11.2", features = ["tray-icon"] }',
|
||
"['tauri', '2.11.2']",
|
||
'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }',
|
||
]) {
|
||
if (!desktopShellConfigCheckSource.includes(snippet)) {
|
||
throw new Error(
|
||
`desktop shell dependency guardrail drifted: missing ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
assertNativeShellDependencyVersionGuardrails();
|
||
const h5HostBridgeCallChainWrapperFiles = [
|
||
'src/hooks/useHostLifecycleActive.ts',
|
||
'src/hooks/useHostNavigationCanGoBack.ts',
|
||
'src/hooks/useHostNetworkOnline.ts',
|
||
'src/components/platform-entry/platformProfileHostClipboard.ts',
|
||
'src/components/platform-entry/platformHostBridgeSync.ts',
|
||
];
|
||
const h5HostBridgeRequiredCallChainFiles = [
|
||
'src/ActiveApp.tsx',
|
||
'src/components/auth/AuthGate.tsx',
|
||
'src/components/platform-entry/PlatformFeedbackView.tsx',
|
||
'src/components/platform-entry/PlatformProfileQrScannerModal.tsx',
|
||
'src/components/platform-entry/PlatformProfileReferralModal.tsx',
|
||
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
|
||
'src/components/platform-entry/usePlatformProfileCenterController.ts',
|
||
'src/hooks/useHostLifecycleActive.ts',
|
||
'src/hooks/useHostNavigationCanGoBack.ts',
|
||
'src/hooks/useHostNetworkOnline.ts',
|
||
'src/active-main.tsx',
|
||
'src/services/activeAppTitle.ts',
|
||
'src/services/authService.ts',
|
||
'src/services/clipboard.ts',
|
||
'src/services/payment/paymentRedirect.ts',
|
||
];
|
||
const h5HostBridgeEventSubscriptionFacades = [
|
||
{
|
||
functionName: 'subscribeHostAppLifecycle',
|
||
eventName: 'app.lifecycle',
|
||
},
|
||
{
|
||
functionName: 'subscribeHostNetworkStatusChange',
|
||
eventName: 'network.statusChanged',
|
||
},
|
||
{
|
||
functionName: 'subscribeHostNavigationCanGoBack',
|
||
eventName: 'navigation.canGoBack',
|
||
},
|
||
{
|
||
functionName: 'subscribeHostImageDrop',
|
||
eventName: 'file.imageDropped',
|
||
},
|
||
];
|
||
const wechatCapabilityFlowContracts = [
|
||
{
|
||
capability: 'auth.requestLogin',
|
||
files: [
|
||
'miniprogram/host-bridge/protocol.js',
|
||
'miniprogram/host-bridge/webView.js',
|
||
'miniprogram/shell/webView.js',
|
||
'miniprogram/pages/web-view/index.js',
|
||
],
|
||
snippets: [
|
||
['miniprogram/host-bridge/protocol.js', 'WECHAT_AUTH_PAGE_URL'],
|
||
[
|
||
'miniprogram/host-bridge/webView.js',
|
||
'resolveWebViewUrlFromRuntimeConfig',
|
||
],
|
||
['miniprogram/shell/webView.js', 'shouldStartAuthFromQuery'],
|
||
['miniprogram/shell/webView.js', 'wx.login'],
|
||
['miniprogram/shell/webView.js', '/api/auth/wechat/miniprogram-login'],
|
||
['miniprogram/pages/web-view/index.js', 'createWechatWebViewPage'],
|
||
],
|
||
tests: [
|
||
'miniprogram/host-bridge/protocol.test.js',
|
||
'miniprogram/host-bridge/webView.test.js',
|
||
'miniprogram/shell/webView.test.js',
|
||
'scripts/miniprogram-web-view-auth.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'payment.request',
|
||
files: [
|
||
'miniprogram/host-bridge/protocol.js',
|
||
'miniprogram/host-bridge/payment.js',
|
||
'miniprogram/shell/payment.js',
|
||
'miniprogram/pages/wechat-pay/index.js',
|
||
],
|
||
snippets: [
|
||
['miniprogram/host-bridge/protocol.js', 'WECHAT_PAY_PAGE_URL'],
|
||
['miniprogram/host-bridge/payment.js', 'requestWechatPayment'],
|
||
['miniprogram/host-bridge/payment.js', 'wx.requestPayment'],
|
||
['miniprogram/host-bridge/payment.js', 'wx.requestVirtualPayment'],
|
||
['miniprogram/shell/payment.js', 'createWechatPayPage'],
|
||
['miniprogram/shell/payment.js', 'notifyPreviousWebView'],
|
||
['miniprogram/pages/wechat-pay/index.js', 'createWechatPayPage'],
|
||
],
|
||
tests: [
|
||
'miniprogram/host-bridge/protocol.test.js',
|
||
'miniprogram/host-bridge/payment.test.js',
|
||
'miniprogram/shell/payment.test.js',
|
||
'scripts/miniprogram-web-view-auth.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'share.setTarget',
|
||
files: [
|
||
'miniprogram/host-bridge/protocol.js',
|
||
'miniprogram/host-bridge/webView.js',
|
||
'miniprogram/shell/webView.js',
|
||
],
|
||
snippets: [
|
||
[
|
||
'miniprogram/host-bridge/protocol.js',
|
||
'WECHAT_SHARE_TARGET_MESSAGE_TYPE',
|
||
],
|
||
[
|
||
'miniprogram/host-bridge/webView.js',
|
||
'resolveShareTargetFromWebViewMessage',
|
||
],
|
||
['miniprogram/shell/webView.js', 'handleWebViewMessage'],
|
||
['miniprogram/shell/webView.js', '_currentShareTarget'],
|
||
],
|
||
tests: [
|
||
'miniprogram/host-bridge/protocol.test.js',
|
||
'miniprogram/host-bridge/webView.test.js',
|
||
'miniprogram/shell/webView.test.js',
|
||
],
|
||
},
|
||
{
|
||
capability: 'share.open',
|
||
files: [
|
||
'miniprogram/host-bridge/protocol.js',
|
||
'miniprogram/host-bridge/webView.js',
|
||
'miniprogram/host-bridge/shareGrid.js',
|
||
'miniprogram/shell/webView.js',
|
||
'miniprogram/shell/shareGrid.js',
|
||
'miniprogram/pages/share-grid/index.js',
|
||
],
|
||
snippets: [
|
||
['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_GRID_PAGE_URL'],
|
||
['miniprogram/host-bridge/webView.js', 'buildWebViewSharePath'],
|
||
['miniprogram/host-bridge/shareGrid.js', 'buildShareGridTilePlan'],
|
||
['miniprogram/shell/webView.js', 'onShareAppMessage'],
|
||
['miniprogram/shell/webView.js', 'onShareTimeline'],
|
||
['miniprogram/shell/shareGrid.js', 'wx.saveImageToPhotosAlbum'],
|
||
['miniprogram/pages/share-grid/index.js', 'createWechatShareGridPage'],
|
||
],
|
||
tests: [
|
||
'miniprogram/host-bridge/protocol.test.js',
|
||
'miniprogram/host-bridge/webView.test.js',
|
||
'miniprogram/host-bridge/shareGrid.test.js',
|
||
'miniprogram/shell/webView.test.js',
|
||
'miniprogram/shell/shareGrid.test.js',
|
||
],
|
||
},
|
||
];
|
||
const mobileCapabilityFlowContracts = [
|
||
{
|
||
capability: 'host.getRuntime',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/runtime.ts',
|
||
'apps/mobile-shell/src/host-bridge/capabilities.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'getMobileHostBridgeRuntimeResponse(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/runtime.ts',
|
||
'getMobileHostBridgeRuntime',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/runtime.ts',
|
||
'resolveMobileHostCapabilities(platform)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'capabilities: resolveMobileHostCapabilities()',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'mobile shell dispatch must delegate host.getRuntime to runtime.ts',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/runtime.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/capabilities.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
],
|
||
},
|
||
{
|
||
capability: 'appearance.getColorScheme',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/appearance.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'getMobileHostBridgeAppearanceColorScheme(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/appearance.ts',
|
||
'Appearance.getColorScheme()',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/appearance.ts',
|
||
'normalizeHostBridgeColorScheme',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'Appearance.getColorScheme()',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/appearance.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'host.events',
|
||
files: [
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
['apps/mobile-shell/src/shell/ShellApp.tsx', 'injectHostBridgeEvent'],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
"hostBridgeEvent('app.lifecycle')",
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.lifecycle',
|
||
files: [
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
"injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state))",
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
"hostBridgeEvent('app.lifecycle')",
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/shell/lifecycle.test.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
],
|
||
},
|
||
{
|
||
capability: 'share.setTarget',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/share.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'setMobileHostBridgeShareTarget(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/share.ts',
|
||
'normalizeHostBridgeShareOpenPayload',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/share.ts',
|
||
'currentShareTarget = normalizedTarget.payload',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'share.setTarget'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/share.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'navigation.openNativePage',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/navigation.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'openMobileHostBridgeNativePage(request, navigation)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/navigation.ts',
|
||
'navigation.baseWebUrlOptions',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/navigation.ts',
|
||
'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,',
|
||
],
|
||
['apps/mobile-shell/src/shell/ShellApp.tsx', 'openWebViewUrl(url)'],
|
||
['apps/mobile-shell/src/shell/ShellApp.tsx', 'setWebUrl(url)'],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'navigation.openNativePage',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/navigation.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
],
|
||
},
|
||
{
|
||
capability: 'navigation.canGoBack',
|
||
files: [
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'apps/mobile-shell/src/shell/webViewHistory.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
"injectHostBridgeEvent('navigation.canGoBack'",
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/webViewHistory.ts',
|
||
'parseMobileWebViewHistoryStateMessage',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
"lastHostBridgeEvent('navigation.canGoBack')",
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/shell/webViewHistory.test.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.reloadWebView',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/navigation.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'reloadMobileHostBridgeWebView(request, navigation)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/navigation.ts',
|
||
'navigation.reloadWebView()',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'reloadWebView: reloadCurrentWebView',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'app.reloadWebView'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/navigation.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.openExternalUrl',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/navigation.ts',
|
||
'apps/mobile-shell/src/shell/navigation.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'openMobileHostBridgeExternalUrl(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/navigation.ts',
|
||
'normalizeHostBridgeExternalUrlPayload',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/navigation.ts',
|
||
'navigator.openURL(externalUrl)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'mobile shell app.openExternalUrl must normalize payloads',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/navigation.test.ts',
|
||
'apps/mobile-shell/src/shell/navigation.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'network.status',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/network.ts',
|
||
'apps/mobile-shell/src/shell/network.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'getMobileHostBridgeNetworkStatus(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/network.ts',
|
||
'getMobileNetworkStatus()',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/network.ts',
|
||
'Network.getNetworkStateAsync()',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'network status unavailable',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/network.test.ts',
|
||
'apps/mobile-shell/src/shell/network.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'clipboard.writeText',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/clipboard.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'writeMobileHostBridgeClipboardText(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/clipboard.ts',
|
||
'Clipboard.setStringAsync(clipboardText.text)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/clipboard.ts',
|
||
'normalizeHostBridgeClipboardText',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'writeMobileHostBridgeClipboardText',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/clipboard.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'clipboard.readText',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/clipboard.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'readMobileHostBridgeClipboardText(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/clipboard.ts',
|
||
'Clipboard.getStringAsync()',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/clipboard.ts',
|
||
'normalizeHostBridgeClipboardText(rawText)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'readMobileHostBridgeClipboardText',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/clipboard.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.exportText',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'exportMobileHostBridgeTextFile(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'exportTextFile(request.payload)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'normalizeHostBridgeExportFileName',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.exportText'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importText',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'importMobileHostBridgeTextFile(request)',
|
||
],
|
||
['apps/mobile-shell/src/host-bridge/files.ts', 'importTextFile()'],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'normalizeImportedTextMimeType',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.importText'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importDocument',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'importMobileHostBridgeDocumentFile(request)',
|
||
],
|
||
['apps/mobile-shell/src/host-bridge/files.ts', 'importDocumentFile()'],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'normalizeImportedDocumentMimeType',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.importDocument'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.exportImage',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'exportMobileHostBridgeImageFile(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'exportImageFile(request.payload)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'normalizedBase64Data(exportPayload?.base64Data)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'ensureImageBytesMatchMimeType',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.exportImage'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importImage',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'importMobileHostBridgeImageFile(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'ImagePicker.launchImageLibraryAsync',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'imagePickerResultToImportPayload',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.importImage'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importAudio',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'importMobileHostBridgeAudioFile(request)',
|
||
],
|
||
['apps/mobile-shell/src/host-bridge/files.ts', 'importAudioFile()'],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'normalizeImportedAudioMimeType',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.importAudio'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.exportAudio',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'exportMobileHostBridgeAudioFile(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'exportAudioFile(request.payload)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'normalizedBase64Data(exportPayload?.base64Data)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'ensureAudioBytesMatchMimeType',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.exportAudio'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'haptics.impact',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/haptics.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'runMobileHostBridgeHapticsImpact(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/haptics.ts',
|
||
'Haptics.impactAsync(toExpoImpactStyle(style))',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/haptics.ts',
|
||
'normalizeHostBridgeHapticsImpactStyle',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'haptics impact unavailable',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/haptics.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.setBadgeCount',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/badge.ts',
|
||
'apps/mobile-shell/src/host-bridge/capabilities.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'setMobileAppBadgeCount(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/badge.ts',
|
||
'Notifications.setBadgeCountAsync',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/capabilities.ts',
|
||
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'iOS mobile shell capabilities missing app.setBadgeCount',
|
||
],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/badge.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/capabilities.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'notification.showLocal',
|
||
files: [
|
||
'apps/mobile-shell/app.json',
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/notifications.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'apps/mobile-shell/scripts/check-expo-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
['apps/mobile-shell/app.json', 'android.permission.POST_NOTIFICATIONS'],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'showMobileHostBridgeLocalNotification(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/notifications.ts',
|
||
'Notifications.scheduleNotificationAsync',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/notifications.ts',
|
||
'Notifications.setNotificationChannelAsync',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/notifications.ts',
|
||
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
|
||
],
|
||
[
|
||
'apps/mobile-shell/scripts/check-expo-config.mjs',
|
||
'android.permission.POST_NOTIFICATIONS',
|
||
],
|
||
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/notifications.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.captureImage',
|
||
files: [
|
||
'apps/mobile-shell/app.json',
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
['apps/mobile-shell/app.json', 'expo-image-picker'],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'captureMobileHostBridgeImageFile(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'ImagePicker.requestCameraPermissionsAsync',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/files.ts',
|
||
'ImagePicker.launchCameraAsync',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'file.captureImage'],
|
||
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/files.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'scanner.scanQrCode',
|
||
files: [
|
||
'apps/mobile-shell/app.json',
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/scanner.ts',
|
||
'apps/mobile-shell/src/shell/QrScannerOverlay.tsx',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
['apps/mobile-shell/app.json', 'expo-camera'],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'scanMobileHostBridgeQrCode(request)',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/scanner.ts',
|
||
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/QrScannerOverlay.tsx',
|
||
'Camera.requestCameraPermissionsAsync',
|
||
],
|
||
['apps/mobile-shell/src/shell/QrScannerOverlay.tsx', 'CameraView'],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'scanner.scanQrCode'],
|
||
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/scanner.test.ts',
|
||
'apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx',
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'network.statusChanged',
|
||
files: [
|
||
'apps/mobile-shell/src/shell/network.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/mobile-shell/src/shell/network.ts',
|
||
'Network.addNetworkStateListener',
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
"injectHostBridgeEvent('network.statusChanged', payload)",
|
||
],
|
||
[
|
||
'apps/mobile-shell/src/shell/ShellApp.tsx',
|
||
"logMobileHostEventFailure('network.statusChanged', error)",
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'network.statusChanged'],
|
||
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/shell/network.test.ts',
|
||
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
{
|
||
capability: 'share.open',
|
||
files: [
|
||
'apps/mobile-shell/src/host-bridge/dispatch.ts',
|
||
'apps/mobile-shell/src/host-bridge/share.ts',
|
||
'apps/mobile-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
['apps/mobile-shell/src/host-bridge/dispatch.ts', 'openShare(request)'],
|
||
['apps/mobile-shell/src/host-bridge/share.ts', 'Share.share'],
|
||
[
|
||
'apps/mobile-shell/src/host-bridge/share.ts',
|
||
'normalizeHostBridgeShareOpenPayload',
|
||
],
|
||
['apps/mobile-shell/scripts/check-config.mjs', 'share.open'],
|
||
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
|
||
],
|
||
tests: [
|
||
'apps/mobile-shell/src/host-bridge/share.test.ts',
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
],
|
||
},
|
||
];
|
||
const desktopCapabilityFlowContracts = [
|
||
{
|
||
capability: 'host.getRuntime',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'desktop_host_bridge_runtime_response(&request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs',
|
||
'shell: "tauri_desktop"',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs',
|
||
'capabilities: capabilities()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs',
|
||
'pub(crate) fn capabilities()',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'host.getRuntime'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'appearance.getColorScheme',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/runtime.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'desktop_appearance_color_scheme(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs',
|
||
'window.theme()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs',
|
||
'"colorScheme": color_scheme',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/runtime.rs',
|
||
'color_scheme_from_theme',
|
||
],
|
||
[
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
'appearance.getColorScheme',
|
||
],
|
||
],
|
||
},
|
||
{
|
||
capability: 'host.events',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/shell/events.rs',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/events.rs',
|
||
'const HOST_BRIDGE_EVENTS',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/events.rs',
|
||
"window.dispatchEvent(new MessageEvent('message'",
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'replay_desktop_webview_state(&window)',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'host.events'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.lifecycle',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/events.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'register_desktop_lifecycle_events(&window)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
|
||
'resolve_desktop_lifecycle_payload',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
|
||
'emit_current_desktop_lifecycle_event',
|
||
],
|
||
['apps/desktop-shell/src-tauri/src/shell/events.rs', '"app.lifecycle"'],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'app.lifecycle'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'share.setTarget',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'set_desktop_host_bridge_share_target(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
|
||
'share_text_from_value(target)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
|
||
'*current_target = Some(target.clone())',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'share.setTarget'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'navigation.openNativePage',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'open_desktop_host_bridge_native_page(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'desktop_native_page_url_from_request',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'window.navigate(url)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'normalize_native_page_url',
|
||
],
|
||
[
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
'navigation.openNativePage',
|
||
],
|
||
],
|
||
},
|
||
{
|
||
capability: 'navigation.canGoBack',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'register_desktop_navigation_events(&window)?',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'desktop_navigation_state_script()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'"navigation.canGoBack"',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
|
||
'register_desktop_navigation_events(window)',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'navigation.canGoBack'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.reloadWebView',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'reload_desktop_host_bridge_webview(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'window.reload()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'webview reload unavailable',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'app.reloadWebView'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.openExternalUrl',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-opener'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'tauri_plugin_opener::init()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'open_desktop_host_bridge_external_url(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
|
||
'desktop_external_url_from_request',
|
||
],
|
||
['apps/desktop-shell/src-tauri/src/shell/navigation.rs', 'app.opener()'],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'app.openExternalUrl'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.setTitle',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/title.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'set_desktop_host_bridge_window_title(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/title.rs',
|
||
'normalize_window_title',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/title.rs',
|
||
'window.set_title(&title)',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'app.setTitle'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'app.setBadgeCount',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/badge.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'set_desktop_app_badge_count(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/badge.rs',
|
||
'badge_count_payload(request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/badge.rs',
|
||
'window.set_badge_count(count)',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'app.setBadgeCount'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'network.status',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/network.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/network.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'resolve_desktop_host_bridge_network_status(&request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/network.rs',
|
||
'resolve_desktop_network_status',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/network.rs',
|
||
'desktop_network_status_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'network.status'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'clipboard.writeText',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'tauri-plugin-clipboard-manager',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'tauri_plugin_clipboard_manager::init()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'write_desktop_host_bridge_clipboard_text(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'write_desktop_clipboard_text(app, text)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'app.clipboard()',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'clipboard.writeText'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'clipboard.readText',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'tauri-plugin-clipboard-manager',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'tauri_plugin_clipboard_manager::init()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'read_desktop_host_bridge_clipboard_text(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'read_desktop_clipboard_text(app)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'"text": text',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'clipboard.readText'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.exportText',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'export_desktop_host_bridge_text_file(&app, &request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'export_text_payload(request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'write_export_text_file(path, content)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'export_text_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.exportText'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importText',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'import_desktop_host_bridge_text_file(&app, &request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'import_text_file_payload(path)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'import_text_file_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.importText'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importImage',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'import_desktop_host_bridge_image_file(&app, &request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'add_filter("Image", &["png", "jpg", "jpeg", "webp"])',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'import_image_file_payload(path, "selected", None)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'import_image_file_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.importImage'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importAudio',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'import_desktop_host_bridge_audio_file(&app, &request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"])',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'import_audio_file_payload(path)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'import_audio_file_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.importAudio'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.exportAudio',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'export_desktop_host_bridge_audio_file(&app, &request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'export_audio_payload(request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'write_export_bytes_file(path, bytes)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'export_audio_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.exportAudio'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'notification.showLocal',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-notification'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'tauri_plugin_notification::init()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'show_desktop_local_notification(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
|
||
'app.notification()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
|
||
'request_permission()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
|
||
'notification.show()',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'notification.showLocal'],
|
||
[nativeShellPlanPath, 'Tauri 壳'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.importDocument',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'tauri_plugin_dialog::init()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'import_desktop_host_bridge_document_file(&app, &request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'add_filter(\n "Document"',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'blocking_pick_file()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'import_document_file_payload(path)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'import_document_file_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.importDocument'],
|
||
[nativeShellPlanPath, 'Tauri 壳'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.exportImage',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'tauri_plugin_dialog::init()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'export_desktop_host_bridge_image_file(&app, &request).await',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'add_filter("Image", &["png", "jpg", "jpeg", "webp"])',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'blocking_save_file()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
|
||
'write_export_bytes_file(path, bytes)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
|
||
'export_image_payload',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.exportImage'],
|
||
[nativeShellPlanPath, 'Tauri 壳'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'file.imageDropped',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
|
||
'apps/desktop-shell/src-tauri/src/shell/events.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'register_desktop_file_drop_events(&window)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
|
||
'DragDropEvent::Drop',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
|
||
'host_bridge_event_script("file.imageDropped", payload)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
|
||
'import_image_file_payload(path.clone(), "dropped", Some(position))',
|
||
],
|
||
['apps/desktop-shell/src-tauri/src/shell/events.rs', 'file.imageDropped'],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'file.imageDropped'],
|
||
[nativeShellPlanPath, 'Tauri 壳'],
|
||
],
|
||
},
|
||
{
|
||
capability: 'share.open',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'apps/desktop-shell/scripts/check-config.mjs',
|
||
nativeShellPlanPath,
|
||
],
|
||
snippets: [
|
||
[
|
||
'apps/desktop-shell/src-tauri/Cargo.toml',
|
||
'tauri-plugin-clipboard-manager',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'tauri_plugin_clipboard_manager::init()',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'open_desktop_host_bridge_share(&app, &request)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
|
||
'normalize_public_share_url',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
|
||
'write_desktop_clipboard_text(app, &share_text)',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
|
||
'"action": "copied_to_clipboard"',
|
||
],
|
||
[
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
|
||
'write_desktop_clipboard_text',
|
||
],
|
||
['apps/desktop-shell/scripts/check-config.mjs', 'share.open'],
|
||
[nativeShellPlanPath, 'Tauri 壳'],
|
||
],
|
||
},
|
||
];
|
||
const h5NativeAppRouteFlowContracts = [];
|
||
const expectedWechatHostBridgeFiles = [
|
||
'dispatch.js',
|
||
'payment.js',
|
||
'payment.test.js',
|
||
'protocol.js',
|
||
'protocol.test.js',
|
||
'shareGrid.js',
|
||
'shareGrid.test.js',
|
||
'webView.js',
|
||
'webView.test.js',
|
||
];
|
||
const expectedWechatShellFiles = [
|
||
'payment.js',
|
||
'payment.test.js',
|
||
'shareGrid.js',
|
||
'shareGrid.test.js',
|
||
'webView.js',
|
||
'webView.test.js',
|
||
];
|
||
const expectedWechatPageFilesByRoute = {
|
||
'share-grid': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
|
||
'web-view': [
|
||
'index.js',
|
||
'index.json',
|
||
'index.style.test.js',
|
||
'index.wxml',
|
||
'index.wxss',
|
||
],
|
||
'wechat-pay': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
|
||
};
|
||
const expectedMobileHostBridgeFiles = [
|
||
'appearance.test.ts',
|
||
'appearance.ts',
|
||
'badge.test.ts',
|
||
'badge.ts',
|
||
'bridge.test.ts',
|
||
'bridge.ts',
|
||
'capabilities.test.ts',
|
||
'capabilities.ts',
|
||
'clipboard.test.ts',
|
||
'clipboard.ts',
|
||
'dispatch.test.ts',
|
||
'dispatch.ts',
|
||
'filePayloads.test.ts',
|
||
'filePayloads.ts',
|
||
'files.test.ts',
|
||
'files.ts',
|
||
'haptics.test.ts',
|
||
'haptics.ts',
|
||
'navigation.test.ts',
|
||
'navigation.ts',
|
||
'network.test.ts',
|
||
'network.ts',
|
||
'notifications.test.ts',
|
||
'notifications.ts',
|
||
'protocol.test.ts',
|
||
'protocol.ts',
|
||
'runtime.test.ts',
|
||
'runtime.ts',
|
||
'scanner.test.ts',
|
||
'scanner.ts',
|
||
'share.test.ts',
|
||
'share.ts',
|
||
];
|
||
const expectedMobileSrcRootEntries = [
|
||
'dir:host-bridge',
|
||
'dir:shell',
|
||
'file:env.d.ts',
|
||
];
|
||
const expectedMobileShellFiles = [
|
||
'QrScannerOverlay.test.tsx',
|
||
'QrScannerOverlay.tsx',
|
||
'ShellApp.test.tsx',
|
||
'ShellApp.tsx',
|
||
'deepLink.test.ts',
|
||
'deepLink.ts',
|
||
'lifecycle.test.ts',
|
||
'lifecycle.ts',
|
||
'loadFailure.test.ts',
|
||
'loadFailure.ts',
|
||
'navigation.test.ts',
|
||
'navigation.ts',
|
||
'network.test.ts',
|
||
'network.ts',
|
||
'runtime.test.ts',
|
||
'runtime.ts',
|
||
'safeArea.test.ts',
|
||
'safeArea.ts',
|
||
'url.test.ts',
|
||
'url.ts',
|
||
'webViewGlobals.d.ts',
|
||
'webViewHistory.test.ts',
|
||
'webViewHistory.ts',
|
||
'webViewPolicy.test.ts',
|
||
'webViewPolicy.ts',
|
||
];
|
||
const expectedDesktopHostBridgeRustFiles = [
|
||
'appearance.rs',
|
||
'badge.rs',
|
||
'capabilities.rs',
|
||
'clipboard.rs',
|
||
'dispatch.rs',
|
||
'file_payloads.rs',
|
||
'files.rs',
|
||
'mod.rs',
|
||
'navigation.rs',
|
||
'network.rs',
|
||
'notifications.rs',
|
||
'protocol.rs',
|
||
'runtime.rs',
|
||
'share.rs',
|
||
'title.rs',
|
||
];
|
||
const expectedDesktopShellRustFiles = [
|
||
'deep_link.rs',
|
||
'events.rs',
|
||
'file_drop.rs',
|
||
'lifecycle.rs',
|
||
'mod.rs',
|
||
'navigation.rs',
|
||
'network.rs',
|
||
'runtime.rs',
|
||
'tray.rs',
|
||
'url.rs',
|
||
'webview.rs',
|
||
'window_state.rs',
|
||
];
|
||
const expectedHostBridgeModuleTaxonomy = {
|
||
allShells: ['dispatch', 'protocol'],
|
||
nativeAppShells: [
|
||
'appearance',
|
||
'badge',
|
||
'capabilities',
|
||
'clipboard',
|
||
'file-payloads',
|
||
'files',
|
||
'navigation',
|
||
'network',
|
||
'notifications',
|
||
'runtime',
|
||
'share',
|
||
],
|
||
mobileOnly: ['bridge', 'haptics', 'scanner'],
|
||
desktopOnly: ['mod', 'title'],
|
||
wechatOnly: ['payment', 'shareGrid', 'webView'],
|
||
};
|
||
const documentedShellLayerGroups = [
|
||
{
|
||
label: 'wechat host bridge files',
|
||
files: expectedWechatHostBridgeFiles
|
||
.filter((fileName) => !fileName.includes('.test.'))
|
||
.map((fileName) => `miniprogram/host-bridge/${fileName}`),
|
||
},
|
||
{
|
||
label: 'wechat shell files',
|
||
files: expectedWechatShellFiles
|
||
.filter((fileName) => !fileName.includes('.test.'))
|
||
.map((fileName) => `miniprogram/shell/${fileName}`),
|
||
},
|
||
{
|
||
label: 'wechat page wrapper files',
|
||
files: Object.entries(expectedWechatPageFilesByRoute).flatMap(
|
||
([route, files]) =>
|
||
files
|
||
.filter((fileName) => !fileName.includes('.test.'))
|
||
.map((fileName) => `miniprogram/pages/${route}/${fileName}`),
|
||
),
|
||
},
|
||
{
|
||
label: 'mobile src root entries',
|
||
files: ['apps/mobile-shell/src/env.d.ts'],
|
||
},
|
||
{
|
||
label: 'mobile host bridge files',
|
||
files: expectedMobileHostBridgeFiles
|
||
.filter((fileName) => !fileName.includes('.test.'))
|
||
.map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`),
|
||
},
|
||
{
|
||
label: 'mobile shell files',
|
||
files: expectedMobileShellFiles
|
||
.filter((fileName) => !fileName.includes('.test.'))
|
||
.map((fileName) => `apps/mobile-shell/src/shell/${fileName}`),
|
||
},
|
||
{
|
||
label: 'desktop entrypoint files',
|
||
files: [
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'apps/desktop-shell/src-tauri/src/main.rs',
|
||
],
|
||
},
|
||
{
|
||
label: 'desktop host bridge files',
|
||
files: expectedDesktopHostBridgeRustFiles.map(
|
||
(fileName) => `apps/desktop-shell/src-tauri/src/host_bridge/${fileName}`,
|
||
),
|
||
},
|
||
{
|
||
label: 'desktop shell files',
|
||
files: expectedDesktopShellRustFiles.map(
|
||
(fileName) => `apps/desktop-shell/src-tauri/src/shell/${fileName}`,
|
||
),
|
||
},
|
||
];
|
||
const capabilityListMarkers = {
|
||
desktop: '桌面壳当前真实能力完整清单为',
|
||
desktopCurrentState: '当前真实能力为',
|
||
mobile: '移动壳当前通用真实能力完整清单为',
|
||
mobileCurrentState: '首轮真实能力包括',
|
||
mobileIosExtra: '移动壳 iOS 额外真实能力为',
|
||
wechat: '微信小程序壳当前真实能力完整清单为',
|
||
};
|
||
const sharedHostBridgeContractPath =
|
||
'packages/shared/src/contracts/hostBridge.ts';
|
||
const productionShellExtensions = new Set([
|
||
'.js',
|
||
'.json',
|
||
'.mjs',
|
||
'.plist',
|
||
'.rs',
|
||
'.toml',
|
||
'.ts',
|
||
'.tsx',
|
||
'.wxml',
|
||
'.wxss',
|
||
]);
|
||
const h5ProductionSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
||
const productionShellExcludedSegments = new Set([
|
||
'.expo',
|
||
'.expo-export-smoke',
|
||
'node_modules',
|
||
'target',
|
||
'test-utils',
|
||
]);
|
||
const productionShellExcludedPaths = new Set([
|
||
'apps/desktop-shell/src-tauri/gen',
|
||
'apps/desktop-shell/src-tauri/permissions/autogenerated',
|
||
]);
|
||
const generatedNativeShellArtifactPaths = [
|
||
'build/native',
|
||
'dist',
|
||
'apps/mobile-shell/.expo',
|
||
'apps/mobile-shell/.expo-export-smoke',
|
||
'apps/desktop-shell/src-tauri/target',
|
||
'apps/desktop-shell/src-tauri/gen',
|
||
'apps/desktop-shell/src-tauri/permissions/autogenerated',
|
||
];
|
||
const generatedNativeShellArtifactIgnoreProbePaths = [
|
||
'build/native/probe',
|
||
'dist/probe',
|
||
'apps/mobile-shell/.expo/probe',
|
||
'apps/mobile-shell/.expo-export-smoke/probe',
|
||
'apps/desktop-shell/src-tauri/target/probe',
|
||
'apps/desktop-shell/src-tauri/gen/probe',
|
||
'apps/desktop-shell/src-tauri/permissions/autogenerated/probe',
|
||
];
|
||
const productionShellDevScaffoldTerms = [
|
||
'mo' + 'ck',
|
||
'fa' + 'ke',
|
||
'place' + 'holder',
|
||
'st' + 'ub',
|
||
'TO' + 'DO',
|
||
'FIX' + 'ME',
|
||
'占' + '位',
|
||
'模' + '拟',
|
||
'伪' + '造',
|
||
'未' + '实现',
|
||
'临' + '时',
|
||
'后' + '续',
|
||
];
|
||
const h5HostBridgeCallChainDevScaffoldTerms = [
|
||
'mo' + 'ck',
|
||
'fa' + 'ke',
|
||
'st' + 'ub',
|
||
'TO' + 'DO',
|
||
'FIX' + 'ME',
|
||
'模' + '拟',
|
||
'伪' + '造',
|
||
];
|
||
|
||
const h5HostBridgeTests = [
|
||
'packages/shared/src/contracts/hostBridge.test.ts',
|
||
'src/services/host-bridge/hostBridge.test.ts',
|
||
'src/services/host-bridge/nativeAppHostBridge.test.ts',
|
||
'src/components/auth/AuthGate.test.tsx',
|
||
'src/hooks/useHostNavigationCanGoBack.test.tsx',
|
||
'src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx',
|
||
'src/routing/activeAppRoutes.test.ts',
|
||
'src/services/clipboard.test.ts',
|
||
'src/services/activeAppTitle.test.ts',
|
||
];
|
||
const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap(
|
||
(contract) =>
|
||
(contract.targetedTests ?? []).map((test) => ({
|
||
label: `h5-native-app-route-${contract.route}`,
|
||
command: npmCommand,
|
||
args: ['run', 'test', '--', test.filePath, '-t', test.name],
|
||
})),
|
||
);
|
||
|
||
const wechatShellTests = [
|
||
'miniprogram/host-bridge/protocol.test.js',
|
||
'miniprogram/host-bridge/webView.test.js',
|
||
'miniprogram/host-bridge/payment.test.js',
|
||
'miniprogram/host-bridge/shareGrid.test.js',
|
||
'miniprogram/shell/webView.test.js',
|
||
'miniprogram/shell/payment.test.js',
|
||
'miniprogram/shell/shareGrid.test.js',
|
||
'miniprogram/pages/web-view/index.style.test.js',
|
||
'scripts/miniprogram-web-view-auth.test.ts',
|
||
];
|
||
|
||
const steps = [
|
||
{
|
||
label: 'h5-host-bridge-tests',
|
||
command: npmCommand,
|
||
args: ['run', 'test', '--', ...h5HostBridgeTests],
|
||
},
|
||
...h5NativeAppRouteFlowTestSteps,
|
||
{
|
||
label: 'wechat-shell-tests',
|
||
command: npmCommand,
|
||
args: ['run', 'test', '--', ...wechatShellTests],
|
||
},
|
||
{
|
||
label: 'mobile-shell-typecheck',
|
||
command: npmCommand,
|
||
args: ['run', 'mobile-shell:typecheck'],
|
||
},
|
||
{
|
||
label: 'mobile-shell-test',
|
||
command: npmCommand,
|
||
args: ['run', 'mobile-shell:test'],
|
||
},
|
||
{
|
||
label: 'mobile-shell-eas-build-config-smoke',
|
||
command: npmCommand,
|
||
args: ['run', 'mobile-shell:build-config'],
|
||
},
|
||
{
|
||
label: 'mobile-shell-expo-config-smoke',
|
||
command: npmCommand,
|
||
args: ['run', 'mobile-shell:config'],
|
||
},
|
||
{
|
||
label: 'mobile-shell-expo-export-smoke',
|
||
command: npmCommand,
|
||
args: ['run', 'mobile-shell:export'],
|
||
},
|
||
{
|
||
label: 'desktop-shell-test',
|
||
command: npmCommand,
|
||
args: ['run', 'desktop-shell:test'],
|
||
},
|
||
{
|
||
label: 'desktop-shell-typecheck',
|
||
command: npmCommand,
|
||
args: ['run', 'desktop-shell:typecheck'],
|
||
},
|
||
{
|
||
label: 'ai-game-creator-shell-check',
|
||
command: npmCommand,
|
||
args: ['run', 'ai-game-creator-shell:check'],
|
||
},
|
||
{
|
||
label: 'ai-game-creator-shell-release-build-smoke',
|
||
command: npmCommand,
|
||
args: ['run', 'ai-game-creator-shell:build', '--', '--no-bundle'],
|
||
},
|
||
{
|
||
label: 'desktop-shell-release-build-smoke',
|
||
command: npmCommand,
|
||
args: ['run', 'desktop-shell:build', '--', '--no-bundle'],
|
||
},
|
||
{
|
||
label: 'desktop-shell-stage-release-binary',
|
||
command: npmCommand,
|
||
args: ['run', 'desktop-shell:stage-release-binary'],
|
||
},
|
||
];
|
||
|
||
function shouldScanProductionShellFile(filePath) {
|
||
const normalizedPath = filePath.split(path.sep).join('/');
|
||
if (
|
||
normalizedPath.includes('.test.') ||
|
||
normalizedPath.endsWith('/scripts/check-config.mjs')
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
return productionShellExtensions.has(path.extname(filePath));
|
||
}
|
||
|
||
function shouldScanH5ProductionSourceFile(filePath) {
|
||
const normalizedPath = filePath.split(path.sep).join('/');
|
||
if (
|
||
normalizedPath.includes('.test.') ||
|
||
normalizedPath.includes('/test-utils/') ||
|
||
normalizedPath.includes('/services/host-bridge/')
|
||
) {
|
||
return false;
|
||
}
|
||
|
||
return h5ProductionSourceExtensions.has(path.extname(filePath));
|
||
}
|
||
|
||
function assertNoTrackedGeneratedNativeShellArtifacts() {
|
||
const result = spawnSync(
|
||
'git',
|
||
['ls-files', ...generatedNativeShellArtifactPaths],
|
||
{
|
||
cwd: process.cwd(),
|
||
encoding: 'utf8',
|
||
},
|
||
);
|
||
if (result.error) {
|
||
throw new Error(
|
||
`unable to check generated native shell artifacts: ${result.error.message}`,
|
||
);
|
||
}
|
||
if (result.status !== 0) {
|
||
throw new Error(
|
||
`unable to check generated native shell artifacts: ${result.stderr.trim()}`,
|
||
);
|
||
}
|
||
|
||
const trackedArtifacts = result.stdout
|
||
.split('\n')
|
||
.map((entry) => entry.trim())
|
||
.filter(Boolean);
|
||
if (trackedArtifacts.length > 0) {
|
||
throw new Error(
|
||
`generated native shell artifacts must stay untracked: ${trackedArtifacts.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertGeneratedNativeShellArtifactsAreIgnored() {
|
||
const result = spawnSync(
|
||
'git',
|
||
['check-ignore', '-v', ...generatedNativeShellArtifactIgnoreProbePaths],
|
||
{
|
||
cwd: process.cwd(),
|
||
encoding: 'utf8',
|
||
},
|
||
);
|
||
if (result.error) {
|
||
throw new Error(
|
||
`unable to check generated native shell artifact gitignore rules: ${result.error.message}`,
|
||
);
|
||
}
|
||
if (result.status !== 0) {
|
||
throw new Error(
|
||
`generated native shell artifacts must be gitignored: ${result.stderr.trim() || result.stdout.trim()}`,
|
||
);
|
||
}
|
||
|
||
const ignoredArtifacts = new Set(
|
||
result.stdout
|
||
.split('\n')
|
||
.map((entry) => entry.trim().split(/\t/).pop())
|
||
.filter(Boolean),
|
||
);
|
||
const missingArtifacts = generatedNativeShellArtifactIgnoreProbePaths.filter(
|
||
(artifactPath) => !ignoredArtifacts.has(artifactPath),
|
||
);
|
||
if (missingArtifacts.length > 0) {
|
||
throw new Error(
|
||
`generated native shell artifacts missing gitignore coverage: ${missingArtifacts.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertAiGameCreatorShellUserDevBoundary() {
|
||
const windows = aiGameCreatorShellTauriConfig.app?.windows ?? [];
|
||
if (
|
||
windows.length !== 1 ||
|
||
windows[0]?.label !== 'client' ||
|
||
windows[0]?.url !== 'index.html'
|
||
) {
|
||
throw new Error(
|
||
'AI game creator release shell must register only the client window',
|
||
);
|
||
}
|
||
const releaseCsp = aiGameCreatorShellTauriConfig.app?.security?.csp ?? '';
|
||
const devCsp = aiGameCreatorShellTauriConfig.app?.security?.devCsp ?? '';
|
||
const releaseFrameSrc =
|
||
releaseCsp
|
||
.split(';')
|
||
.map((directive) => directive.trim())
|
||
.find((directive) => directive.startsWith('frame-src ')) ?? '';
|
||
const devFrameSrc =
|
||
devCsp
|
||
.split(';')
|
||
.map((directive) => directive.trim())
|
||
.find((directive) => directive.startsWith('frame-src ')) ?? '';
|
||
if (!releaseFrameSrc.includes('http://127.0.0.1:*')) {
|
||
throw new Error(
|
||
'AI game creator release shell must allow the client-local preview frame',
|
||
);
|
||
}
|
||
if (!devFrameSrc.includes('http://127.0.0.1:*')) {
|
||
throw new Error(
|
||
'AI game creator dev shell must keep local preview iframe access for the developer window',
|
||
);
|
||
}
|
||
|
||
for (const snippet of [
|
||
'function isDeveloperMode()',
|
||
'if (!import.meta.env.DEV)',
|
||
"return params.has('dev') || window.location.hash === '#dev';",
|
||
]) {
|
||
if (!aiGameCreatorShellModelSource.includes(snippet)) {
|
||
throw new Error(
|
||
`AI game creator developer-mode boundary drifted: missing ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
for (const snippet of ['{devMode ? (', 'className="developer-pane"']) {
|
||
if (!aiGameCreatorShellAppSource.includes(snippet)) {
|
||
throw new Error(
|
||
`AI game creator user/dev UI boundary drifted: missing ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
if (!aiGameCreatorShellChatPaneSource.includes('className="chat-pane"')) {
|
||
throw new Error('AI game creator chat pane boundary drifted');
|
||
}
|
||
|
||
const previewFrameIndexes = [
|
||
...aiGameCreatorShellAppSource.matchAll(/<iframe\b/g),
|
||
].map((match) => match.index ?? -1);
|
||
if (previewFrameIndexes.length !== 0) {
|
||
throw new Error(
|
||
'AI game creator app shell must delegate preview iframe to the client workbench',
|
||
);
|
||
}
|
||
|
||
const clientPreviewFrameCount = [
|
||
...aiGameCreatorProjectDevelopmentSource.matchAll(/<iframe\b/g),
|
||
].length;
|
||
if (clientPreviewFrameCount !== 1) {
|
||
throw new Error(
|
||
'AI game creator client workbench must own exactly one local preview frame',
|
||
);
|
||
}
|
||
for (const snippet of [
|
||
'function resolveEmbeddedPreviewUrl(',
|
||
"url.protocol !== 'http:' || url.hostname !== '127.0.0.1'",
|
||
'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"',
|
||
'src={embeddedPreviewUrl}',
|
||
]) {
|
||
if (!aiGameCreatorProjectDevelopmentSource.includes(snippet)) {
|
||
throw new Error(
|
||
`AI game creator embedded preview boundary drifted: missing ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
for (const snippet of [
|
||
"await invoke<LocalPreviewStatus>(\n 'activate_local_game_preview'",
|
||
'已切换到客户端运行视图',
|
||
]) {
|
||
if (!aiGameCreatorShellAppSource.includes(snippet)) {
|
||
throw new Error(
|
||
`AI game creator client preview activation drifted: missing ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
for (const snippet of [
|
||
'fn activate_local_game_preview(',
|
||
'preview_open_url(&status)?;',
|
||
]) {
|
||
if (!aiGameCreatorPreviewRustSource.includes(snippet)) {
|
||
throw new Error(
|
||
`AI game creator client preview command drifted: missing ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
aiGameCreatorShellAppSource.includes('openPreviewInExternalBrowser') ||
|
||
aiGameCreatorShellAppSource.includes('open_local_game_preview') ||
|
||
aiGameCreatorPreviewRustSource.includes('.open_url(&url')
|
||
) {
|
||
throw new Error(
|
||
'AI game creator preview must not invoke the external browser',
|
||
);
|
||
}
|
||
for (const snippet of [
|
||
'#[cfg(all(debug_assertions, not(test)))]\npub(crate) fn open_developer_window(',
|
||
'#[cfg(all(debug_assertions, not(test)))]\n open_developer_window(app.handle())?;',
|
||
]) {
|
||
if (!aiGameCreatorShellTauriSource.includes(snippet)) {
|
||
throw new Error(
|
||
`AI game creator developer window must stay compile-time debug-only: ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
for (const snippet of [
|
||
'fn open_project_supervisor_chat_window(',
|
||
'#[cfg(not(debug_assertions))]',
|
||
'项目总控对话窗口仅在开发构建中可用',
|
||
'index.html?supervisor-chat&projectPath=',
|
||
]) {
|
||
if (!aiGameCreatorShellTauriSource.includes(snippet)) {
|
||
throw new Error(
|
||
`AI game creator supervisor chat window must stay developer-only: ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const workspaceWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf(
|
||
'fn open_game_creator_workspace_window(',
|
||
);
|
||
const launcherWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf(
|
||
'fn open_game_creator_launcher_window(',
|
||
);
|
||
const workspaceWindowCommandSource = aiGameCreatorShellTauriSource.slice(
|
||
workspaceWindowCommandIndex,
|
||
launcherWindowCommandIndex,
|
||
);
|
||
const launcherWindowCommandSource = aiGameCreatorShellTauriSource.slice(
|
||
launcherWindowCommandIndex,
|
||
);
|
||
if (
|
||
workspaceWindowCommandIndex < 0 ||
|
||
launcherWindowCommandIndex < 0 ||
|
||
!workspaceWindowCommandSource.includes(
|
||
'window.close().map_err(|error| error.to_string())?;',
|
||
) ||
|
||
!launcherWindowCommandSource.includes(
|
||
'window.close().map_err(|error| error.to_string())?;',
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'AI game creator workspace switch must close the source window',
|
||
);
|
||
}
|
||
}
|
||
|
||
function collectProductionShellFiles(entryPath) {
|
||
const normalizedPath = entryPath.split(path.sep).join('/');
|
||
if (productionShellExcludedPaths.has(normalizedPath)) {
|
||
return [];
|
||
}
|
||
|
||
if (!fs.existsSync(entryPath)) {
|
||
throw new Error(`production shell scan path does not exist: ${entryPath}`);
|
||
}
|
||
|
||
const stats = fs.statSync(entryPath);
|
||
if (stats.isDirectory()) {
|
||
const name = path.basename(entryPath);
|
||
if (productionShellExcludedSegments.has(name)) {
|
||
return [];
|
||
}
|
||
|
||
return fs
|
||
.readdirSync(entryPath, { withFileTypes: true })
|
||
.flatMap((entry) =>
|
||
collectProductionShellFiles(path.join(entryPath, entry.name)),
|
||
);
|
||
}
|
||
|
||
return shouldScanProductionShellFile(entryPath) ? [entryPath] : [];
|
||
}
|
||
|
||
function collectFiles(entryPath, shouldIncludeFile) {
|
||
if (!fs.existsSync(entryPath)) {
|
||
throw new Error(`scan path does not exist: ${entryPath}`);
|
||
}
|
||
|
||
const stats = fs.statSync(entryPath);
|
||
if (stats.isDirectory()) {
|
||
const name = path.basename(entryPath);
|
||
if (productionShellExcludedSegments.has(name)) {
|
||
return [];
|
||
}
|
||
|
||
return fs
|
||
.readdirSync(entryPath, { withFileTypes: true })
|
||
.flatMap((entry) =>
|
||
collectFiles(path.join(entryPath, entry.name), shouldIncludeFile),
|
||
);
|
||
}
|
||
|
||
return shouldIncludeFile(entryPath) ? [entryPath] : [];
|
||
}
|
||
|
||
function normalizeModulePath(modulePath) {
|
||
return modulePath
|
||
.split(path.sep)
|
||
.join('/')
|
||
.replace(/\.(jsx?|tsx?)$/, '');
|
||
}
|
||
|
||
function importedModulePath(fromFile, specifier) {
|
||
if (specifier === '@') {
|
||
return '.';
|
||
}
|
||
if (specifier.startsWith('@/')) {
|
||
return normalizeModulePath(specifier.slice(2));
|
||
}
|
||
if (!specifier.startsWith('.')) {
|
||
return specifier;
|
||
}
|
||
|
||
return normalizeModulePath(
|
||
path.normalize(path.join(path.dirname(fromFile), specifier)),
|
||
);
|
||
}
|
||
|
||
function extractImportSpecifiers(source) {
|
||
const specifiers = [];
|
||
for (const match of source.matchAll(
|
||
/import\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]/g,
|
||
)) {
|
||
specifiers.push(match[1]);
|
||
}
|
||
for (const match of source.matchAll(
|
||
/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+['"]([^'"]+)['"]/g,
|
||
)) {
|
||
specifiers.push(match[1]);
|
||
}
|
||
for (const match of source.matchAll(/import\s*['"]([^'"]+)['"]/g)) {
|
||
specifiers.push(match[1]);
|
||
}
|
||
|
||
return specifiers;
|
||
}
|
||
|
||
function extractNamedImportsFromModule(source, fromFile, targetModule) {
|
||
const importedNames = new Set();
|
||
const importPattern =
|
||
/import\s+(?:type\s+)?(?:\{([\s\S]*?)\}|[A-Za-z0-9_$]+\s*,\s*\{([\s\S]*?)\})\s+from\s+['"]([^'"]+)['"]/g;
|
||
const exportPattern =
|
||
/export\s+(?:type\s+)?\{([\s\S]*?)\}\s+from\s+['"]([^'"]+)['"]/g;
|
||
const collectNamedImports = (importBody) => {
|
||
for (const rawImport of importBody.split(',')) {
|
||
const cleanedImport = rawImport.replace(/\btype\s+/g, '').trim();
|
||
if (!cleanedImport) {
|
||
continue;
|
||
}
|
||
importedNames.add(cleanedImport.split(/\s+as\s+/)[0]?.trim() ?? '');
|
||
}
|
||
};
|
||
|
||
for (const match of source.matchAll(importPattern)) {
|
||
if (importedModulePath(fromFile, match[3]) !== targetModule) {
|
||
continue;
|
||
}
|
||
|
||
collectNamedImports(match[1] ?? match[2] ?? '');
|
||
}
|
||
|
||
for (const match of source.matchAll(exportPattern)) {
|
||
if (importedModulePath(fromFile, match[2]) !== targetModule) {
|
||
continue;
|
||
}
|
||
|
||
collectNamedImports(match[1] ?? '');
|
||
}
|
||
|
||
return importedNames;
|
||
}
|
||
|
||
function collectH5HostBridgeCallChainFiles() {
|
||
const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile);
|
||
const wrapperModules = new Set(
|
||
h5HostBridgeCallChainWrapperFiles.map(normalizeModulePath),
|
||
);
|
||
const scannedFiles = new Set();
|
||
|
||
for (const file of sourceFiles) {
|
||
const source = fs.readFileSync(file, 'utf8');
|
||
const imports = extractImportSpecifiers(source).map((specifier) =>
|
||
importedModulePath(file, specifier),
|
||
);
|
||
const facadeImports = extractNamedImportsFromModule(
|
||
source,
|
||
file,
|
||
h5HostBridgeFacadeModule,
|
||
);
|
||
const importsScannedFacadeCapability = [...facadeImports].some(
|
||
(importName) => h5HostBridgeScannedFacadeImports.has(importName),
|
||
);
|
||
if (
|
||
importsScannedFacadeCapability ||
|
||
imports.some((specifier) => wrapperModules.has(specifier))
|
||
) {
|
||
scannedFiles.add(file);
|
||
}
|
||
}
|
||
|
||
for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) {
|
||
scannedFiles.add(wrapperFile);
|
||
}
|
||
|
||
const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter(
|
||
(file) => !scannedFiles.has(file),
|
||
);
|
||
if (missingRequiredFiles.length > 0) {
|
||
throw new Error(
|
||
`H5 HostBridge call chain scan is missing required files: ${missingRequiredFiles.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
return [...scannedFiles].sort();
|
||
}
|
||
|
||
function assertH5NativeAppTransportFacadeBoundary() {
|
||
const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile);
|
||
const directTransportImports = [];
|
||
|
||
for (const file of sourceFiles) {
|
||
const source = fs.readFileSync(file, 'utf8');
|
||
const imports = extractImportSpecifiers(source).map((specifier) =>
|
||
importedModulePath(file, specifier),
|
||
);
|
||
if (imports.includes(h5NativeAppHostBridgeTransportModule)) {
|
||
directTransportImports.push(file);
|
||
}
|
||
}
|
||
|
||
if (directTransportImports.length > 0) {
|
||
throw new Error(
|
||
`H5 production code must use the HostBridge facade instead of native app transport directly: ${directTransportImports.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertNoDevScaffoldTermsInFiles(files, terms) {
|
||
for (const file of files) {
|
||
const source = fs.readFileSync(file, 'utf8');
|
||
const lowerSource = source.toLowerCase();
|
||
for (const term of terms) {
|
||
const matchIndex = lowerSource.indexOf(term.toLowerCase());
|
||
if (matchIndex === -1) {
|
||
continue;
|
||
}
|
||
|
||
const line = source.slice(0, matchIndex).split('\n').length;
|
||
throw new Error(
|
||
`production native shell source must not include ${term}: ${file}:${line}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertNoProductionShellDevScaffoldTerms() {
|
||
assertNoDevScaffoldTermsInFiles(
|
||
productionShellScanRoots.flatMap(collectProductionShellFiles),
|
||
productionShellDevScaffoldTerms,
|
||
);
|
||
assertNoDevScaffoldTermsInFiles(
|
||
collectH5HostBridgeCallChainFiles().flatMap(collectProductionShellFiles),
|
||
h5HostBridgeCallChainDevScaffoldTerms,
|
||
);
|
||
}
|
||
|
||
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(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertUniqueList(values, label) {
|
||
const seen = new Set();
|
||
const duplicates = values.filter((value) => {
|
||
if (seen.has(value)) {
|
||
return true;
|
||
}
|
||
seen.add(value);
|
||
return false;
|
||
});
|
||
if (duplicates.length > 0) {
|
||
throw new Error(
|
||
`${label} must not contain duplicate entries: ${duplicates.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertSameSet(actual, expected, label) {
|
||
const sortedActual = [...actual].sort();
|
||
const sortedExpected = [...expected].sort();
|
||
assertSameList(sortedActual, sortedExpected, label);
|
||
}
|
||
|
||
function readDirectoryEntryList(directory, label) {
|
||
const entries = fs
|
||
.readdirSync(directory, { withFileTypes: true })
|
||
.map((entry) => {
|
||
if (entry.isFile()) {
|
||
return `file:${entry.name}`;
|
||
}
|
||
if (entry.isDirectory()) {
|
||
return `dir:${entry.name}`;
|
||
}
|
||
throw new Error(
|
||
`${label} must not contain special entries: ${entry.name}`,
|
||
);
|
||
})
|
||
.sort();
|
||
if (entries.length === 0) {
|
||
throw new Error(`${label} must not be empty`);
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
function readDirectoryFileList(directory, label) {
|
||
const files = [];
|
||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||
if (!entry.isFile()) {
|
||
throw new Error(
|
||
`${label} must not contain nested entries: ${entry.name}`,
|
||
);
|
||
}
|
||
files.push(entry.name);
|
||
}
|
||
return files.sort();
|
||
}
|
||
|
||
function readDirectoryNameList(directory, label) {
|
||
const directories = [];
|
||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||
if (!entry.isDirectory()) {
|
||
throw new Error(`${label} must not contain root files: ${entry.name}`);
|
||
}
|
||
directories.push(entry.name);
|
||
}
|
||
return directories.sort();
|
||
}
|
||
|
||
function assertShellLayerLayoutDocumented(source, label) {
|
||
const canonicalMarker = '结构门禁按完整相对路径反查文档和目录';
|
||
const pointerMarker =
|
||
'当前 `npm run check:native-shells` 锁定的生产文件清单以本文后续“结构门禁按完整相对路径反查文档和目录”段落为唯一文档口径';
|
||
if (!source.includes(pointerMarker)) {
|
||
throw new Error(
|
||
`${label} must point short shell file lists to the canonical full-path section`,
|
||
);
|
||
}
|
||
const canonicalStart = source.indexOf(canonicalMarker);
|
||
if (canonicalStart < 0) {
|
||
throw new Error(`${label} missing canonical shell layer layout section`);
|
||
}
|
||
const canonicalSource = source.slice(canonicalStart);
|
||
for (const group of documentedShellLayerGroups) {
|
||
for (const fileName of group.files) {
|
||
if (!canonicalSource.includes(fileName)) {
|
||
throw new Error(`${label} missing ${group.label}: ${fileName}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function extractTsStringArray(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(...extractTsStringArray(source, entry[1], nextSeen));
|
||
} else {
|
||
entries.push(entry[2]);
|
||
}
|
||
}
|
||
|
||
return entries;
|
||
}
|
||
|
||
function extractRustCapabilities(source) {
|
||
const match = source.match(
|
||
/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/,
|
||
);
|
||
if (!match) {
|
||
throw new Error('unable to read desktop shell capabilities');
|
||
}
|
||
|
||
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
|
||
}
|
||
|
||
function extractRustStringArray(source, constName) {
|
||
const match = source.match(
|
||
new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`),
|
||
);
|
||
if (!match) {
|
||
throw new Error(`unable to read Rust ${constName}`);
|
||
}
|
||
|
||
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
|
||
}
|
||
|
||
function extractStringConst(source, constName) {
|
||
const match = source.match(
|
||
new RegExp(`const ${constName}\\s*=\\s*['"]([^'"]+)['"];`),
|
||
);
|
||
if (!match) {
|
||
throw new Error(`unable to read string const ${constName}`);
|
||
}
|
||
|
||
return match[1];
|
||
}
|
||
|
||
function escapeRegExp(value) {
|
||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
function extractFunctionSource(source, functionName) {
|
||
const declarationStart = source.indexOf(`function ${functionName}`);
|
||
const exportedDeclarationStart = source.indexOf(
|
||
`export function ${functionName}`,
|
||
);
|
||
const start =
|
||
exportedDeclarationStart === -1
|
||
? declarationStart
|
||
: exportedDeclarationStart;
|
||
if (start === -1) {
|
||
throw new Error(`unable to read function ${functionName}`);
|
||
}
|
||
|
||
const openBrace = source.indexOf('{', start);
|
||
if (openBrace === -1) {
|
||
throw new Error(`unable to read function body ${functionName}`);
|
||
}
|
||
|
||
let depth = 0;
|
||
for (let index = openBrace; index < source.length; index += 1) {
|
||
if (source[index] === '{') {
|
||
depth += 1;
|
||
} else if (source[index] === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) {
|
||
return source.slice(start, index + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new Error(`unterminated function body ${functionName}`);
|
||
}
|
||
|
||
function extractRustFunctionSource(source, functionName) {
|
||
const start = source.indexOf(`fn ${functionName}`);
|
||
if (start === -1) {
|
||
throw new Error(`unable to read Rust function ${functionName}`);
|
||
}
|
||
|
||
const openBrace = source.indexOf('{', start);
|
||
if (openBrace === -1) {
|
||
throw new Error(`unable to read Rust function body ${functionName}`);
|
||
}
|
||
|
||
let depth = 0;
|
||
for (let index = openBrace; index < source.length; index += 1) {
|
||
if (source[index] === '{') {
|
||
depth += 1;
|
||
} else if (source[index] === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) {
|
||
return source.slice(start, index + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new Error(`unterminated Rust function body ${functionName}`);
|
||
}
|
||
|
||
function extractTsStringObject(source, exportName) {
|
||
const match = source.match(
|
||
new RegExp(
|
||
`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`,
|
||
),
|
||
);
|
||
if (!match) {
|
||
throw new Error(`unable to read ${exportName}`);
|
||
}
|
||
|
||
return Object.fromEntries(
|
||
[...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [
|
||
entry[1],
|
||
entry[2],
|
||
]),
|
||
);
|
||
}
|
||
|
||
function assertH5HostBridgeEventSubscriptionGates() {
|
||
const sharedContractSource = fs.readFileSync(
|
||
sharedHostBridgeContractPath,
|
||
'utf8',
|
||
);
|
||
const h5HostBridgeSource = fs.readFileSync(
|
||
'src/services/host-bridge/hostBridge.ts',
|
||
'utf8',
|
||
);
|
||
const sharedEvents = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_EVENTS',
|
||
);
|
||
|
||
assertSameList(
|
||
h5HostBridgeEventSubscriptionFacades.map((entry) => entry.eventName),
|
||
sharedEvents,
|
||
'H5 HostBridge event subscription facade coverage',
|
||
);
|
||
|
||
const helperSource = extractFunctionSource(
|
||
h5HostBridgeSource,
|
||
'canUseNativeHostEventCapability',
|
||
);
|
||
if (
|
||
!helperSource.includes("canUseNativeHostCapability('host.events')") ||
|
||
!helperSource.includes('canUseNativeHostCapability(capability)')
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge event capability helper must require host.events and the event capability',
|
||
);
|
||
}
|
||
|
||
const subscribedEvents = [
|
||
...h5HostBridgeSource.matchAll(
|
||
/subscribeNativeAppHostBridgeEvent(?:<[^>]+>)?\(\s*['"]([^'"]+)['"]/g,
|
||
),
|
||
].map((entry) => entry[1]);
|
||
assertSameList(
|
||
subscribedEvents,
|
||
sharedEvents,
|
||
'H5 HostBridge subscribed event list',
|
||
);
|
||
|
||
for (const {
|
||
functionName,
|
||
eventName,
|
||
} of h5HostBridgeEventSubscriptionFacades) {
|
||
const functionSource = extractFunctionSource(
|
||
h5HostBridgeSource,
|
||
functionName,
|
||
);
|
||
if (
|
||
!functionSource.includes(
|
||
`canUseNativeHostEventCapability('${eventName}')`,
|
||
)
|
||
) {
|
||
throw new Error(
|
||
`${functionName} must gate ${eventName} with host.events and the event capability`,
|
||
);
|
||
}
|
||
|
||
const directCapabilityPattern = new RegExp(
|
||
`canUseNativeHostCapability\\('${escapeRegExp(eventName)}'\\)`,
|
||
);
|
||
if (directCapabilityPattern.test(functionSource)) {
|
||
throw new Error(
|
||
`${functionName} must not bypass canUseNativeHostEventCapability for ${eventName}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertH5HostBridgePayloadBoundaries() {
|
||
const sharedContractSource = fs.readFileSync(
|
||
'packages/shared/src/contracts/hostBridge.ts',
|
||
'utf8',
|
||
);
|
||
const h5HostBridgeSource = fs.readFileSync(
|
||
'src/services/host-bridge/hostBridge.ts',
|
||
'utf8',
|
||
);
|
||
const h5HostBridgeTestSource = fs.readFileSync(
|
||
'src/services/host-bridge/hostBridge.test.ts',
|
||
'utf8',
|
||
);
|
||
const nativeRequestCapabilities = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_METHODS',
|
||
).filter((method) => method !== 'host.getRuntime');
|
||
|
||
for (const sharedBoundary of [
|
||
'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
|
||
'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS',
|
||
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
|
||
]) {
|
||
if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) {
|
||
throw new Error(
|
||
`H5 HostBridge facade must import shared HostBridge boundary ${sharedBoundary}`,
|
||
);
|
||
}
|
||
if (
|
||
new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(
|
||
h5HostBridgeSource,
|
||
)
|
||
) {
|
||
throw new Error(
|
||
`H5 HostBridge facade must not redeclare shared payload boundary ${sharedBoundary}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const mimeLiteral of [
|
||
"'text/plain'",
|
||
"'text/markdown'",
|
||
"'text/csv'",
|
||
"'application/json'",
|
||
"'image/png'",
|
||
"'image/jpeg'",
|
||
"'image/webp'",
|
||
"'audio/mpeg'",
|
||
"'audio/mp4'",
|
||
"'audio/wav'",
|
||
"'audio/ogg'",
|
||
"'audio/webm'",
|
||
]) {
|
||
if (h5HostBridgeSource.includes(mimeLiteral)) {
|
||
throw new Error(
|
||
`H5 HostBridge facade must read MIME boundaries from shared contract instead of ${mimeLiteral}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must use shared HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
|
||
);
|
||
}
|
||
if (h5HostBridgeSource.includes('timeoutMs: 30000')) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must use shared HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS',
|
||
);
|
||
}
|
||
if (h5HostBridgeSource.includes('timeoutMs: 60000')) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must use shared HOST_BRIDGE_SCANNER_TIMEOUT_MS',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'return normalizeHostBridgeExternalUrlPayload(trimmedUrl);',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
'return normalizeHostBridgeExternalUrlPayload(\n new URL(trimmedUrl, window.location.origin).toString(),\n );',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
'const normalizedPayload = normalizeHostExternalUrlPayload(url);',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
"return await requestNativeHostBoolean(\n 'app.openExternalUrl',\n normalizedPayload,\n );",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize app.openExternalUrl payloads with the shared external URL boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'function normalizeNativeAppPageUrl(url: string)',
|
||
) ||
|
||
!h5HostBridgeSource.includes("trimmedUrl.startsWith('//')") ||
|
||
!h5HostBridgeSource.includes(
|
||
'nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
'const normalizedUrl = normalizeNativeAppPageUrl(url);',
|
||
) ||
|
||
!h5HostBridgeSource.includes('{ url: normalizedUrl },')
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
"'[host-bridge] wechat mini program navigation failed'",
|
||
) ||
|
||
!h5HostBridgeSource.includes('reject(new Error(errorMessage));') ||
|
||
h5HostBridgeSource.includes(
|
||
"console.error(\n '[host-bridge] wechat mini program navigation failed'",
|
||
) ||
|
||
h5HostBridgeSource.includes(
|
||
'reject(new Error(error?.errMsg || errorMessage));',
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must not expose wx.miniProgram.navigateTo native failures',
|
||
);
|
||
}
|
||
for (const snippet of [
|
||
'hides wechat mini program navigation native failure details',
|
||
"errMsg: 'navigateTo:fail private native detail'",
|
||
"rejects.toThrow(\n '请在微信小程序内完成登录',",
|
||
"rejects.not.toThrow(\n 'private native detail',",
|
||
'expect(consoleError.mock.calls.flat()).not.toContain(navigationError)',
|
||
]) {
|
||
if (!h5HostBridgeTestSource.includes(snippet)) {
|
||
throw new Error(
|
||
`H5 HostBridge navigation failure test must include ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes('absolutizeHostSharePayloadUrls(params),') ||
|
||
!h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") ||
|
||
!h5HostBridgeSource.includes(
|
||
"return await requestNativeHostBoolean(\n 'share.open',\n normalizedPayload.payload,\n );",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize share.open payloads with the shared share boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const normalizedPayload = normalizeHostBridgeShareOpenPayload(message);',
|
||
) ||
|
||
!h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") ||
|
||
!h5HostBridgeSource.includes("'share.setTarget', {\n target: message,")
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must validate native share.setTarget payloads before sending them to native shells',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const normalizedPayload = normalizeHostBridgeExportTextPayload(params);',
|
||
) ||
|
||
!h5HostBridgeSource.includes("'file.exportText',\n normalizedPayload,")
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize file.exportText payloads with the shared text export boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const normalizedPayload = normalizeHostBridgeExportImagePayload(params);',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
"'file.exportImage',\n normalizedPayload,",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize file.exportImage payloads with the shared image export boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const normalizedPayload = normalizeHostBridgeExportAudioPayload(params);',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
"'file.exportAudio',\n normalizedPayload,",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize file.exportAudio payloads with the shared audio export boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const clipboardText = normalizeHostBridgeClipboardText(text);',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
"return await requestNativeHostBoolean('clipboard.writeText', clipboardText);",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize clipboard.writeText payloads with the shared clipboard boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const clipboardText = normalizeHostBridgeClipboardText(',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
"await requestNativeAppHostBridge<ClipboardReadTextResult>(\n 'clipboard.readText',",
|
||
) ||
|
||
!h5HostBridgeSource.includes('return clipboardText ?? false;')
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize clipboard.readText results with the shared clipboard boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const style = normalizeHostBridgeHapticsImpactStyle(params.style);',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
"return await requestNativeHostBoolean('haptics.impact', { style });",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize haptics.impact payloads with the shared style boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'colorScheme: normalizeHostBridgeColorScheme(result?.colorScheme),',
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize appearance.getColorScheme results with the shared color scheme boundary',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const connectionType = normalizeHostBridgeConnectionType(\n payload?.connectionType ?? payload?.nativeType,\n );',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
'listener(normalizeHostNetworkStatus(payload));',
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize network.status results and network.statusChanged events with shared network boundaries',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
'const normalizedTitle = normalizeHostBridgeAppTitle(title);',
|
||
) ||
|
||
!h5HostBridgeSource.includes(
|
||
"return await requestNativeHostBoolean('app.setTitle', normalizedTitle);",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must normalize app.setTitle payloads with the shared title boundary',
|
||
);
|
||
}
|
||
for (const nativeCapability of nativeRequestCapabilities) {
|
||
if (
|
||
h5HostBridgeSource.includes(
|
||
`runtime.hostCapabilities.includes('${nativeCapability}')`,
|
||
) ||
|
||
h5HostBridgeSource.includes(
|
||
`runtime.hostCapabilities.includes("${nativeCapability}")`,
|
||
)
|
||
) {
|
||
throw new Error(
|
||
`H5 HostBridge facade must gate ${nativeCapability} through canUseNativeHostCapability()`,
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes(
|
||
`canUseNativeHostCapability('${nativeCapability}')`,
|
||
)
|
||
) {
|
||
throw new Error(
|
||
`H5 HostBridge facade must check ${nativeCapability} with canUseNativeHostCapability()`,
|
||
);
|
||
}
|
||
}
|
||
for (const importBoundary of [
|
||
{
|
||
functionName: 'importHostTextFile',
|
||
normalizer: 'normalizeHostBridgeImportTextResult',
|
||
preserveCancellation: true,
|
||
},
|
||
{
|
||
functionName: 'importHostDocumentFile',
|
||
normalizer: 'normalizeHostBridgeImportDocumentResult',
|
||
preserveCancellation: true,
|
||
},
|
||
{
|
||
functionName: 'importHostImageFile',
|
||
normalizer: 'normalizeHostBridgeImportImageResult',
|
||
preserveCancellation: true,
|
||
},
|
||
{
|
||
functionName: 'captureHostImageFile',
|
||
normalizer: 'normalizeHostBridgeImportImageResult',
|
||
preserveCancellation: true,
|
||
},
|
||
{
|
||
functionName: 'importHostAudioFile',
|
||
normalizer: 'normalizeHostBridgeImportAudioResult',
|
||
preserveCancellation: true,
|
||
},
|
||
{
|
||
functionName: 'subscribeHostImageDrop',
|
||
normalizer: 'normalizeHostBridgeImportImageResult',
|
||
preserveCancellation: false,
|
||
},
|
||
]) {
|
||
const functionSource = extractFunctionSource(
|
||
h5HostBridgeSource,
|
||
importBoundary.functionName,
|
||
);
|
||
if (!functionSource.includes(`${importBoundary.normalizer}(`)) {
|
||
throw new Error(
|
||
`${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`,
|
||
);
|
||
}
|
||
if (
|
||
importBoundary.preserveCancellation &&
|
||
(!functionSource.includes('isCancelledHostBridgeError(error)') ||
|
||
!functionSource.includes('return null;'))
|
||
) {
|
||
throw new Error(
|
||
`${importBoundary.functionName} must preserve native user cancellation as null`,
|
||
);
|
||
}
|
||
}
|
||
const unsupportedHelperSource = extractFunctionSource(
|
||
h5HostBridgeSource,
|
||
'isUnsupportedHostBridgeError',
|
||
);
|
||
if (unsupportedHelperSource.includes("'cancelled'")) {
|
||
throw new Error(
|
||
'H5 HostBridge facade must not treat native user cancellation as unsupported capability',
|
||
);
|
||
}
|
||
for (const localMimeSet of [
|
||
'HOST_BRIDGE_TEXT_MIME_TYPE_SET',
|
||
'HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET',
|
||
'HOST_BRIDGE_IMAGE_MIME_TYPE_SET',
|
||
'HOST_BRIDGE_AUDIO_MIME_TYPE_SET',
|
||
]) {
|
||
if (h5HostBridgeSource.includes(localMimeSet)) {
|
||
throw new Error(
|
||
`H5 HostBridge facade must use shared import normalizers instead of local ${localMimeSet}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertH5NativeAppTransportTimeoutBoundaries() {
|
||
const nativeAppHostBridgeSource = fs.readFileSync(
|
||
'src/services/host-bridge/nativeAppHostBridge.ts',
|
||
'utf8',
|
||
);
|
||
|
||
for (const sharedBoundary of [
|
||
'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS',
|
||
'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS',
|
||
]) {
|
||
if (!nativeAppHostBridgeSource.includes(`${sharedBoundary},`)) {
|
||
throw new Error(
|
||
`H5 native app transport must import shared timeout boundary ${sharedBoundary}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const staleTimeoutBoundary of [
|
||
'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS',
|
||
'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS',
|
||
]) {
|
||
if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) {
|
||
throw new Error(
|
||
`H5 native app transport must not redeclare timeout boundary ${staleTimeoutBoundary}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertH5NativeAppMessageSourceBoundaries() {
|
||
const nativeAppHostBridgeSource = fs.readFileSync(
|
||
'src/services/host-bridge/nativeAppHostBridge.ts',
|
||
'utf8',
|
||
);
|
||
const nativeAppHostBridgeTestSource = fs.readFileSync(
|
||
'src/services/host-bridge/nativeAppHostBridge.test.ts',
|
||
'utf8',
|
||
);
|
||
|
||
const requiredSourceSnippets = [
|
||
'function isNativeInjectedMessageEvent(event: MessageEvent)',
|
||
'event.source && event.source !== nativeWindow',
|
||
'event.origin && event.origin !== nativeWindow.location.origin',
|
||
'if (!isNativeInjectedMessageEvent(event))',
|
||
];
|
||
for (const snippet of requiredSourceSnippets) {
|
||
if (!nativeAppHostBridgeSource.includes(snippet)) {
|
||
throw new Error(
|
||
`H5 native app transport must verify injected message source and origin: ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const requiredTestSnippets = [
|
||
'忽略非当前窗口来源伪造的 HostBridge 回包',
|
||
'source: channel.port1',
|
||
'忽略非当前页面来源伪造的宿主事件',
|
||
"origin: 'https://sandbox.genarrative.invalid'",
|
||
'expect(listener).not.toHaveBeenCalled();',
|
||
];
|
||
for (const snippet of requiredTestSnippets) {
|
||
if (!nativeAppHostBridgeTestSource.includes(snippet)) {
|
||
throw new Error(
|
||
`H5 native app transport source boundary test must include ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function extractDocumentCapabilityList(source, marker) {
|
||
return extractDocumentCapabilityListBefore(source, marker, '。');
|
||
}
|
||
|
||
function extractDocumentCapabilityListBefore(source, marker, terminator) {
|
||
const markerIndex = source.indexOf(marker);
|
||
if (markerIndex === -1) {
|
||
throw new Error(`native shell plan missing ${marker}`);
|
||
}
|
||
|
||
const sentenceEnd = source.indexOf(terminator, markerIndex);
|
||
const sentence = source.slice(
|
||
markerIndex,
|
||
sentenceEnd === -1 ? undefined : sentenceEnd,
|
||
);
|
||
return [...sentence.matchAll(/`([^`]+)`/g)].map((entry) => entry[1]);
|
||
}
|
||
|
||
function extractDocumentMethodTable(source) {
|
||
const start = source.indexOf('首批 method:');
|
||
if (start === -1) {
|
||
throw new Error('native shell plan missing HostBridge method table');
|
||
}
|
||
|
||
const end = source.indexOf('每个 method 都必须', start);
|
||
if (end === -1) {
|
||
throw new Error('native shell plan method table missing end marker');
|
||
}
|
||
|
||
return [...source.slice(start, end).matchAll(/^\| `([^`]+)` \|/gm)].map(
|
||
(entry) => entry[1],
|
||
);
|
||
}
|
||
|
||
function assertNativeShellScaffoldScanWording(source, label) {
|
||
if (
|
||
source.includes('三端生产壳临时替身词扫描') ||
|
||
source.includes('三端壳生产源码') ||
|
||
source.includes('壳生产源码禁替身') ||
|
||
!source.includes('H5 HostBridge 真实调用链的临时替身词扫描')
|
||
) {
|
||
throw new Error(
|
||
`${label} must document production scaffold scanning for the H5 HostBridge call chain`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertNativeShellCapabilityPlan() {
|
||
const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8');
|
||
const hostBridgeProtocolDocSource = fs.readFileSync(
|
||
hostBridgeProtocolDocPath,
|
||
'utf8',
|
||
);
|
||
const developmentWorkflowDocSource = fs.readFileSync(
|
||
developmentWorkflowDocPath,
|
||
'utf8',
|
||
);
|
||
const decisionLogDocSource = fs.readFileSync(decisionLogDocPath, 'utf8');
|
||
if (
|
||
planSource.includes('permissions` 必须只包含 `core:default`') ||
|
||
planSource.includes(
|
||
'permissions=["core:default","allow-host-bridge-request"]',
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'native shell plan must not document core:default as a desktop capability permission',
|
||
);
|
||
}
|
||
if (
|
||
!planSource.includes('主窗口 capability 只授予 `allow-host-bridge-request`')
|
||
) {
|
||
throw new Error(
|
||
'native shell plan must document the minimal desktop capability permission',
|
||
);
|
||
}
|
||
for (const staleShareWording of [
|
||
'深链、系统分享、即时本地通知',
|
||
'重复执行支付、登录、系统分享、文件导入导出',
|
||
'发布分享弹窗只有声明 `share.open` 时才显示“系统分享”',
|
||
'发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作',
|
||
]) {
|
||
if (
|
||
planSource.includes(staleShareWording) ||
|
||
hostBridgeProtocolDocSource.includes(staleShareWording) ||
|
||
decisionLogDocSource.includes(staleShareWording)
|
||
) {
|
||
throw new Error(
|
||
`native shell docs must describe share.open as a host-specific controlled share action: ${staleShareWording}`,
|
||
);
|
||
}
|
||
}
|
||
for (const requiredShareWording of [
|
||
'深链、受控分享动作、即时本地通知',
|
||
'重复 `id` 不得重复执行支付、登录、受控分享动作、文件导入导出',
|
||
'发布分享弹窗在 Expo 移动壳声明 `share.open` 时提供“系统分享”动作',
|
||
'发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”',
|
||
'并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达',
|
||
]) {
|
||
if (
|
||
!planSource.includes(requiredShareWording) &&
|
||
!hostBridgeProtocolDocSource.includes(requiredShareWording) &&
|
||
!decisionLogDocSource.includes(requiredShareWording)
|
||
) {
|
||
throw new Error(
|
||
`native shell docs missing host-specific share.open wording: ${requiredShareWording}`,
|
||
);
|
||
}
|
||
}
|
||
for (const staleMobilePermissionText of [
|
||
'Android `permissions` 不手写显式权限',
|
||
'最终 Expo public config 只允许扫码能力由 `expo-camera` plugin 带入 `android.permission.CAMERA`',
|
||
'移动拍摄不请求麦克风权限',
|
||
]) {
|
||
if (
|
||
planSource.includes(staleMobilePermissionText) ||
|
||
hostBridgeProtocolDocSource.includes(staleMobilePermissionText)
|
||
) {
|
||
throw new Error(
|
||
`native shell docs must not keep stale mobile permission wording: ${staleMobilePermissionText}`,
|
||
);
|
||
}
|
||
}
|
||
assertNativeShellScaffoldScanWording(planSource, 'native shell plan');
|
||
assertNativeShellScaffoldScanWording(
|
||
hostBridgeProtocolDocSource,
|
||
'HostBridge protocol document',
|
||
);
|
||
assertNativeShellScaffoldScanWording(
|
||
developmentWorkflowDocSource,
|
||
'development workflow document',
|
||
);
|
||
assertNativeShellScaffoldScanWording(
|
||
decisionLogDocSource,
|
||
'decision log document',
|
||
);
|
||
assertShellLayerLayoutDocumented(planSource, 'native shell plan');
|
||
assertShellLayerLayoutDocumented(
|
||
hostBridgeProtocolDocSource,
|
||
'HostBridge protocol document',
|
||
);
|
||
|
||
const desktopCapabilitySource = fs.readFileSync(
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs',
|
||
'utf8',
|
||
);
|
||
const sharedContractSource = fs.readFileSync(
|
||
sharedHostBridgeContractPath,
|
||
'utf8',
|
||
);
|
||
const sharedMethods = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_METHODS',
|
||
);
|
||
const sharedEvents = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_EVENTS',
|
||
);
|
||
const sharedWechatCapabilities = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES',
|
||
);
|
||
const mobileCapabilities = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
|
||
);
|
||
const iosMobileCapabilities = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
|
||
);
|
||
const iosExtraCapabilities = iosMobileCapabilities.filter(
|
||
(capability) => !mobileCapabilities.includes(capability),
|
||
);
|
||
const sharedDesktopCapabilities = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
|
||
);
|
||
const desktopCapabilities = extractRustCapabilities(desktopCapabilitySource);
|
||
const desktopEventSource = fs.readFileSync(
|
||
'apps/desktop-shell/src-tauri/src/shell/events.rs',
|
||
'utf8',
|
||
);
|
||
const mobileDispatchTestSource = fs.readFileSync(
|
||
'apps/mobile-shell/src/host-bridge/dispatch.test.ts',
|
||
'utf8',
|
||
);
|
||
const mobileBridgeTestSource = fs.readFileSync(
|
||
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
|
||
'utf8',
|
||
);
|
||
const desktopDispatchSource = fs.readFileSync(
|
||
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
|
||
'utf8',
|
||
);
|
||
const wechatProtocol = requireCommonJsModule(
|
||
'miniprogram/host-bridge/protocol.js',
|
||
);
|
||
|
||
assertSameList(
|
||
wechatProtocol.WECHAT_HOST_CAPABILITIES ?? [],
|
||
sharedWechatCapabilities,
|
||
'wechat mini program runtime capability profile',
|
||
);
|
||
|
||
const desktopEventWhitelist = extractRustStringArray(
|
||
desktopEventSource,
|
||
'HOST_BRIDGE_EVENTS',
|
||
);
|
||
const sharedDesktopEvents = sharedDesktopCapabilities.filter((capability) =>
|
||
sharedEvents.includes(capability),
|
||
);
|
||
assertSameList(
|
||
desktopEventWhitelist,
|
||
sharedDesktopEvents,
|
||
'desktop shell runtime event whitelist',
|
||
);
|
||
if (sharedDesktopCapabilities.includes('network.statusChanged')) {
|
||
throw new Error(
|
||
'desktop shell must not declare network.statusChanged until Rust owns a real event source',
|
||
);
|
||
}
|
||
if (desktopEventWhitelist.includes('network.statusChanged')) {
|
||
throw new Error(
|
||
'desktop shell event whitelist must not include network.statusChanged until Rust owns a real event source',
|
||
);
|
||
}
|
||
|
||
assertSameList(
|
||
desktopCapabilities,
|
||
sharedDesktopCapabilities,
|
||
'desktop shell runtime capability profile',
|
||
);
|
||
for (const [source, label, snippets] of [
|
||
[
|
||
mobileDispatchTestSource,
|
||
'mobile dispatch undeclared method test',
|
||
[
|
||
'HOST_BRIDGE_METHODS.filter',
|
||
'!HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)',
|
||
"expect(response.error.code).toBe('unsupported_method')",
|
||
],
|
||
],
|
||
[
|
||
mobileBridgeTestSource,
|
||
'mobile bridge undeclared method test',
|
||
[
|
||
'HOST_BRIDGE_METHODS.filter',
|
||
'!HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)',
|
||
"expect(failedResponse.error.code).toBe('unsupported_method')",
|
||
],
|
||
],
|
||
]) {
|
||
for (const snippet of snippets) {
|
||
if (!source.includes(snippet)) {
|
||
throw new Error(
|
||
`${label} must derive unsupported methods from shared method list`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
for (const snippet of [
|
||
'HOST_BRIDGE_METHODS\n .iter()',
|
||
'.filter(|method| !desktop_capabilities.contains(method))',
|
||
'let response = resolve_host_bridge_request(request(method));',
|
||
'assert_eq!(error.code, "unsupported_method");',
|
||
]) {
|
||
if (!desktopDispatchSource.includes(snippet)) {
|
||
throw new Error(
|
||
'desktop dispatch test must derive unsupported methods from Rust capability list',
|
||
);
|
||
}
|
||
}
|
||
|
||
assertSameList(
|
||
extractDocumentMethodTable(planSource),
|
||
[
|
||
...sharedMethods.slice(0, 8),
|
||
'app.lifecycle',
|
||
'navigation.canGoBack',
|
||
...sharedMethods.slice(8, 12),
|
||
'network.statusChanged',
|
||
...sharedMethods.slice(12, 22),
|
||
sharedMethods[22],
|
||
'file.imageDropped',
|
||
...sharedMethods.slice(23),
|
||
],
|
||
'native shell documented method table',
|
||
);
|
||
assertSameList(
|
||
extractDocumentCapabilityList(planSource, capabilityListMarkers.wechat),
|
||
sharedWechatCapabilities,
|
||
'wechat mini program documented capabilities',
|
||
);
|
||
assertSameList(
|
||
extractDocumentCapabilityList(planSource, capabilityListMarkers.mobile),
|
||
mobileCapabilities,
|
||
'mobile shell documented common capabilities',
|
||
);
|
||
assertSameSet(
|
||
extractDocumentCapabilityListBefore(
|
||
planSource,
|
||
capabilityListMarkers.mobileCurrentState,
|
||
';',
|
||
).filter((capability) => capability !== 'Android 返回键回退'),
|
||
mobileCapabilities,
|
||
'mobile shell current state documented capabilities',
|
||
);
|
||
assertSameList(
|
||
extractDocumentCapabilityList(
|
||
planSource,
|
||
capabilityListMarkers.mobileIosExtra,
|
||
),
|
||
iosExtraCapabilities,
|
||
'mobile shell documented iOS extra capabilities',
|
||
);
|
||
assertSameList(
|
||
extractDocumentCapabilityList(planSource, capabilityListMarkers.desktop),
|
||
desktopCapabilities,
|
||
'desktop shell documented capabilities',
|
||
);
|
||
assertSameSet(
|
||
extractDocumentCapabilityListBefore(
|
||
planSource,
|
||
capabilityListMarkers.desktopCurrentState,
|
||
';',
|
||
),
|
||
desktopCapabilities,
|
||
'desktop shell current state documented capabilities',
|
||
);
|
||
}
|
||
|
||
function assertExternalUrlProtocolParity() {
|
||
const sharedContractSource = fs.readFileSync(
|
||
sharedHostBridgeContractPath,
|
||
'utf8',
|
||
);
|
||
const desktopNavigationSource = fs.readFileSync(
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'utf8',
|
||
);
|
||
|
||
assertSameList(
|
||
extractRustStringArray(desktopNavigationSource, 'EXTERNAL_URL_PROTOCOLS'),
|
||
extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS',
|
||
),
|
||
'desktop shell external URL protocol list',
|
||
);
|
||
}
|
||
|
||
function requireCommonJsModule(modulePath) {
|
||
const absolutePath = path.resolve(modulePath);
|
||
const module = { exports: {} };
|
||
const sandbox = {
|
||
module,
|
||
exports: module.exports,
|
||
URL,
|
||
URLSearchParams,
|
||
};
|
||
|
||
vm.runInNewContext(fs.readFileSync(absolutePath, 'utf8'), sandbox, {
|
||
filename: absolutePath,
|
||
});
|
||
|
||
return module.exports;
|
||
}
|
||
|
||
function pageRouteFromMiniProgramUrl(url) {
|
||
return String(url).split('?')[0].replace(/^\//, '');
|
||
}
|
||
|
||
function assertHttpsDomain(value, label) {
|
||
const trimmed = String(value ?? '').trim();
|
||
let url;
|
||
try {
|
||
url = new URL(trimmed);
|
||
} catch {
|
||
throw new Error(`${label} must be a valid HTTPS domain`);
|
||
}
|
||
|
||
if (
|
||
url.protocol !== 'https:' ||
|
||
url.username ||
|
||
url.password ||
|
||
url.port ||
|
||
url.pathname !== '/' ||
|
||
url.search ||
|
||
url.hash ||
|
||
(trimmed !== url.origin && trimmed !== `${url.origin}/`) ||
|
||
url.hostname === 'localhost' ||
|
||
/^\d+\.\d+\.\d+\.\d+$/u.test(url.hostname) ||
|
||
url.hostname.includes(':') ||
|
||
!url.hostname.includes('.')
|
||
) {
|
||
throw new Error(`${label} must be an HTTPS domain without path or query`);
|
||
}
|
||
}
|
||
|
||
function assertWechatMiniProgramRouteParity() {
|
||
const sharedContractSource = fs.readFileSync(
|
||
sharedHostBridgeContractPath,
|
||
'utf8',
|
||
);
|
||
const appConfig = JSON.parse(fs.readFileSync('miniprogram/app.json', 'utf8'));
|
||
const runtimeConfig = requireCommonJsModule('miniprogram/config.js');
|
||
const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js');
|
||
const webViewBridgeSource = fs.readFileSync(
|
||
'miniprogram/host-bridge/webView.js',
|
||
'utf8',
|
||
);
|
||
const webViewShellSource = fs.readFileSync(
|
||
'miniprogram/shell/webView.js',
|
||
'utf8',
|
||
);
|
||
const appPageRoutesSource = fs.readFileSync(
|
||
'src/routing/activeAppPageRoutes.ts',
|
||
'utf8',
|
||
);
|
||
const h5HostBridgeSource = fs.readFileSync(
|
||
'src/services/host-bridge/hostBridge.ts',
|
||
'utf8',
|
||
);
|
||
assertSameList(
|
||
appConfig.pages ?? [],
|
||
[
|
||
protocol.WECHAT_WEB_VIEW_PAGE_URL,
|
||
protocol.WECHAT_SHARE_GRID_PAGE_URL,
|
||
protocol.WECHAT_PAY_PAGE_URL,
|
||
].map(pageRouteFromMiniProgramUrl),
|
||
'wechat mini program app pages',
|
||
);
|
||
|
||
const h5RoutePairs = [
|
||
['MINI_PROGRAM_AUTH_PAGE_URL', protocol.WECHAT_AUTH_PAGE_URL],
|
||
['MINI_PROGRAM_PAY_PAGE_URL', protocol.WECHAT_PAY_PAGE_URL],
|
||
['MINI_PROGRAM_SHARE_GRID_PAGE_URL', protocol.WECHAT_SHARE_GRID_PAGE_URL],
|
||
];
|
||
for (const [constName, expectedValue] of h5RoutePairs) {
|
||
const actualValue = extractStringConst(h5HostBridgeSource, constName);
|
||
if (actualValue !== expectedValue) {
|
||
throw new Error(
|
||
`H5 HostBridge ${constName} drifted: expected ${expectedValue} but got ${actualValue}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (
|
||
extractStringConst(webViewBridgeSource, 'WEB_VIEW_SHARE_PATH') !==
|
||
protocol.WECHAT_WEB_VIEW_PAGE_URL
|
||
) {
|
||
throw new Error(
|
||
'wechat mini program share path must use the web-view page route',
|
||
);
|
||
}
|
||
|
||
if (
|
||
extractStringConst(webViewBridgeSource, 'SHARE_TARGET_MESSAGE_TYPE') !==
|
||
protocol.WECHAT_SHARE_TARGET_MESSAGE_TYPE
|
||
) {
|
||
throw new Error('wechat mini program share target message type drifted');
|
||
}
|
||
|
||
for (const [configKey, label] of [
|
||
['WEB_VIEW_ENTRY_URL', 'wechat release web-view entry URL'],
|
||
['DEV_WEB_VIEW_ENTRY_URL', 'wechat dev web-view entry URL'],
|
||
['API_BASE_URL', 'wechat release API base URL'],
|
||
['DEV_API_BASE_URL', 'wechat dev API base URL'],
|
||
]) {
|
||
assertHttpsDomain(runtimeConfig[configKey], label);
|
||
}
|
||
|
||
const sourceQuery = runtimeConfig.WEB_VIEW_SOURCE_QUERY ?? {};
|
||
const sharedRuntimeContextQueryKey = extractTsStringObject(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY',
|
||
);
|
||
const sharedPreservedRuntimeContextQueryKeys = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
|
||
);
|
||
const sharedWechatSourceQuery = extractTsStringObject(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY',
|
||
);
|
||
assertSameList(
|
||
Object.keys(sourceQuery),
|
||
Object.keys(sharedWechatSourceQuery),
|
||
'wechat web-view source query keys',
|
||
);
|
||
if (
|
||
Object.entries(sharedWechatSourceQuery).some(
|
||
([key, value]) => sourceQuery[key] !== value,
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'wechat web-view source query drifted from HostBridge runtime markers',
|
||
);
|
||
}
|
||
assertSameList(
|
||
sharedPreservedRuntimeContextQueryKeys,
|
||
[
|
||
sharedRuntimeContextQueryKey.clientType,
|
||
sharedRuntimeContextQueryKey.clientRuntime,
|
||
sharedRuntimeContextQueryKey.miniProgramEnv,
|
||
sharedRuntimeContextQueryKey.hostShell,
|
||
sharedRuntimeContextQueryKey.hostPlatform,
|
||
sharedRuntimeContextQueryKey.hostVersion,
|
||
sharedRuntimeContextQueryKey.bridgeVersion,
|
||
sharedRuntimeContextQueryKey.hostCapabilities,
|
||
],
|
||
'preserved HostBridge runtime context query keys',
|
||
);
|
||
if (
|
||
!appPageRoutesSource.includes(
|
||
'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
|
||
) ||
|
||
!appPageRoutesSource.includes(
|
||
'APP_RUNTIME_CONTEXT_QUERY_KEYS =\n HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'H5 app routes must preserve HostBridge runtime context keys from shared contract',
|
||
);
|
||
}
|
||
if (
|
||
!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY') ||
|
||
h5HostBridgeSource.includes("params.get('clientType')") ||
|
||
h5HostBridgeSource.includes("params.get('clientRuntime')") ||
|
||
h5HostBridgeSource.includes("params.get('hostCapabilities')") ||
|
||
h5HostBridgeSource.includes("params.get('miniProgramEnv')")
|
||
) {
|
||
throw new Error(
|
||
'H5 HostBridge runtime parser must read query keys from shared contract',
|
||
);
|
||
}
|
||
for (const snippet of [
|
||
"readWebViewSourceQueryValue('clientType')",
|
||
"readWebViewSourceQueryValue('clientRuntime')",
|
||
]) {
|
||
if (!webViewShellSource.includes(snippet)) {
|
||
throw new Error(
|
||
'wechat request headers must read runtime markers from WEB_VIEW_SOURCE_QUERY',
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
webViewShellSource.includes('MINI_PROGRAM_CLIENT_TYPE') ||
|
||
webViewShellSource.includes('MINI_PROGRAM_CLIENT_RUNTIME')
|
||
) {
|
||
throw new Error(
|
||
'wechat request runtime markers must not be duplicated outside WEB_VIEW_SOURCE_QUERY',
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertFileIncludesSnippet(filePath, snippet, label) {
|
||
const source = fs.readFileSync(filePath, 'utf8');
|
||
if (!source.includes(snippet)) {
|
||
throw new Error(`${label} must include ${snippet} in ${filePath}`);
|
||
}
|
||
}
|
||
|
||
function assertWechatMiniProgramCapabilityFlows() {
|
||
const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js');
|
||
const declaredCapabilities = protocol.WECHAT_HOST_CAPABILITIES ?? [];
|
||
const declaredCapabilitySet = new Set(declaredCapabilities);
|
||
const contractedCapabilities = wechatCapabilityFlowContracts.map(
|
||
({ capability }) => capability,
|
||
);
|
||
|
||
assertSameList(
|
||
contractedCapabilities,
|
||
declaredCapabilities,
|
||
'wechat mini program capability flow contracts',
|
||
);
|
||
|
||
const wechatShellTestSet = new Set(wechatShellTests);
|
||
for (const contract of wechatCapabilityFlowContracts) {
|
||
if (!declaredCapabilitySet.has(contract.capability)) {
|
||
throw new Error(
|
||
`wechat capability flow contract declared for missing capability: ${contract.capability}`,
|
||
);
|
||
}
|
||
|
||
for (const filePath of contract.files) {
|
||
if (!fs.existsSync(filePath)) {
|
||
throw new Error(
|
||
`wechat ${contract.capability} flow file is missing: ${filePath}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const [filePath, snippet] of contract.snippets) {
|
||
assertFileIncludesSnippet(
|
||
filePath,
|
||
snippet,
|
||
`wechat ${contract.capability} flow`,
|
||
);
|
||
}
|
||
|
||
for (const testPath of contract.tests) {
|
||
if (!wechatShellTestSet.has(testPath)) {
|
||
throw new Error(
|
||
`wechat ${contract.capability} flow test is not part of check:native-shells: ${testPath}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertExpoMobileCapabilityFlows() {
|
||
const sharedContractSource = fs.readFileSync(
|
||
sharedHostBridgeContractPath,
|
||
'utf8',
|
||
);
|
||
const declaredCapabilities = [
|
||
...extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
|
||
),
|
||
...extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES',
|
||
),
|
||
];
|
||
const declaredCapabilitySet = new Set(declaredCapabilities);
|
||
const contractedCapabilities = mobileCapabilityFlowContracts.map(
|
||
({ capability }) => capability,
|
||
);
|
||
|
||
assertUniqueList(
|
||
contractedCapabilities,
|
||
'Expo mobile capability flow contracts',
|
||
);
|
||
const missingCapabilityContracts = declaredCapabilities.filter(
|
||
(capability) => !contractedCapabilities.includes(capability),
|
||
);
|
||
if (missingCapabilityContracts.length > 0) {
|
||
throw new Error(
|
||
`Expo mobile declared capabilities missing flow contracts: ${missingCapabilityContracts.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
const mobileShellTestSet = new Set(
|
||
expectedMobileHostBridgeFiles
|
||
.filter((fileName) => fileName.includes('.test.'))
|
||
.map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`)
|
||
.concat(
|
||
expectedMobileShellFiles
|
||
.filter((fileName) => fileName.includes('.test.'))
|
||
.map((fileName) => `apps/mobile-shell/src/shell/${fileName}`),
|
||
),
|
||
);
|
||
|
||
for (const contract of mobileCapabilityFlowContracts) {
|
||
if (!declaredCapabilitySet.has(contract.capability)) {
|
||
throw new Error(
|
||
`Expo mobile capability flow contract declared for missing capability: ${contract.capability}`,
|
||
);
|
||
}
|
||
|
||
for (const filePath of contract.files) {
|
||
if (!fs.existsSync(filePath)) {
|
||
throw new Error(
|
||
`Expo mobile ${contract.capability} flow file is missing: ${filePath}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const [filePath, snippet] of contract.snippets) {
|
||
assertFileIncludesSnippet(
|
||
filePath,
|
||
snippet,
|
||
`Expo mobile ${contract.capability} flow`,
|
||
);
|
||
}
|
||
|
||
for (const testPath of contract.tests) {
|
||
if (!mobileShellTestSet.has(testPath)) {
|
||
throw new Error(
|
||
`Expo mobile ${contract.capability} flow test is not part of mobile-shell:test: ${testPath}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertTauriDesktopCapabilityFlows() {
|
||
const sharedContractSource = fs.readFileSync(
|
||
sharedHostBridgeContractPath,
|
||
'utf8',
|
||
);
|
||
const declaredCapabilities = extractTsStringArray(
|
||
sharedContractSource,
|
||
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
|
||
);
|
||
const declaredCapabilitySet = new Set(declaredCapabilities);
|
||
const contractedCapabilities = desktopCapabilityFlowContracts.map(
|
||
({ capability }) => capability,
|
||
);
|
||
|
||
assertUniqueList(
|
||
contractedCapabilities,
|
||
'Tauri desktop capability flow contracts',
|
||
);
|
||
const missingCapabilityContracts = declaredCapabilities.filter(
|
||
(capability) => !contractedCapabilities.includes(capability),
|
||
);
|
||
if (missingCapabilityContracts.length > 0) {
|
||
throw new Error(
|
||
`Tauri desktop declared capabilities missing flow contracts: ${missingCapabilityContracts.join(', ')}`,
|
||
);
|
||
}
|
||
const desktopShellTestStep = steps.some(
|
||
(step) =>
|
||
step.label === 'desktop-shell-test' &&
|
||
step.args[0] === 'run' &&
|
||
step.args[1] === 'desktop-shell:test',
|
||
);
|
||
if (!desktopShellTestStep) {
|
||
throw new Error(
|
||
'Tauri desktop capability flows must be guarded by desktop-shell:test',
|
||
);
|
||
}
|
||
|
||
for (const contract of desktopCapabilityFlowContracts) {
|
||
if (!declaredCapabilitySet.has(contract.capability)) {
|
||
throw new Error(
|
||
`Tauri desktop capability flow contract declared for missing capability: ${contract.capability}`,
|
||
);
|
||
}
|
||
|
||
for (const filePath of contract.files) {
|
||
if (!fs.existsSync(filePath)) {
|
||
throw new Error(
|
||
`Tauri desktop ${contract.capability} flow file is missing: ${filePath}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const [filePath, snippet] of contract.snippets) {
|
||
assertFileIncludesSnippet(
|
||
filePath,
|
||
snippet,
|
||
`Tauri desktop ${contract.capability} flow`,
|
||
);
|
||
}
|
||
|
||
const testedRustFlowFiles = contract.files.filter((filePath) => {
|
||
if (
|
||
!filePath.startsWith('apps/desktop-shell/src-tauri/src/') ||
|
||
!filePath.endsWith('.rs')
|
||
) {
|
||
return false;
|
||
}
|
||
const source = fs.readFileSync(filePath, 'utf8');
|
||
return source.includes('#[test]');
|
||
});
|
||
if (testedRustFlowFiles.length === 0) {
|
||
throw new Error(
|
||
`Tauri desktop ${contract.capability} flow must include at least one Rust unit-tested module`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertH5NativeAppRouteFlows() {
|
||
const h5HostBridgeTestSet = new Set(h5HostBridgeTests);
|
||
for (const contract of h5NativeAppRouteFlowContracts) {
|
||
for (const filePath of contract.files) {
|
||
if (!fs.existsSync(filePath)) {
|
||
throw new Error(
|
||
`H5 native app ${contract.label} route flow file is missing: ${filePath}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const [filePath, snippet] of contract.snippets) {
|
||
assertFileIncludesSnippet(
|
||
filePath,
|
||
snippet,
|
||
`H5 native app ${contract.label} route flow`,
|
||
);
|
||
}
|
||
|
||
for (const testPath of contract.tests) {
|
||
if (!h5HostBridgeTestSet.has(testPath)) {
|
||
throw new Error(
|
||
`H5 native app ${contract.label} route flow test is not part of check:native-shells: ${testPath}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const test of contract.targetedTests ?? []) {
|
||
assertFileIncludesSnippet(
|
||
test.filePath,
|
||
`test('${test.name}'`,
|
||
`H5 native app ${contract.label} route flow test`,
|
||
);
|
||
const stepRunsTest = h5NativeAppRouteFlowTestSteps.some(
|
||
(step) =>
|
||
step.args.includes(test.filePath) && step.args.includes(test.name),
|
||
);
|
||
if (!stepRunsTest) {
|
||
throw new Error(
|
||
`H5 native app ${contract.label} route flow targeted test is not part of check:native-shells: ${test.filePath}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertWechatPaymentResultBoundaries() {
|
||
const paymentSource = fs.readFileSync(
|
||
'miniprogram/host-bridge/payment.js',
|
||
'utf8',
|
||
);
|
||
const paymentTestSource = fs.readFileSync(
|
||
'miniprogram/host-bridge/payment.test.js',
|
||
'utf8',
|
||
);
|
||
|
||
for (const snippet of [
|
||
'function normalizePayError()',
|
||
"return 'wechat payment unavailable'",
|
||
'function logWechatPayFailure(label, _error)',
|
||
'console.error(`[wechat-pay] ${label}`)',
|
||
"logWechatPayFailure('parse params failed', error)",
|
||
"logWechatPayFailure('requestVirtualPayment unavailable')",
|
||
"logWechatPayFailure('requestVirtualPayment failed', error)",
|
||
]) {
|
||
if (!paymentSource.includes(snippet)) {
|
||
throw new Error(`wechat payment bridge must include ${snippet}`);
|
||
}
|
||
}
|
||
if (
|
||
paymentSource.includes('JSON.stringify({\n errCode') ||
|
||
paymentSource.includes('String(error.errMsg || error)') ||
|
||
paymentSource.includes(
|
||
"console.error('[wechat-pay] parse params failed', error)",
|
||
) ||
|
||
paymentSource.includes(
|
||
"console.error('[wechat-pay] requestVirtualPayment unavailable',",
|
||
) ||
|
||
paymentSource.includes(
|
||
"console.error('[wechat-pay] requestVirtualPayment failed', error)",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'wechat payment bridge must not expose native payment errors to H5',
|
||
);
|
||
}
|
||
for (const snippet of [
|
||
'maps virtual payment cancel errCode to cancel result',
|
||
'logs virtual payment unavailable without exposing capability details',
|
||
'hides ordinary payment native failure details from H5 result',
|
||
"errorMessage: 'wechat payment unavailable'",
|
||
"expect(console.error.mock.calls).toContainEqual([\n '[wechat-pay] requestVirtualPayment unavailable',",
|
||
"expect(console.error).toHaveBeenCalledWith(\n '[wechat-pay] requestVirtualPayment failed'",
|
||
'expect(console.error.mock.calls.flat()).not.toContain(payError)',
|
||
]) {
|
||
if (!paymentTestSource.includes(snippet)) {
|
||
throw new Error(`wechat payment bridge test must include ${snippet}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertWechatAuthFailureBoundaries() {
|
||
const webViewShellSource = fs.readFileSync(
|
||
'miniprogram/shell/webView.js',
|
||
'utf8',
|
||
);
|
||
const authTestSource = fs.readFileSync(
|
||
'scripts/miniprogram-web-view-auth.test.ts',
|
||
'utf8',
|
||
);
|
||
|
||
for (const snippet of [
|
||
'function logMiniProgramEnvReadFailure(_error)',
|
||
"console.warn('[web-view] read mini program env failed')",
|
||
'logMiniProgramEnvReadFailure(error)',
|
||
]) {
|
||
if (!webViewShellSource.includes(snippet)) {
|
||
throw new Error(
|
||
`wechat web-view env diagnostics must include ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
if (
|
||
webViewShellSource.includes(
|
||
"console.warn('[web-view] read mini program env failed', error)",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'wechat web-view env diagnostics must not log native error details',
|
||
);
|
||
}
|
||
|
||
for (const snippet of [
|
||
"WECHAT_LOGIN_UNAVAILABLE_MESSAGE = '微信登录失败,请稍后重试。'",
|
||
"WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE = '绑定手机号失败,请稍后重试。'",
|
||
"WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE = '需要授权手机号后才能完成绑定。'",
|
||
'function logWebViewAuthFailure(label, _detail)',
|
||
'console.error(`[web-view] ${label}`)',
|
||
"logWebViewAuthFailure('parse auth result failed', error)",
|
||
"logWebViewAuthFailure('wx.login returned no code', result)",
|
||
"logWebViewAuthFailure('wx.login failed', error)",
|
||
"logWebViewAuthFailure('mini program login failed', response)",
|
||
"logWebViewAuthFailure('mini program login request failed', error)",
|
||
"logWebViewAuthFailure('mini program bind phone failed', response)",
|
||
"logWebViewAuthFailure('mini program bind phone request failed', error)",
|
||
"logWebViewAuthFailure('auth flow failed', error)",
|
||
"logWebViewAuthFailure('bind phone auth declined', detail)",
|
||
"logWebViewAuthFailure('bind phone failed', error)",
|
||
'errorMessage: WECHAT_LOGIN_UNAVAILABLE_MESSAGE',
|
||
'errorMessage: WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE',
|
||
'errorMessage: WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE',
|
||
]) {
|
||
if (!webViewShellSource.includes(snippet)) {
|
||
throw new Error(`wechat auth shell must include ${snippet}`);
|
||
}
|
||
}
|
||
for (const forbiddenSnippet of [
|
||
"reject(new Error(error.errMsg || '微信登录失败'))",
|
||
"reject(new Error(error.errMsg || '微信登录请求失败'))",
|
||
"reject(new Error(error.errMsg || '绑定手机号请求失败'))",
|
||
"error && error.message ? error.message : '微信登录失败,请稍后重试。'",
|
||
"detail.errMsg || '需要授权手机号后才能完成绑定。'",
|
||
'response.data.error.message',
|
||
"console.error('[web-view] parse auth result failed', error)",
|
||
"console.error('[web-view] wx.login returned no code', result)",
|
||
"console.error('[web-view] wx.login failed', error)",
|
||
"console.error('[web-view] mini program login failed', response)",
|
||
"console.error('[web-view] mini program login request failed', error)",
|
||
"console.error('[web-view] mini program bind phone failed', response)",
|
||
"console.error('[web-view] mini program bind phone request failed', error)",
|
||
"console.error('[web-view] auth flow failed', error)",
|
||
"console.error('[web-view] bind phone auth declined', detail)",
|
||
"console.error('[web-view] bind phone failed', error)",
|
||
]) {
|
||
if (webViewShellSource.includes(forbiddenSnippet)) {
|
||
throw new Error(
|
||
`wechat auth shell must not expose native failure detail via ${forbiddenSnippet}`,
|
||
);
|
||
}
|
||
}
|
||
for (const snippet of [
|
||
'微信登录失败不向页面透出原生错误',
|
||
'绑定手机号失败不向页面透出后端错误体',
|
||
'拒绝手机号授权不向页面透出微信原生错误',
|
||
"expect(page.data.errorMessage).toBe('微信登录失败,请稍后重试。')",
|
||
"expect(page.data.errorMessage).toBe('绑定手机号失败,请稍后重试。')",
|
||
"expect(page.data.errorMessage).toBe('需要授权手机号后才能完成绑定。')",
|
||
"expect(console.error).toHaveBeenCalledWith('[web-view] wx.login failed')",
|
||
"expect(console.error).toHaveBeenCalledWith('[web-view] auth flow failed')",
|
||
'expect(console.error.mock.calls.flat()).not.toContain(loginError)',
|
||
"expect(console.error).toHaveBeenCalledWith(\n '[web-view] mini program bind phone failed'",
|
||
"expect(console.error).toHaveBeenCalledWith('[web-view] bind phone failed')",
|
||
"expect(console.error.mock.calls.flat()).not.toContain('private backend detail')",
|
||
"expect(console.error).toHaveBeenCalledWith(\n '[web-view] bind phone auth declined'",
|
||
'expect(console.error.mock.calls.flat()).not.toContain(authDeclined)',
|
||
]) {
|
||
if (!authTestSource.includes(snippet)) {
|
||
throw new Error(`wechat auth boundary test must include ${snippet}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertWechatWebViewPageEventBoundaries() {
|
||
const webViewShellSource = fs.readFileSync(
|
||
'miniprogram/shell/webView.js',
|
||
'utf8',
|
||
);
|
||
const webViewTestSource = fs.readFileSync(
|
||
'miniprogram/shell/webView.test.js',
|
||
'utf8',
|
||
);
|
||
|
||
for (const snippet of [
|
||
'function logWebViewPageEvent(label, _detail)',
|
||
'function logWebViewPageFailure(label, _detail)',
|
||
'console.info(`[web-view] ${label}`)',
|
||
'console.error(`[web-view] ${label}`)',
|
||
"logWebViewPageEvent('loaded', event.detail)",
|
||
"logWebViewPageFailure('load failed', event.detail)",
|
||
"logWebViewPageEvent('message', event.detail)",
|
||
]) {
|
||
if (!webViewShellSource.includes(snippet)) {
|
||
throw new Error(
|
||
`wechat web-view page event diagnostics must include ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const forbiddenSnippet of [
|
||
"console.info('[web-view] loaded', event.detail)",
|
||
"console.error('[web-view] load failed', event.detail)",
|
||
"console.info('[web-view] message', event.detail)",
|
||
]) {
|
||
if (webViewShellSource.includes(forbiddenSnippet)) {
|
||
throw new Error(
|
||
`wechat web-view page event diagnostics must not expose native detail via ${forbiddenSnippet}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const snippet of [
|
||
'logs web-view page events without native detail payloads',
|
||
"expect(console.info).toHaveBeenCalledWith('[web-view] loaded')",
|
||
"expect(console.error).toHaveBeenCalledWith('[web-view] load failed')",
|
||
'expect(console.info.mock.calls.flat()).not.toContain(loadDetail)',
|
||
'expect(console.error.mock.calls.flat()).not.toContain(errorDetail)',
|
||
"expect(console.info).toHaveBeenCalledWith('[web-view] message')",
|
||
'expect(console.info.mock.calls.flat()).not.toContain(webViewDetail)',
|
||
]) {
|
||
if (!webViewTestSource.includes(snippet)) {
|
||
throw new Error(
|
||
`wechat web-view page event boundary test must include ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertWechatShareGridFailureBoundaries() {
|
||
const shareGridSource = fs.readFileSync(
|
||
'miniprogram/shell/shareGrid.js',
|
||
'utf8',
|
||
);
|
||
const shareGridTestSource = fs.readFileSync(
|
||
'miniprogram/shell/shareGrid.test.js',
|
||
'utf8',
|
||
);
|
||
|
||
for (const snippet of [
|
||
"WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE = '九宫切图保存失败。'",
|
||
'function logShareGridFailure(label, _error)',
|
||
'console.error(`[share-grid] ${label}`)',
|
||
'logShareGridFailure(label, error)',
|
||
"logShareGridFailure('save failed', error)",
|
||
'reject(new Error(WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE))',
|
||
'errorMessage: WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE',
|
||
]) {
|
||
if (!shareGridSource.includes(snippet)) {
|
||
throw new Error(`wechat share-grid shell must include ${snippet}`);
|
||
}
|
||
}
|
||
for (const forbiddenSnippet of [
|
||
"reject(new Error(error.errMsg || '封面下载失败'))",
|
||
"reject(new Error(error.errMsg || '读取封面失败'))",
|
||
"reject(new Error(error.errMsg || '导出切图失败'))",
|
||
"reject(new Error(error.errMsg || '保存到相册失败'))",
|
||
"error && error.message ? error.message : '九宫切图保存失败。'",
|
||
'console.error(`[share-grid] ${label}`, error)',
|
||
"console.error('[share-grid] save failed', error)",
|
||
]) {
|
||
if (shareGridSource.includes(forbiddenSnippet)) {
|
||
throw new Error(
|
||
`wechat share-grid shell must not expose native failure detail via ${forbiddenSnippet}`,
|
||
);
|
||
}
|
||
}
|
||
for (const snippet of [
|
||
'hides downloadFile native failure details from the page',
|
||
"expect(page.data.errorMessage).toBe('九宫切图保存失败。')",
|
||
"expect(page.data.errorMessage).not.toContain('private native download detail')",
|
||
"expect(consoleError).toHaveBeenCalledWith('[share-grid] download failed')",
|
||
"expect(consoleError).toHaveBeenCalledWith('[share-grid] save failed')",
|
||
"expect(consoleError.mock.calls.flat()).not.toContain('private native download detail')",
|
||
]) {
|
||
if (!shareGridTestSource.includes(snippet)) {
|
||
throw new Error(
|
||
`wechat share-grid boundary test must include ${snippet}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertDesktopNavigationEventBoundaries() {
|
||
const desktopShellNavigationSource = fs.readFileSync(
|
||
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
|
||
'utf8',
|
||
);
|
||
const desktopNavigationStateScriptSource = extractRustFunctionSource(
|
||
desktopShellNavigationSource,
|
||
'desktop_navigation_state_script',
|
||
);
|
||
|
||
if (
|
||
!desktopNavigationStateScriptSource.includes(
|
||
"console.warn('desktop navigation state sync failed')",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'desktop shell navigation state diagnostics must log a stable label',
|
||
);
|
||
}
|
||
if (
|
||
desktopNavigationStateScriptSource.includes(
|
||
"console.warn('desktop navigation state sync failed', error)",
|
||
)
|
||
) {
|
||
throw new Error(
|
||
'desktop shell navigation state diagnostics must not log native error objects',
|
||
);
|
||
}
|
||
}
|
||
|
||
function assertHostBridgeLayerLayout() {
|
||
for (const [label, values] of [
|
||
['wechat host bridge files expectation', expectedWechatHostBridgeFiles],
|
||
['wechat shell files expectation', expectedWechatShellFiles],
|
||
[
|
||
'wechat page routes expectation',
|
||
Object.keys(expectedWechatPageFilesByRoute),
|
||
],
|
||
['mobile host bridge files expectation', expectedMobileHostBridgeFiles],
|
||
['mobile shell src root entries expectation', expectedMobileSrcRootEntries],
|
||
['mobile shell files expectation', expectedMobileShellFiles],
|
||
[
|
||
'desktop host bridge Rust files expectation',
|
||
expectedDesktopHostBridgeRustFiles,
|
||
],
|
||
['desktop shell Rust files expectation', expectedDesktopShellRustFiles],
|
||
]) {
|
||
assertUniqueList(values, label);
|
||
}
|
||
for (const [label, values] of Object.entries(
|
||
expectedHostBridgeModuleTaxonomy,
|
||
)) {
|
||
assertUniqueList(values, `host bridge ${label} module taxonomy`);
|
||
}
|
||
for (const [route, expectedFiles] of Object.entries(
|
||
expectedWechatPageFilesByRoute,
|
||
)) {
|
||
assertUniqueList(
|
||
expectedFiles,
|
||
`wechat ${route} page wrapper files expectation`,
|
||
);
|
||
}
|
||
|
||
assertSameList(
|
||
readDirectoryFileList(
|
||
'miniprogram/host-bridge',
|
||
'wechat host bridge files',
|
||
),
|
||
expectedWechatHostBridgeFiles,
|
||
'wechat host bridge files',
|
||
);
|
||
assertHostBridgeModuleTaxonomy();
|
||
|
||
assertSameList(
|
||
readDirectoryFileList('miniprogram/shell', 'wechat shell files'),
|
||
expectedWechatShellFiles,
|
||
'wechat shell files',
|
||
);
|
||
|
||
assertSameList(
|
||
readDirectoryNameList('miniprogram/pages', 'wechat page directories'),
|
||
Object.keys(expectedWechatPageFilesByRoute).sort(),
|
||
'wechat page directories',
|
||
);
|
||
|
||
for (const [route, expectedFiles] of Object.entries(
|
||
expectedWechatPageFilesByRoute,
|
||
)) {
|
||
assertSameList(
|
||
readDirectoryFileList(
|
||
`miniprogram/pages/${route}`,
|
||
`wechat ${route} page wrapper files`,
|
||
),
|
||
expectedFiles,
|
||
`wechat ${route} page wrapper files`,
|
||
);
|
||
const pagePath = `miniprogram/pages/${route}/index.js`;
|
||
const source = fs.readFileSync(pagePath, 'utf8');
|
||
if (!source.includes("require('../../shell/")) {
|
||
throw new Error(`${pagePath} must import from miniprogram/shell`);
|
||
}
|
||
if (
|
||
source.includes("require('../../host-bridge/") ||
|
||
source.includes("require('./index.shared')")
|
||
) {
|
||
throw new Error(`${pagePath} must not import bridge logic directly`);
|
||
}
|
||
}
|
||
|
||
assertSameList(
|
||
readDirectoryEntryList(
|
||
'apps/mobile-shell/src',
|
||
'mobile shell src root entries',
|
||
),
|
||
expectedMobileSrcRootEntries,
|
||
'mobile shell src root entries',
|
||
);
|
||
|
||
assertSameList(
|
||
readDirectoryFileList(
|
||
'apps/mobile-shell/src/host-bridge',
|
||
'mobile host bridge files',
|
||
),
|
||
expectedMobileHostBridgeFiles,
|
||
'mobile host bridge files',
|
||
);
|
||
|
||
assertSameList(
|
||
readDirectoryFileList('apps/mobile-shell/src/shell', 'mobile shell files'),
|
||
expectedMobileShellFiles,
|
||
'mobile shell files',
|
||
);
|
||
|
||
const mobileAppSource = fs.readFileSync('apps/mobile-shell/App.tsx', 'utf8');
|
||
if (
|
||
!mobileAppSource.includes("import ShellApp from './src/shell/ShellApp';")
|
||
) {
|
||
throw new Error(
|
||
'mobile shell App.tsx must import from apps/mobile-shell/src/shell',
|
||
);
|
||
}
|
||
if (mobileAppSource.includes('./src/host-bridge/')) {
|
||
throw new Error('mobile shell App.tsx must not import HostBridge directly');
|
||
}
|
||
|
||
assertSameList(
|
||
readDirectoryEntryList(
|
||
'apps/desktop-shell/src-tauri/src',
|
||
'desktop shell Rust root entries',
|
||
),
|
||
['dir:host_bridge', 'dir:shell', 'file:app.rs', 'file:main.rs'],
|
||
'desktop shell Rust root entries',
|
||
);
|
||
|
||
const desktopMainSource = fs.readFileSync(
|
||
'apps/desktop-shell/src-tauri/src/main.rs',
|
||
'utf8',
|
||
);
|
||
const desktopAppSource = fs.readFileSync(
|
||
'apps/desktop-shell/src-tauri/src/app.rs',
|
||
'utf8',
|
||
);
|
||
if (
|
||
!desktopMainSource.includes('mod app;') ||
|
||
!desktopMainSource.includes('app::run();')
|
||
) {
|
||
throw new Error('desktop shell main.rs must stay a thin app entrypoint');
|
||
}
|
||
if (
|
||
desktopMainSource.includes('tauri::Builder::default()') ||
|
||
desktopMainSource.includes('tauri::generate_handler!')
|
||
) {
|
||
throw new Error('desktop shell main.rs must not own Tauri app setup');
|
||
}
|
||
if (
|
||
!desktopAppSource.includes('tauri::Builder::default()') ||
|
||
!desktopAppSource.includes('crate::host_bridge::host_bridge_request')
|
||
) {
|
||
throw new Error('desktop shell app.rs must own Tauri app setup');
|
||
}
|
||
|
||
assertSameList(
|
||
readDirectoryFileList(
|
||
'apps/desktop-shell/src-tauri/src/host_bridge',
|
||
'desktop host bridge Rust files',
|
||
),
|
||
expectedDesktopHostBridgeRustFiles,
|
||
'desktop host bridge Rust files',
|
||
);
|
||
|
||
assertSameList(
|
||
readDirectoryFileList(
|
||
'apps/desktop-shell/src-tauri/src/shell',
|
||
'desktop shell Rust files',
|
||
),
|
||
expectedDesktopShellRustFiles,
|
||
'desktop shell Rust files',
|
||
);
|
||
}
|
||
|
||
function normalizeHostBridgeModuleNames(files) {
|
||
return files
|
||
.filter((fileName) => !fileName.includes('.test.'))
|
||
.map((fileName) =>
|
||
fileName
|
||
.replace(/\.(js|rs|ts)$/, '')
|
||
.replace('filePayloads', 'file-payloads')
|
||
.replace('file_payloads', 'file-payloads'),
|
||
)
|
||
.sort();
|
||
}
|
||
|
||
function assertHostBridgeModuleTaxonomy() {
|
||
const wechatModules = normalizeHostBridgeModuleNames(
|
||
expectedWechatHostBridgeFiles,
|
||
);
|
||
const mobileModules = normalizeHostBridgeModuleNames(
|
||
expectedMobileHostBridgeFiles,
|
||
);
|
||
const desktopModules = normalizeHostBridgeModuleNames(
|
||
expectedDesktopHostBridgeRustFiles,
|
||
);
|
||
const allShellModules = [
|
||
...wechatModules,
|
||
...mobileModules,
|
||
...desktopModules,
|
||
];
|
||
const countInShells = (moduleName) =>
|
||
[wechatModules, mobileModules, desktopModules].filter((modules) =>
|
||
modules.includes(moduleName),
|
||
).length;
|
||
const actualAllShells = allShellModules
|
||
.filter(
|
||
(moduleName, index, modules) => modules.indexOf(moduleName) === index,
|
||
)
|
||
.filter((moduleName) => countInShells(moduleName) === 3)
|
||
.sort();
|
||
const actualNativeAppShells = mobileModules
|
||
.filter((moduleName) => desktopModules.includes(moduleName))
|
||
.filter((moduleName) => !wechatModules.includes(moduleName))
|
||
.sort();
|
||
const actualMobileOnly = mobileModules
|
||
.filter(
|
||
(moduleName) =>
|
||
!wechatModules.includes(moduleName) &&
|
||
!desktopModules.includes(moduleName),
|
||
)
|
||
.sort();
|
||
const actualDesktopOnly = desktopModules
|
||
.filter(
|
||
(moduleName) =>
|
||
!wechatModules.includes(moduleName) &&
|
||
!mobileModules.includes(moduleName),
|
||
)
|
||
.sort();
|
||
const actualWechatOnly = wechatModules
|
||
.filter(
|
||
(moduleName) =>
|
||
!mobileModules.includes(moduleName) &&
|
||
!desktopModules.includes(moduleName),
|
||
)
|
||
.sort();
|
||
|
||
assertSameList(
|
||
actualAllShells,
|
||
expectedHostBridgeModuleTaxonomy.allShells,
|
||
'all-shell HostBridge module taxonomy',
|
||
);
|
||
assertSameList(
|
||
actualNativeAppShells,
|
||
expectedHostBridgeModuleTaxonomy.nativeAppShells,
|
||
'native-app HostBridge module taxonomy',
|
||
);
|
||
assertSameList(
|
||
actualMobileOnly,
|
||
expectedHostBridgeModuleTaxonomy.mobileOnly,
|
||
'mobile-only HostBridge module taxonomy',
|
||
);
|
||
assertSameList(
|
||
actualDesktopOnly,
|
||
expectedHostBridgeModuleTaxonomy.desktopOnly,
|
||
'desktop-only HostBridge module taxonomy',
|
||
);
|
||
assertSameList(
|
||
actualWechatOnly,
|
||
expectedHostBridgeModuleTaxonomy.wechatOnly,
|
||
'wechat-only HostBridge module taxonomy',
|
||
);
|
||
}
|
||
|
||
function assertDesktopReleaseBinaryArtifact() {
|
||
const executableName =
|
||
process.platform === 'win32'
|
||
? 'genarrative-desktop-shell.exe'
|
||
: 'genarrative-desktop-shell';
|
||
const executablePath = path.join(
|
||
'build',
|
||
'native',
|
||
'desktop',
|
||
executableName,
|
||
);
|
||
|
||
if (!fs.existsSync(executablePath)) {
|
||
throw new Error(`desktop release binary is missing: ${executablePath}`);
|
||
}
|
||
|
||
const stat = fs.statSync(executablePath);
|
||
if (!stat.isFile() || stat.size < 1024 * 1024) {
|
||
throw new Error(
|
||
'desktop release binary must be a real non-empty executable file',
|
||
);
|
||
}
|
||
|
||
const header = fs.readFileSync(executablePath, { start: 0, end: 7 });
|
||
if (process.platform === 'linux') {
|
||
const isElf =
|
||
header[0] === 0x7f &&
|
||
header[1] === 0x45 &&
|
||
header[2] === 0x4c &&
|
||
header[3] === 0x46;
|
||
if (!isElf || (stat.mode & 0o111) === 0) {
|
||
throw new Error(
|
||
'desktop Linux release binary must be an executable ELF file',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (process.platform === 'darwin') {
|
||
const machMagic = header.readUInt32BE(0);
|
||
const isMachO =
|
||
machMagic === 0xcafebabe ||
|
||
machMagic === 0xcafed00d ||
|
||
machMagic === 0xfeedface ||
|
||
machMagic === 0xfeedfacf;
|
||
if (!isMachO || (stat.mode & 0o111) === 0) {
|
||
throw new Error(
|
||
'desktop macOS release binary must be an executable Mach-O file',
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (process.platform === 'win32') {
|
||
if (header[0] !== 0x4d || header[1] !== 0x5a) {
|
||
throw new Error('desktop Windows release binary must be a PE executable');
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const step of steps) {
|
||
console.log(`[check:native-shells] ${step.label}`);
|
||
const result = spawnSync(step.command, step.args, {
|
||
cwd: process.cwd(),
|
||
stdio: 'inherit',
|
||
});
|
||
|
||
if (result.error) {
|
||
console.error(
|
||
`[check:native-shells] failed to start ${step.label}: ${result.error.message}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
if (result.signal) {
|
||
console.error(
|
||
`[check:native-shells] ${step.label} was terminated by signal ${result.signal}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
if ((result.status ?? 0) !== 0) {
|
||
process.exit(result.status ?? 1);
|
||
}
|
||
}
|
||
|
||
console.log('[check:native-shells] desktop-release-binary-artifact');
|
||
assertDesktopReleaseBinaryArtifact();
|
||
|
||
console.log('[check:native-shells] host-bridge-layer-layout');
|
||
assertHostBridgeLayerLayout();
|
||
|
||
console.log('[check:native-shells] native-shell-capability-plan');
|
||
assertNativeShellCapabilityPlan();
|
||
|
||
console.log('[check:native-shells] external-url-protocol-parity');
|
||
assertExternalUrlProtocolParity();
|
||
|
||
console.log('[check:native-shells] wechat-mini-program-route-parity');
|
||
assertWechatMiniProgramRouteParity();
|
||
|
||
console.log('[check:native-shells] wechat-mini-program-capability-flows');
|
||
assertWechatMiniProgramCapabilityFlows();
|
||
|
||
console.log('[check:native-shells] expo-mobile-capability-flows');
|
||
assertExpoMobileCapabilityFlows();
|
||
|
||
console.log('[check:native-shells] tauri-desktop-capability-flows');
|
||
assertTauriDesktopCapabilityFlows();
|
||
|
||
console.log('[check:native-shells] h5-native-app-route-flows');
|
||
assertH5NativeAppRouteFlows();
|
||
|
||
console.log('[check:native-shells] wechat-payment-result-boundaries');
|
||
assertWechatPaymentResultBoundaries();
|
||
|
||
console.log('[check:native-shells] wechat-auth-failure-boundaries');
|
||
assertWechatAuthFailureBoundaries();
|
||
|
||
console.log('[check:native-shells] wechat-web-view-page-event-boundaries');
|
||
assertWechatWebViewPageEventBoundaries();
|
||
|
||
console.log('[check:native-shells] wechat-share-grid-failure-boundaries');
|
||
assertWechatShareGridFailureBoundaries();
|
||
|
||
console.log('[check:native-shells] desktop-navigation-event-boundaries');
|
||
assertDesktopNavigationEventBoundaries();
|
||
|
||
console.log('[check:native-shells] h5-host-bridge-event-subscription-gates');
|
||
assertH5HostBridgeEventSubscriptionGates();
|
||
|
||
console.log('[check:native-shells] h5-host-bridge-payload-boundaries');
|
||
assertH5HostBridgePayloadBoundaries();
|
||
|
||
console.log('[check:native-shells] h5-native-app-transport-timeout-boundaries');
|
||
assertH5NativeAppTransportTimeoutBoundaries();
|
||
|
||
console.log('[check:native-shells] h5-native-app-message-source-boundaries');
|
||
assertH5NativeAppMessageSourceBoundaries();
|
||
|
||
console.log('[check:native-shells] h5-native-app-transport-facade-boundary');
|
||
assertH5NativeAppTransportFacadeBoundary();
|
||
|
||
console.log('[check:native-shells] generated-native-shell-artifact-boundary');
|
||
assertNoTrackedGeneratedNativeShellArtifacts();
|
||
assertGeneratedNativeShellArtifactsAreIgnored();
|
||
|
||
console.log('[check:native-shells] ai-game-creator-shell-user-dev-boundary');
|
||
assertAiGameCreatorShellUserDevBoundary();
|
||
|
||
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
|
||
assertNoProductionShellDevScaffoldTerms();
|
||
|
||
console.log('[check:native-shells] OK');
|