Files
Genarrative/scripts/check-native-shells.mjs
T
menghao 8e3d9991fa
Project CI / Repository checks (push) Successful in 1m5s
Project CI / Frontend tests (push) Successful in 3m0s
Project CI / Backend tests (push) Successful in 4m4s
Project CI / Native shell tests (push) Successful in 14m17s
完成资源管理的功能修复 (#154)
实现资源卡本体化与图片视频音频预览播放

实现四个资源分区独立高度与内外滚动

实现依赖视图确定性聚类与拓扑变化重排

补齐布局、SVG、AppSurface、性能和媒体回归

同步工作台PRD、技术方案与项目共享记忆

---------

Co-authored-by: kdletters <kdletters@qq.com>
Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/154
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-12 13:14:55 +08:00

5105 lines
158 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 aiGameCreatorShellAppModelSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/app-shell/model.ts',
'utf8',
);
const aiGameCreatorShellProjectWorkspaceChatPaneSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx',
'utf8',
);
const aiGameCreatorShellDeveloperProjectPanelsSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/DeveloperProjectPanels.tsx',
'utf8',
);
const aiGameCreatorLocalGamePreviewFrameSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx',
'utf8',
);
const aiGameCreatorSupervisorChatOnlyViewSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.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 aiGameCreatorClientHttpSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/services/clientHttp.ts',
'utf8',
);
const aiGameCreatorMainCapability = JSON.parse(
fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/capabilities/main.json',
'utf8',
),
);
const aiGameCreatorCargoManifestSource = fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
'utf8',
);
const aiGameCreatorViteConfigSource = fs.readFileSync(
'apps/ai-game-creator-shell/vite.config.ts',
'utf8',
);
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',
);
}
if (
!aiGameCreatorClientHttpSource.includes(
"'https://dev.genarrative.world'",
) ||
!aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'") ||
!aiGameCreatorClientHttpSource.includes('target.origin !==')
) {
throw new Error(
'AI game creator release dev API transport boundary drifted',
);
}
if (
!aiGameCreatorCargoManifestSource.includes(
'tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }',
) ||
!aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
) {
throw new Error(
'AI game creator release must register the native HTTP plugin without the OS automatic system-proxy feature',
);
}
if (
!aiGameCreatorViteConfigSource.includes("dedupe: ['react', 'react-dom']")
) {
throw new Error(
'AI game creator monorepo build must dedupe React runtime packages',
);
}
const httpPermission = (aiGameCreatorMainCapability.permissions ?? []).find(
(permission) =>
typeof permission === 'object' &&
permission?.identifier === 'http:default',
);
if (
!httpPermission ||
JSON.stringify(httpPermission.allow ?? []) !==
JSON.stringify([{ url: 'https://dev.genarrative.world/api/*' }])
) {
throw new Error(
'AI game creator native HTTP scope must stay limited to the dev API',
);
}
for (const snippet of [
'function isDeveloperMode()',
'if (!import.meta.env.DEV)',
"return params.has('dev') || window.location.hash === '#dev';",
]) {
if (!aiGameCreatorShellAppModelSource.includes(snippet)) {
throw new Error(
`AI game creator developer mode boundary drifted: missing ${snippet}`,
);
}
}
for (const snippet of [
'projectSupervisorOnly ? false : isDeveloperMode()',
'{devMode ? (',
'className="developer-pane"',
]) {
if (!aiGameCreatorShellAppSource.includes(snippet)) {
throw new Error(
`AI game creator user/dev UI boundary drifted: missing ${snippet}`,
);
}
}
if (
!aiGameCreatorShellAppSource.includes('<ProjectWorkspaceChatPane') ||
!aiGameCreatorShellProjectWorkspaceChatPaneSource.includes(
'className="chat-pane"',
)
) {
throw new Error('AI game creator user chat pane boundary drifted');
}
const developerProjectPanelIndexes = [
...aiGameCreatorShellAppSource.matchAll(/<DeveloperProjectPanels\b/g),
].map((match) => match.index ?? -1);
const previewFrameCount = [
...aiGameCreatorShellDeveloperProjectPanelsSource.matchAll(/<iframe\b/g),
].length;
const devModeBranchIndex =
aiGameCreatorShellAppSource.indexOf('{devMode ? (');
const developerPaneIndex = aiGameCreatorShellAppSource.indexOf(
'className="developer-pane"',
);
if (previewFrameCount !== 1) {
throw new Error(
'AI game creator developer pane preview frame count drifted',
);
}
if (developerProjectPanelIndexes.length !== 1) {
throw new Error(
'AI game creator developer project panels mount count drifted',
);
}
if (
devModeBranchIndex < 0 ||
developerPaneIndex < 0 ||
developerProjectPanelIndexes.some(
(index) => index < devModeBranchIndex || index < developerPaneIndex,
)
) {
throw new Error(
'AI game creator preview panels must stay inside the dev-only pane',
);
}
// 上面几条只约束 DeveloperProjectPanels 自身的 iframe 数量和挂载位置,管不到 App.tsx
// 直接内嵌 iframe 的情况——预览必须一律委托给客户端工作台,外壳自己不持有预览框。
if ([...aiGameCreatorShellAppSource.matchAll(/<iframe\b/g)].length !== 0) {
throw new Error(
'AI game creator app shell must delegate preview iframe to the client workbench',
);
}
const sharedPreviewIframeCount = [
...aiGameCreatorLocalGamePreviewFrameSource.matchAll(/<iframe\b/g),
].length;
const clientPreviewMountCount = [
...aiGameCreatorProjectDevelopmentSource.matchAll(
/<LocalGamePreviewFrame\b/g,
),
].length;
const gameChatPreviewMountCount = [
...aiGameCreatorSupervisorChatOnlyViewSource.matchAll(
/<LocalGamePreviewFrame\b/g,
),
].length;
if (
sharedPreviewIframeCount !== 1 ||
clientPreviewMountCount !== 1 ||
gameChatPreviewMountCount !== 1
) {
throw new Error(
'AI game creator workbenches must reuse one guarded local preview frame component',
);
}
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={embeddedUrl}',
]) {
if (!aiGameCreatorLocalGamePreviewFrameSource.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',
);
}
const developerWindowCallCount = [
...aiGameCreatorShellTauriSource.matchAll(
/open_developer_window\(app\.handle\(\)\)\?;/g,
),
].length;
if (
!aiGameCreatorShellTauriSource.includes(
'#[cfg(all(debug_assertions, not(test)))]\npub(crate) fn open_developer_window(',
) ||
!/#\[cfg\(all\(debug_assertions, not\(test\)\)\)\]\s+if game_chat_launch\.is_none\(\) \{\s+open_developer_window\(app\.handle\(\)\)\?;\s+\}/u.test(
aiGameCreatorShellTauriSource,
) ||
developerWindowCallCount !== 1
) {
throw new Error(
'AI game creator developer window must stay compile-time debug-only and outside game-chat launch',
);
}
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 === 0xbebafeca ||
machMagic === 0xcafebabf ||
machMagic === 0xbfbafeca ||
machMagic === 0xfeedface ||
machMagic === 0xcefaedfe ||
machMagic === 0xfeedfacf ||
machMagic === 0xcffaedfe;
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');