Files
Genarrative/scripts/check-native-shells.mjs
T
kdletters e4b9cbf09d 接入移动端原生扫码能力
新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。

接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。

让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。

同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
2026-06-19 05:13:21 +08:00

933 lines
27 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';
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 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 h5HostBridgeScannedFacadeImports = new Set([
'canUseHostShareGrid',
'captureHostImageFile',
'exportHostAudioFile',
'exportHostImageFile',
'exportHostTextFile',
'getHostAppearanceColorScheme',
'getHostNetworkStatus',
'getNativeAppHostRuntime',
'importHostAudioFile',
'importHostImageFile',
'importHostTextFile',
'navigateHostNativePage',
'openHostExternalUrl',
'openHostShare',
'openHostShareGrid',
'readHostClipboardText',
'refreshNativeAppHostRuntime',
'reloadHostWebView',
'requestHostHapticsImpact',
'requestHostLogin',
'requestHostPayment',
'scanHostQrCode',
'setHostAppBadgeCount',
'setHostAppTitle',
'setHostShareTarget',
'showHostLocalNotification',
'subscribeHostAppLifecycle',
'subscribeHostImageDrop',
'subscribeHostNavigationCanGoBack',
'subscribeHostNetworkStatusChange',
'subscribeHostRuntimeChange',
'writeHostClipboardText',
]);
const h5HostBridgeCallChainWrapperFiles = [
'src/hooks/useHostLifecycleActive.ts',
'src/hooks/useHostNetworkOnline.ts',
'src/components/platform-entry/platformProfileHostClipboard.ts',
'src/components/platform-entry/platformHostBridgeSync.ts',
];
const h5HostBridgeRequiredCallChainFiles = [
'src/App.tsx',
'src/components/auth/AuthGate.tsx',
'src/components/common/CreativeAudioInputPanel.tsx',
'src/components/common/CreativeImageInputPanel.tsx',
'src/components/common/PublishShareModal.tsx',
'src/components/common/publishShareCardImage.ts',
'src/components/creation-agent/CreationAgentWorkspace.tsx',
'src/components/match3d-runtime/Match3DRuntimeShell.tsx',
'src/components/platform-entry/PlatformEntryFlowShellImpl.tsx',
'src/components/platform-entry/PlatformFeedbackView.tsx',
'src/components/platform-entry/PlatformProfilePrimitives.tsx',
'src/components/platform-entry/PlatformProfileReferralModal.tsx',
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
'src/components/platform-entry/usePlatformProfileCenterController.ts',
'src/components/puzzle-runtime/PuzzleRuntimeShell.tsx',
'src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx',
'src/components/rpg-entry/RpgEntryHomeView.tsx',
'src/components/square-hole-result/SquareHoleResultView.tsx',
'src/hooks/useBackgroundMusic.ts',
'src/hooks/useHostLifecycleActive.ts',
'src/hooks/useHostNetworkOnline.ts',
'src/main.tsx',
'src/services/appTitle.ts',
'src/services/clipboard.ts',
'src/services/runtimeAudioFeedback.ts',
'src/services/wechatMiniProgramShareTarget.ts',
'src/services/wechatMiniProgramSubscribe.ts',
'src/services/wechatMiniProgramShareGrid.ts',
];
const expectedWechatHostBridgeFiles = [
'dispatch.js',
'payment.js',
'payment.test.js',
'protocol.js',
'protocol.test.js',
'shareGrid.js',
'shareGrid.test.js',
'subscribeMessage.js',
'subscribeMessage.test.js',
'webView.js',
'webView.test.js',
];
const expectedWechatShellFiles = [
'payment.js',
'payment.test.js',
'shareGrid.js',
'shareGrid.test.js',
'subscribeMessage.js',
'subscribeMessage.test.js',
'webView.js',
'webView.test.js',
];
const expectedMobileHostBridgeFiles = [
'bridge.test.ts',
'bridge.ts',
'capabilities.ts',
'dispatch.ts',
'files.ts',
'protocol.ts',
'scanner.ts',
'share.ts',
];
const expectedMobileShellFiles = [
'QrScannerOverlay.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.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 = [
'capabilities.rs',
'dispatch.rs',
'files.rs',
'mod.rs',
'protocol.rs',
'share.rs',
];
const expectedDesktopShellRustFiles = [
'deep_link.rs',
'events.rs',
'file_drop.rs',
'lifecycle.rs',
'menu.rs',
'mod.rs',
'navigation.rs',
'network.rs',
'runtime.rs',
'tray.rs',
'url.rs',
'webview.rs',
'window_state.rs',
];
const capabilityListMarkers = {
desktop: '桌面壳当前真实能力完整清单为',
mobile: '移动壳当前通用真实能力完整清单为',
mobileIosExtra: '移动壳 iOS 额外真实能力为',
};
const sharedHostBridgeContractPath =
'packages/shared/src/contracts/hostBridge.ts';
const productionShellExtensions = new Set([
'.js',
'.json',
'.mjs',
'.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 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/App.test.tsx',
'src/components/common/CreativeAudioInputPanel.test.tsx',
'src/components/common/PublishShareModal.test.tsx',
'src/components/creation-agent/CreationAgentWorkspace.test.tsx',
'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts',
'src/components/platform-entry/platformHostBridgeSync.test.ts',
'src/components/platform-entry/platformHostNotificationModel.test.ts',
'src/services/runtimeAudioFeedback.test.ts',
'src/services/clipboard.test.ts',
'src/services/appTitle.test.ts',
];
const h5PlatformHostBridgeIntegrationTest =
'native app jump hop draft completion sends host notification and badge from platform shell';
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/host-bridge/subscribeMessage.test.js',
'miniprogram/shell/webView.test.js',
'miniprogram/shell/payment.test.js',
'miniprogram/shell/shareGrid.test.js',
'miniprogram/shell/subscribeMessage.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],
},
{
label: 'h5-platform-host-bridge-integration',
command: npmCommand,
args: [
'run',
'test',
'--',
'src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx',
'-t',
h5PlatformHostBridgeIntegrationTest,
],
},
{
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-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-typecheck',
command: npmCommand,
args: ['run', 'desktop-shell:typecheck'],
},
{
label: 'desktop-shell-test',
command: npmCommand,
args: ['run', 'desktop-shell:test'],
},
{
label: 'desktop-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'desktop-shell:build', '--', '--no-bundle'],
},
];
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 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 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 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 extractDocumentCapabilityList(source, marker) {
const markerIndex = source.indexOf(marker);
if (markerIndex === -1) {
throw new Error(`native shell plan missing ${marker}`);
}
const sentenceEnd = source.indexOf('。', 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',
);
}
assertNativeShellScaffoldScanWording(planSource, 'native shell plan');
assertNativeShellScaffoldScanWording(
hostBridgeProtocolDocSource,
'HostBridge protocol document',
);
assertNativeShellScaffoldScanWording(
developmentWorkflowDocSource,
'development workflow document',
);
assertNativeShellScaffoldScanWording(
decisionLogDocSource,
'decision log 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 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',
);
assertSameList(
extractRustStringArray(desktopEventSource, 'HOST_BRIDGE_EVENTS'),
sharedEvents,
'desktop shell runtime event whitelist',
);
for (const eventName of sharedEvents) {
if (!sharedDesktopCapabilities.includes(eventName)) {
throw new Error(`shared HostBridge event must be in desktop capability profile: ${eventName}`);
}
}
assertSameList(
desktopCapabilities,
sharedDesktopCapabilities,
'desktop shell runtime capability profile',
);
assertSameList(
extractDocumentMethodTable(planSource),
[
...sharedMethods.slice(0, 8),
'app.lifecycle',
'navigation.canGoBack',
...sharedMethods.slice(8, 12),
'network.statusChanged',
...sharedMethods.slice(12, 21),
sharedMethods[21],
'file.imageDropped',
...sharedMethods.slice(22),
],
'native shell documented method table',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.mobile),
mobileCapabilities,
'mobile shell documented common capabilities',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.mobileIosExtra),
iosExtraCapabilities,
'mobile shell documented iOS extra capabilities',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.desktop),
desktopCapabilities,
'desktop shell 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 assertHostBridgeLayerLayout() {
const wechatBridgeFiles = fs
.readdirSync('miniprogram/host-bridge', { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
assertSameList(
wechatBridgeFiles,
expectedWechatHostBridgeFiles,
'wechat host bridge files',
);
const wechatShellLayerFiles = fs
.readdirSync('miniprogram/shell', { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
assertSameList(
wechatShellLayerFiles,
expectedWechatShellFiles,
'wechat shell files',
);
const wechatPagePaths = [
'miniprogram/pages/web-view/index.js',
'miniprogram/pages/wechat-pay/index.js',
'miniprogram/pages/share-grid/index.js',
'miniprogram/pages/subscribe-message/index.js',
];
for (const pagePath of wechatPagePaths) {
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`);
}
}
const mobileHostBridgeFiles = fs
.readdirSync('apps/mobile-shell/src/host-bridge', { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
assertSameList(
mobileHostBridgeFiles,
expectedMobileHostBridgeFiles,
'mobile host bridge files',
);
const mobileShellLayerFiles = fs
.readdirSync('apps/mobile-shell/src/shell', { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
assertSameList(
mobileShellLayerFiles,
expectedMobileShellFiles,
'mobile shell bridge 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');
}
const desktopEntrypointRustFiles = fs
.readdirSync('apps/desktop-shell/src-tauri/src', { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.rs'))
.map((entry) => entry.name)
.sort();
assertSameList(
desktopEntrypointRustFiles,
['app.rs', 'main.rs'],
'desktop shell entrypoint Rust files',
);
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');
}
const desktopHostBridgeRustFiles = fs
.readdirSync('apps/desktop-shell/src-tauri/src/host_bridge', { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.rs'))
.map((entry) => entry.name)
.sort();
assertSameList(
desktopHostBridgeRustFiles,
expectedDesktopHostBridgeRustFiles,
'desktop host bridge Rust files',
);
const desktopShellRustFiles = fs
.readdirSync('apps/desktop-shell/src-tauri/src/shell', { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.rs'))
.map((entry) => entry.name)
.sort();
assertSameList(
desktopShellRustFiles,
expectedDesktopShellRustFiles,
'desktop shell Rust bridge files',
);
}
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] 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] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();
console.log('[check:native-shells] OK');