Files
Genarrative/scripts/check-native-shells.mjs
T
kdletters 429e65952d 收口宿主事件白名单
新增共享 HostBridge event 白名单与类型守卫

让 H5、Expo 和 Tauri 只分发白名单宿主事件

补齐移动端、桌面端和根级原生壳门禁

更新宿主壳方案文档和项目决策记录
2026-06-19 02:18:36 +08:00

663 lines
19 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 h5HostBridgeCallChainScanFiles = [
'src/services/clipboard.ts',
'src/services/appTitle.ts',
'src/services/runtimeAudioFeedback.ts',
'src/hooks/useHostLifecycleActive.ts',
'src/hooks/useHostNetworkOnline.ts',
'src/components/common/PublishShareModal.tsx',
'src/components/common/publishShareCardImage.ts',
'src/components/common/CreativeAudioInputPanel.tsx',
];
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',
'share.ts',
];
const expectedMobileShellFiles = [
'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 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 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/PublishShareModal.test.tsx',
'src/services/runtimeAudioFeedback.test.ts',
'src/services/clipboard.test.ts',
'src/services/appTitle.test.ts',
];
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: '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 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 assertNoProductionShellDevScaffoldTerms() {
const files = [
...productionShellScanRoots,
...h5HostBridgeCallChainScanFiles,
].flatMap(collectProductionShellFiles);
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const lowerSource = source.toLowerCase();
for (const term of productionShellDevScaffoldTerms) {
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 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),
'file.imageDropped',
...sharedMethods.slice(21),
],
'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,
['main.rs'],
'desktop shell entrypoint Rust files',
);
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');