e4b9cbf09d
新增 scanner.scanQrCode HostBridge 契约与二维码结果归一。 接入 Expo Camera 扫码 overlay 并保留 H5 浏览器扫码回退。 让 Tauri 白名单识别扫码 method 但不声明桌面扫码能力。 同步原生壳门禁、Expo 权限检查、方案文档和共享决策记录。
933 lines
27 KiB
JavaScript
933 lines
27 KiB
JavaScript
#!/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');
|