Files
Genarrative/apps/desktop-shell/scripts/check-config.mjs
T
kdletters 9eee997731 收紧原生壳桥接权限边界
移动壳 HostBridge 协议名和版本改为共享契约常量

移动壳配置检查锁定主动导航和 deep link 宿主上下文补写

桌面壳主窗口 capability 移除 Tauri core 默认权限

桌面壳配置检查拒绝 core 默认权限和插件权限外露

更新原生壳方案与共享决策记录
2026-06-18 23:52:40 +08:00

1438 lines
44 KiB
JavaScript

import fs from 'node:fs';
const configPath = new URL('../src-tauri/tauri.conf.json', import.meta.url);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const packagePath = new URL('../package.json', import.meta.url);
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const rootPackagePath = new URL('../../../package.json', import.meta.url);
const rootPackageConfig = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url);
const rootPackageLock = JSON.parse(fs.readFileSync(rootPackageLockPath, 'utf8'));
const capabilityPath = new URL(
'../src-tauri/capabilities/main.json',
import.meta.url,
);
const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
const buildScriptPath = new URL('../src-tauri/build.rs', import.meta.url);
const buildScript = fs.readFileSync(buildScriptPath, 'utf8');
const cargoManifestPath = new URL('../src-tauri/Cargo.toml', import.meta.url);
const cargoManifest = fs.readFileSync(cargoManifestPath, 'utf8');
const cargoLockPath = new URL('../src-tauri/Cargo.lock', import.meta.url);
const cargoLock = fs.readFileSync(cargoLockPath, 'utf8');
const iconDirPath = new URL('../src-tauri/icons/', import.meta.url);
const generatedPermissionDir = new URL(
'../src-tauri/permissions/autogenerated/',
import.meta.url,
);
const sharedContractPath = new URL(
'../../../packages/shared/src/contracts/hostBridge.ts',
import.meta.url,
);
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
const nativeAppHostBridgePath = new URL(
'../../../src/services/host-bridge/nativeAppHostBridge.ts',
import.meta.url,
);
const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8');
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
const main = fs.readFileSync(mainPath, 'utf8');
const rustSourceDir = new URL('../src-tauri/src/', import.meta.url);
const desktopHostBridgeCapabilitiesPath = new URL(
'../src-tauri/src/host_bridge/capabilities.rs',
import.meta.url,
);
const desktopHostBridgeCapabilitiesSource = fs.readFileSync(
desktopHostBridgeCapabilitiesPath,
'utf8',
);
const desktopHostBridgeDispatchPath = new URL(
'../src-tauri/src/host_bridge/dispatch.rs',
import.meta.url,
);
const desktopHostBridgeDispatchSource = fs.readFileSync(
desktopHostBridgeDispatchPath,
'utf8',
);
const productionSourceRoots = [
new URL('../package.json', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
new URL('../src-tauri/build.rs', import.meta.url),
new URL('../src-tauri/capabilities/main.json', import.meta.url),
new URL('../src-tauri/src/', import.meta.url),
new URL('../src-tauri/tauri.conf.json', import.meta.url),
];
const productionFileExtensions = new Set(['.json', '.mjs', '.rs', '.toml']);
const devScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'place' + 'holder',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'占' + '位',
'模' + '拟',
'伪' + '造',
];
const blockedDesktopNpmDependencies = [
'@amplitude/analytics-browser',
'@bugsnag/js',
'@datadog/browser-rum',
'@segment/analytics-next',
'@sentry/browser',
'@sentry/react',
'@sentry/tauri',
'@sentry/tracing',
'@tauri-apps/plugin-log',
'@tauri-apps/plugin-updater',
'amplitude-js',
'mixpanel-browser',
'posthog-js',
];
const blockedDesktopCargoDependencies = [
'bugsnag',
'datadog',
'opentelemetry',
'opentelemetry-otlp',
'posthog-rs',
'sentry',
'sentry-tauri',
'tauri-plugin-log',
'tauri-plugin-updater',
'tracing-opentelemetry',
];
const blockedDesktopSdkSnippets = [
'@sentry/',
'Bugsnag.start',
'Datadog',
'Sentry.init',
'analytics.load',
'amplitude.init',
'datadogRum.init',
'mixpanel.init',
'opentelemetry::',
'posthog.init',
'sentry::init',
'sentry_tauri',
'tauri_plugin_log::',
'tauri_plugin_updater::',
'tracing_opentelemetry',
];
const requiredBundleIcons = [
'icons/32x32.png',
'icons/128x128.png',
'icons/128x128@2x.png',
'icons/icon.icns',
'icons/icon.ico',
'icons/icon.png',
];
const requiredPngIconSizes = new Map([
['32x32.png', 32],
['128x128.png', 128],
['128x128@2x.png', 256],
['icon.png', 512],
]);
function extractCargoPackageString(source, key) {
const match = source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"`, 'm'));
if (!match) {
throw new Error(`unable to read Cargo package ${key}`);
}
return match[1];
}
function extractCargoDependencyLine(source, sectionName, dependencyName) {
let inSection = false;
for (const line of source.split('\n')) {
if (/^\[[^\]]+\]$/.test(line)) {
inSection = line === `[${sectionName}]`;
continue;
}
if (!inSection) {
continue;
}
if (line.match(new RegExp(`^${escapeRegExp(dependencyName)}\\s*=`))) {
return line;
}
}
throw new Error(`Cargo ${sectionName}.${dependencyName} is missing`);
}
function extractCargoLockPackages(source) {
return source
.split('\n[[package]]\n')
.map((block) => block.trim())
.filter((block) => block.includes('name = '))
.map((block) => {
const name = block.match(/^name = "([^"]+)"/m)?.[1];
const version = block.match(/^version = "([^"]+)"/m)?.[1];
const dependenciesMatch = block.match(/^dependencies = \[\n([\s\S]*?)\n\]/m);
const dependencies = dependenciesMatch
? [...dependenciesMatch[1].matchAll(/ "([^"]+)"/g)].map((entry) => entry[1])
: [];
return { block, dependencies, name, version };
});
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function assertNoBlockedNpmDependencies() {
const dependencySections = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
];
for (const dependency of blockedDesktopNpmDependencies) {
for (const section of dependencySections) {
if (packageConfig[section]?.[dependency]) {
throw new Error(
`desktop shell must not depend on ${dependency} before real observability/channel contracts exist`,
);
}
}
}
}
function assertNoBlockedNpmLockPackages() {
const packageNames = Object.keys(rootPackageLock.packages ?? {})
.filter((packagePath) => packagePath.startsWith('node_modules/'))
.map((packagePath) => packagePath.replace(/^node_modules\//, ''));
const dependencyNames = Object.keys(rootPackageLock.dependencies ?? {});
const lockedPackageNames = new Set([...packageNames, ...dependencyNames]);
for (const dependency of blockedDesktopNpmDependencies) {
if (lockedPackageNames.has(dependency)) {
throw new Error(
`root package-lock must not resolve ${dependency} before real observability/channel contracts exist`,
);
}
}
for (const dependency of lockedPackageNames) {
if (dependency === '@tauri-apps/api' || dependency.startsWith('@tauri-apps/plugin-')) {
throw new Error(
`root package-lock must not resolve ${dependency}; desktop H5 must use HostBridge through the injected Tauri global only`,
);
}
}
}
function collectNpmDependencyNames(packageJson) {
const dependencySections = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
];
return dependencySections.flatMap((section) =>
Object.keys(packageJson[section] ?? {}),
);
}
function assertNoTauriGuestNpmDependencies(packageJson, packageLabel) {
for (const dependency of collectNpmDependencyNames(packageJson)) {
if (dependency === '@tauri-apps/api' || dependency.startsWith('@tauri-apps/plugin-')) {
throw new Error(
`${packageLabel} must not depend on ${dependency}; desktop H5 must use HostBridge through the injected Tauri global only`,
);
}
}
}
function assertNoBlockedCargoDependencies() {
for (const dependency of blockedDesktopCargoDependencies) {
const dependencyPattern = new RegExp(
`^\\s*(?:"${escapeRegExp(dependency)}"|${escapeRegExp(dependency)})\\s*=`,
'm',
);
if (dependencyPattern.test(cargoManifest)) {
throw new Error(
`desktop shell must not depend on ${dependency} before real observability/channel contracts exist`,
);
}
}
}
function assertNoBlockedCargoLockPackages() {
const cargoPackageNames = new Set(
cargoLockPackages.map((entry) => entry.name).filter(Boolean),
);
for (const dependency of blockedDesktopCargoDependencies) {
if (cargoPackageNames.has(dependency)) {
throw new Error(
`Cargo.lock must not resolve ${dependency} before real observability/channel contracts exist`,
);
}
}
}
function assertNoBlockedDesktopSdkSnippets() {
const sources = [
['tauri.conf.json', JSON.stringify(config)],
['build.rs', buildScript],
['src-tauri/src', rustHostSource],
];
for (const [sourceName, source] of sources) {
for (const snippet of blockedDesktopSdkSnippets) {
if (source.includes(snippet)) {
throw new Error(
`desktop shell ${sourceName} must not initialize ${snippet} before real observability/channel contracts exist`,
);
}
}
}
}
function assertPackageScript(packageJson, packageLabel, scriptName, expected) {
const actual = packageJson.scripts?.[scriptName];
if (actual !== expected) {
throw new Error(
`${packageLabel} script ${scriptName} drifted: expected ${expected} but got ${actual}`,
);
}
}
function assertPackageDependencyVersion(
packageJson,
packageLabel,
section,
dependency,
expected,
) {
const actual = packageJson[section]?.[dependency];
if (actual !== expected) {
throw new Error(
`${packageLabel} ${section}.${dependency} drifted: expected ${expected} but got ${actual}`,
);
}
}
function assertPackageLockVersion(dependency, expected) {
const actual = rootPackageLock.packages?.[`node_modules/${dependency}`]?.version;
if (actual !== expected) {
throw new Error(
`root package-lock ${dependency} resolved version drifted: expected ${expected} but got ${actual}`,
);
}
}
function assertCargoDependencyLine(sectionName, dependencyName, expectedLine) {
const actualLine = extractCargoDependencyLine(
cargoManifest,
sectionName,
dependencyName,
);
if (actualLine !== expectedLine) {
throw new Error(
`Cargo ${sectionName}.${dependencyName} drifted: expected ${expectedLine} but got ${actualLine}`,
);
}
}
const cargoLockPackages = extractCargoLockPackages(cargoLock);
function findCargoLockPackage(packageName, expectedVersion) {
return cargoLockPackages.find(
(entry) => entry.name === packageName && entry.version === expectedVersion,
);
}
function assertCargoLockPackageVersion(packageName, expectedVersion) {
if (!findCargoLockPackage(packageName, expectedVersion)) {
const actualVersions = cargoLockPackages
.filter((entry) => entry.name === packageName)
.map((entry) => entry.version)
.join(', ');
throw new Error(
`Cargo.lock ${packageName} resolved version drifted: expected ${expectedVersion} but got ${actualVersions || 'missing'}`,
);
}
}
function assertCargoLockDirectDependency(
parentPackageName,
parentVersion,
dependencyName,
expectedVersion,
) {
const parentPackage = findCargoLockPackage(parentPackageName, parentVersion);
if (!parentPackage) {
throw new Error(
`Cargo.lock ${parentPackageName} ${parentVersion} is missing`,
);
}
const dependencyToken = parentPackage.dependencies.find((dependency) => {
const [name, version] = dependency.split(' ');
return name === dependencyName && (!version || version === expectedVersion);
});
if (!dependencyToken) {
throw new Error(
`Cargo.lock ${parentPackageName} ${parentVersion} dependency ${dependencyName} drifted: expected ${expectedVersion}`,
);
}
assertCargoLockPackageVersion(dependencyName, expectedVersion);
}
function collectProductionSourceFiles(entry) {
const stats = fs.statSync(entry);
if (stats.isDirectory()) {
const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`);
return fs
.readdirSync(entry, { withFileTypes: true })
.flatMap((child) =>
collectProductionSourceFiles(
new URL(`${child.name}${child.isDirectory() ? '/' : ''}`, directory),
),
);
}
const path = entry.pathname;
const extension = path.match(/\.[^.]+$/)?.[0] ?? '';
if (!productionFileExtensions.has(extension)) {
return [];
}
if (path.includes('.test.') || path.endsWith('/scripts/check-config.mjs')) {
return [];
}
return [entry];
}
const productionSourceFiles = productionSourceRoots.flatMap((root) =>
collectProductionSourceFiles(root),
);
const rustHostSourceFiles = collectProductionSourceFiles(rustSourceDir);
const rustHostSource = rustHostSourceFiles
.map((file) => fs.readFileSync(file, 'utf8'))
.join('\n');
function assertNoDevScaffoldTerms(files) {
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const lineStarts = [0];
for (let index = 0; index < source.length; index += 1) {
if (source[index] === '\n') {
lineStarts.push(index + 1);
}
}
for (const term of devScaffoldTerms) {
const matchIndex = source.toLowerCase().indexOf(term.toLowerCase());
if (matchIndex === -1) {
continue;
}
const line = lineStarts.filter((start) => start <= matchIndex).length;
throw new Error(
`desktop shell production source must not include ${term}: ${file.pathname}:${line}`,
);
}
}
}
assertNoDevScaffoldTerms(productionSourceFiles);
assertNoBlockedNpmDependencies();
assertNoTauriGuestNpmDependencies(packageConfig, 'desktop shell package');
assertNoTauriGuestNpmDependencies(rootPackageConfig, 'root H5 package');
assertNoBlockedNpmLockPackages();
assertNoBlockedCargoDependencies();
assertNoBlockedCargoLockPackages();
assertNoBlockedDesktopSdkSnippets();
for (const [scriptName, expected] of Object.entries({
dev: 'tauri dev',
build: 'tauri build',
typecheck: 'node scripts/check-config.mjs',
})) {
assertPackageScript(packageConfig, 'desktop shell package', scriptName, expected);
}
for (const [scriptName, expected] of Object.entries({
'desktop-shell:dev': 'npm --prefix apps/desktop-shell run dev',
'desktop-shell:build': 'npm --prefix apps/desktop-shell run build --',
'desktop-shell:typecheck': 'npm --prefix apps/desktop-shell run typecheck',
'desktop-shell:test': 'cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml',
})) {
assertPackageScript(rootPackageConfig, 'root package', scriptName, expected);
}
for (const [dependency, expected] of Object.entries({
'@tauri-apps/cli': '^2.11.2',
typescript: '~5.8.2',
})) {
assertPackageDependencyVersion(
packageConfig,
'desktop shell package',
'devDependencies',
dependency,
expected,
);
assertPackageDependencyVersion(
rootPackageConfig,
'root package',
'devDependencies',
dependency,
expected,
);
}
for (const [dependency, expected] of Object.entries({
'@tauri-apps/cli': '2.11.2',
typescript: '5.8.3',
})) {
assertPackageLockVersion(dependency, expected);
}
for (const [sectionName, dependencyName, expectedLine] of [
['build-dependencies', 'tauri-build', 'tauri-build = { version = "2.6.2", features = [] }'],
['dependencies', 'base64', 'base64 = "0.22"'],
['dependencies', 'serde', 'serde = { version = "1", features = ["derive"] }'],
['dependencies', 'serde_json', 'serde_json = "1"'],
['dependencies', 'tauri', 'tauri = { version = "2.11.2", features = ["tray-icon"] }'],
[
'dependencies',
'tauri-plugin-clipboard-manager',
'tauri-plugin-clipboard-manager = "2.3.2"',
],
[
'dependencies',
'tauri-plugin-dialog',
'tauri-plugin-dialog = "2.7.1"',
],
[
'dependencies',
'tauri-plugin-deep-link',
'tauri-plugin-deep-link = "2.4.9"',
],
[
'dependencies',
'tauri-plugin-notification',
'tauri-plugin-notification = "2.3.3"',
],
[
'dependencies',
'tauri-plugin-opener',
'tauri-plugin-opener = "2.5.4"',
],
[
'dependencies',
'tauri-plugin-single-instance',
'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }',
],
[
'dependencies',
'tauri-plugin-window-state',
'tauri-plugin-window-state = "2.4.1"',
],
]) {
assertCargoDependencyLine(sectionName, dependencyName, expectedLine);
}
for (const [packageName, expectedVersion] of [
['tauri-build', '2.6.2'],
['base64', '0.22.1'],
['serde', '1.0.228'],
['serde_json', '1.0.150'],
['tauri', '2.11.2'],
['tauri-plugin-clipboard-manager', '2.3.2'],
['tauri-plugin-deep-link', '2.4.9'],
['tauri-plugin-dialog', '2.7.1'],
['tauri-plugin-notification', '2.3.3'],
['tauri-plugin-opener', '2.5.4'],
['tauri-plugin-single-instance', '2.4.2'],
['tauri-plugin-window-state', '2.4.1'],
]) {
assertCargoLockPackageVersion(packageName, expectedVersion);
}
for (const [dependencyName, expectedVersion] of [
['tauri-build', '2.6.2'],
['base64', '0.22.1'],
['serde', '1.0.228'],
['serde_json', '1.0.150'],
['tauri', '2.11.2'],
['tauri-plugin-clipboard-manager', '2.3.2'],
['tauri-plugin-deep-link', '2.4.9'],
['tauri-plugin-dialog', '2.7.1'],
['tauri-plugin-notification', '2.3.3'],
['tauri-plugin-opener', '2.5.4'],
['tauri-plugin-single-instance', '2.4.2'],
['tauri-plugin-window-state', '2.4.1'],
]) {
assertCargoLockDirectDependency(
'genarrative-desktop-shell',
config.version,
dependencyName,
expectedVersion,
);
}
function readPngSize(file) {
const buffer = fs.readFileSync(file);
if (
buffer.length < 24 ||
buffer.toString('hex', 0, 8) !== '89504e470d0a1a0a'
) {
throw new Error(`desktop shell icon is not a PNG: ${file.pathname}`);
}
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
const dataStart = buffer.indexOf(Buffer.from('IDAT'));
const hasTransparencyChunk = buffer.includes(Buffer.from('tRNS'));
const hasAlphaColorType = buffer[25] === 4 || buffer[25] === 6;
return {
width,
height,
dataStart,
hasTransparency: hasTransparencyChunk || hasAlphaColorType,
};
}
function assertDesktopIconSet() {
const icons = config.bundle?.icon ?? [];
if (
icons.length !== requiredBundleIcons.length ||
requiredBundleIcons.some((icon) => !icons.includes(icon))
) {
throw new Error('desktop shell must bundle the full real desktop icon set');
}
for (const [fileName, expectedSize] of requiredPngIconSizes) {
const icon = readPngSize(new URL(fileName, iconDirPath));
if (icon.width !== expectedSize || icon.height !== expectedSize) {
throw new Error(`desktop shell icon ${fileName} must be ${expectedSize}x${expectedSize}`);
}
if (!icon.hasTransparency || icon.dataStart === -1) {
throw new Error(`desktop shell icon ${fileName} must use a real transparent brand asset`);
}
}
const ico = fs.readFileSync(new URL('icon.ico', iconDirPath));
if (
ico.length < 6 ||
ico.readUInt16LE(0) !== 0 ||
ico.readUInt16LE(2) !== 1 ||
ico.readUInt16LE(4) < 3
) {
throw new Error('desktop shell Windows icon must be a multi-size ICO');
}
const icns = fs.readFileSync(new URL('icon.icns', iconDirPath));
if (
icns.length < 16 ||
icns.toString('ascii', 0, 4) !== 'icns' ||
icns.readUInt32BE(4) !== icns.length
) {
throw new Error('desktop shell macOS icon must be a valid ICNS');
}
}
function extractStringArrayExport(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(...extractStringArrayExport(source, entry[1], nextSeen));
} else {
entries.push(entry[2]);
}
}
return entries;
}
function extractRustStringArrayConst(source, constName) {
const match = source.match(
new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`),
);
if (!match) {
throw new Error(`unable to read Rust const ${constName}`);
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractTsStringConst(source, constName) {
const match = source.match(
new RegExp(`export const ${constName}\\s*=\\s*'([^']+)';`),
);
if (!match) {
throw new Error(`unable to read TypeScript const ${constName}`);
}
return match[1];
}
function extractDesktopCapabilities(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 extractDesktopHandledMethods(source) {
const matchBodies = [...source.matchAll(/match request\.method\.as_str\(\) \{([\s\S]*?)\n \}/g)].map(
(match) => match[1],
);
if (matchBodies.length === 0) {
throw new Error('unable to read desktop shell HostBridge handler methods');
}
return [
...new Set(
matchBodies.flatMap((body) =>
[...body.matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]),
),
),
];
}
function resolveHostCapabilitiesFromUrl(rawUrl) {
const url = new URL(rawUrl, 'https://app.genarrative.world/');
return (url.searchParams.get('hostCapabilities') ?? '')
.split(',')
.map((capability) => capability.trim())
.filter(Boolean);
}
function resolveHostVersionFromUrl(rawUrl) {
const url = new URL(rawUrl, 'https://app.genarrative.world/');
return url.searchParams.get('hostVersion');
}
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 extractTauriBuildCommands(source) {
const match = source.match(/\.commands\(\s*&\[\s*([^\]]*?)\s*\]\s*\)/);
if (!match) {
throw new Error('unable to read Tauri build manifest commands');
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractTauriInvokeCommands(source) {
const match = source.match(/tauri::generate_handler!\[\s*([^\]]*?)\s*\]/);
if (!match) {
throw new Error('unable to read Tauri invoke handler commands');
}
return match[1]
.split(',')
.map((command) => command.trim())
.filter(Boolean);
}
function collectRustSourceBasenames(files) {
return files.map((file) => file.pathname.split('/').pop()).sort();
}
function collectRustSourceRelativePaths(files) {
const rootPath = rustSourceDir.pathname.endsWith('/')
? rustSourceDir.pathname
: `${rustSourceDir.pathname}/`;
return files
.map((file) => file.pathname.replace(rootPath, ''))
.sort();
}
function extractNativeAppTauriInvokeCommands(source) {
return [
...source.matchAll(
/\btauriInvoke(?:<[^)]*>)?\(\s*([A-Z_][A-Z0-9_]*)\s*,/g,
),
]
.map((match) => match[1].trim())
.filter(Boolean);
}
function assertGeneratedPermissions(commandNames) {
if (!fs.existsSync(generatedPermissionDir)) {
return;
}
const expectedPermissionFiles = new Set(
commandNames.map((command) => `${command}.toml`),
);
for (const entry of fs.readdirSync(generatedPermissionDir, {
withFileTypes: true,
})) {
if (entry.isDirectory()) {
throw new Error(
`desktop shell generated permissions must not include nested directory ${entry.name}`,
);
}
if (!expectedPermissionFiles.has(entry.name)) {
throw new Error(
`desktop shell generated permission exposes an unexpected command: ${entry.name}`,
);
}
const permissionFile = new URL(entry.name, generatedPermissionDir);
const permissionSource = fs.readFileSync(permissionFile, 'utf8');
const commandName = entry.name.replace(/\.toml$/, '');
for (const expectedSnippet of [
`identifier = "allow-${commandName.replaceAll('_', '-')}"`,
`commands.allow = ["${commandName}"]`,
`commands.deny = ["${commandName}"]`,
]) {
if (!permissionSource.includes(expectedSnippet)) {
throw new Error(
`desktop shell generated permission ${entry.name} drifted from ${commandName}`,
);
}
}
}
}
function assertOnlyMainCapabilityFile() {
const capabilityDir = new URL('../src-tauri/capabilities/', import.meta.url);
const files = fs
.readdirSync(capabilityDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
assertSameList(files, ['main.json'], 'desktop shell capability files');
}
const sharedCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_CAPABILITIES',
);
const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS');
const desktopCapabilities = extractDesktopCapabilities(
desktopHostBridgeCapabilitiesSource,
);
const desktopHandledMethods = extractDesktopHandledMethods(
desktopHostBridgeDispatchSource,
);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist');
const unknownHandledDesktopMethods = desktopHandledMethods.filter(
(method) => !sharedMethods.includes(method),
);
if (unknownHandledDesktopMethods.length > 0) {
throw new Error(
`desktop shell handles unknown HostBridge methods: ${unknownHandledDesktopMethods.join(', ')}`,
);
}
const unknownDesktopCapabilities = desktopCapabilities.filter(
(capability) => !sharedCapabilities.includes(capability),
);
if (unknownDesktopCapabilities.length > 0) {
throw new Error(
`desktop shell declares unknown HostBridge capabilities: ${unknownDesktopCapabilities.join(', ')}`,
);
}
for (const capability of sdkBackedCapabilities) {
if (desktopCapabilities.includes(capability)) {
throw new Error(
`desktop shell must not declare ${capability} until a real SDK/channel flow is implemented`,
);
}
}
const missingDesktopMethodHandlers = desktopCapabilities.filter(
(capability) =>
sharedMethods.includes(capability) &&
!desktopHandledMethods.includes(capability),
);
if (missingDesktopMethodHandlers.length > 0) {
throw new Error(
`desktop shell declares request capabilities without HostBridge handlers: ${missingDesktopMethodHandlers.join(', ')}`,
);
}
const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter(
(method) =>
!desktopCapabilities.includes(method) && !sdkBackedCapabilities.includes(method),
);
if (undeclaredDesktopMethodHandlers.length > 0) {
throw new Error(
`desktop shell handles unadvertised HostBridge methods: ${undeclaredDesktopMethodHandlers.join(', ')}`,
);
}
if (config.productName !== 'Genarrative') {
throw new Error('desktop shell productName must be Genarrative');
}
if (config.identifier !== 'world.genarrative.desktop') {
throw new Error('desktop shell identifier must be world.genarrative.desktop');
}
if (config.version !== '0.1.0') {
throw new Error('desktop shell app version must be 0.1.0');
}
if (packageConfig.version !== config.version) {
throw new Error('desktop shell package version must match tauri.conf.json version');
}
if (extractCargoPackageString(cargoManifest, 'name') !== 'genarrative-desktop-shell') {
throw new Error('desktop shell Cargo package name must be genarrative-desktop-shell');
}
if (extractCargoPackageString(cargoManifest, 'version') !== config.version) {
throw new Error('desktop shell Cargo package version must match tauri.conf.json version');
}
if (!rustHostSource.includes('host_version: env!("CARGO_PKG_VERSION")')) {
throw new Error('desktop shell runtime response must use the Cargo package version');
}
if (config.build?.frontendDist !== '../../../dist') {
throw new Error('desktop shell must package the root H5 dist');
}
if (config.build?.beforeBuildCommand !== 'npm --prefix ../.. run build:raw && npm run typecheck') {
throw new Error('desktop shell build command must run from apps/desktop-shell');
}
if (config.build?.beforeDevCommand !== 'npm --prefix ../.. run dev:web') {
throw new Error('desktop shell dev command must run the root H5 Vite dev server');
}
const [mainWindow] = config.app?.windows ?? [];
if (!mainWindow || mainWindow.create !== false) {
throw new Error('desktop shell must create the main window from Rust setup');
}
if ((config.app?.windows ?? []).length !== 1) {
throw new Error('desktop shell must expose exactly one configured main window');
}
if (mainWindow.label !== 'main') {
throw new Error('desktop shell main window label must be main');
}
if (String(mainWindow.url ?? '').startsWith('http')) {
throw new Error('desktop shell release window must load packaged H5 assets');
}
if (!String(mainWindow.url ?? '').startsWith('index.html?')) {
throw new Error('desktop shell release window must enter through packaged index.html');
}
if (!String(config.build?.devUrl ?? '').startsWith('http://127.0.0.1:3000/?')) {
throw new Error('desktop shell dev URL must load the local Vite H5 entry');
}
if (mainWindow.devtools !== false) {
throw new Error('desktop shell main WebView devtools must be explicitly disabled');
}
assertDesktopIconSet();
if (config.bundle?.active !== true || config.bundle?.targets !== 'all') {
throw new Error('desktop shell bundle targets must remain enabled for all platforms');
}
const csp = String(config.app?.security?.csp ?? '');
const devCsp = String(config.app?.security?.devCsp ?? '');
for (const blockedCspToken of ["'unsafe-eval'", 'tauri:', 'file:']) {
if (csp.includes(blockedCspToken)) {
throw new Error(`desktop shell CSP must not include ${blockedCspToken}`);
}
if (devCsp.includes(blockedCspToken)) {
throw new Error(`desktop shell dev CSP must not include ${blockedCspToken}`);
}
}
for (const releaseOnlyBlockedCspToken of [
'http://127.0.0.1',
'ws://127.0.0.1',
]) {
if (csp.includes(releaseOnlyBlockedCspToken)) {
throw new Error(
`desktop shell release CSP must not include ${releaseOnlyBlockedCspToken}`,
);
}
}
for (const requiredCspToken of [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
'connect-src',
'frame-src',
]) {
if (!csp.includes(requiredCspToken)) {
throw new Error(`desktop shell CSP missing ${requiredCspToken}`);
}
}
for (const requiredDevCspToken of [
'http://127.0.0.1:*',
'ws://127.0.0.1:*',
"script-src 'self'",
]) {
if (!devCsp.includes(requiredDevCspToken)) {
throw new Error(`desktop shell dev CSP missing ${requiredDevCspToken}`);
}
}
const requiredUrlParts = [
'clientRuntime=native_app',
'clientType=native_app',
'hostShell=tauri_desktop',
'hostPlatform=unknown',
'bridgeVersion=1',
];
for (const part of requiredUrlParts) {
if (!String(mainWindow.url ?? '').includes(part)) {
throw new Error(`desktop shell main window URL missing ${part}`);
}
if (!String(config.build?.devUrl ?? '').includes(part)) {
throw new Error(`desktop shell dev URL missing ${part}`);
}
}
if (resolveHostVersionFromUrl(mainWindow.url) !== config.version) {
throw new Error('desktop shell main window hostVersion must match tauri.conf.json version');
}
if (resolveHostVersionFromUrl(config.build?.devUrl ?? '') !== config.version) {
throw new Error('desktop shell dev hostVersion must match tauri.conf.json version');
}
assertSameList(
resolveHostCapabilitiesFromUrl(mainWindow.url),
desktopCapabilities,
'desktop shell main window hostCapabilities',
);
assertSameList(
resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? ''),
desktopCapabilities,
'desktop shell dev hostCapabilities',
);
for (const capability of sdkBackedCapabilities) {
if (
resolveHostCapabilitiesFromUrl(mainWindow.url).includes(capability) ||
resolveHostCapabilitiesFromUrl(config.build?.devUrl ?? '').includes(capability)
) {
throw new Error(
`desktop shell URL must not advertise ${capability} without a real SDK/channel flow`,
);
}
}
const allowedPermissions = [
'allow-host-bridge-request',
];
const blockedCoreDefaultPermissions = [
'core:default',
'core:app:default',
'core:event:default',
'core:image:default',
'core:menu:default',
'core:path:default',
'core:resources:default',
'core:tray:default',
'core:webview:default',
'core:window:default',
];
const blockedCorePermissionPrefixes = [
'core:app:',
'core:event:',
'core:image:',
'core:menu:',
'core:path:',
'core:resources:',
'core:tray:',
'core:webview:',
'core:window:',
];
const blockedPluginPermissionPrefixes = [
'clipboard-manager:',
'deep-link:',
'dialog:',
'fs:',
'notification:',
'opener:',
'window-state:',
];
const sharedTauriCommand = extractTsStringConst(
sharedContractSource,
'HOST_BRIDGE_TAURI_COMMAND',
);
const allowedTauriCommands = [sharedTauriCommand];
const requiredRustHostModules = [
'host_bridge/capabilities.rs',
'host_bridge/dispatch.rs',
'host_bridge/files.rs',
'host_bridge/mod.rs',
'host_bridge/protocol.rs',
'host_bridge/share.rs',
'main.rs',
'shell/deep_link.rs',
'shell/events.rs',
'shell/file_drop.rs',
'shell/lifecycle.rs',
'shell/menu.rs',
'shell/mod.rs',
'shell/navigation.rs',
'shell/network.rs',
'shell/runtime.rs',
'shell/tray.rs',
'shell/url.rs',
'shell/webview.rs',
'shell/window_state.rs',
];
const requiredRustHostSnippets = [
'tauri_plugin_single_instance::init',
'desktop_window_state_plugin()',
'tauri_plugin_window_state::Builder::default()',
'StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED',
'tauri_plugin_deep_link::init()',
'register_desktop_deep_link_events(app)',
'register_desktop_deep_link_schemes(app)',
'DeepLinkExt',
'app.deep_link().on_open_url',
'app.deep_link().get_current()',
'app.deep_link().register_all()',
'normalize_desktop_deep_link_url',
'DESKTOP_DEEP_LINK_HOSTS',
'resolve_desktop_single_instance_action',
'tauri_plugin_clipboard_manager::init()',
'register_desktop_app_menu(app)?',
'DESKTOP_APP_MENU_SHOW',
'DESKTOP_APP_MENU_BACK',
'DESKTOP_APP_MENU_FORWARD',
'DESKTOP_APP_MENU_RELOAD',
'DESKTOP_APP_MENU_QUIT',
'DesktopAppMenuAction::GoBack',
'DesktopAppMenuAction::GoForward',
'DESKTOP_HISTORY_BACK_SCRIPT',
'DESKTOP_HISTORY_FORWARD_SCRIPT',
'window.history.back(); true;',
'window.history.forward(); true;',
'resolve_desktop_app_menu_action',
'app.set_menu',
'app.on_menu_event',
'Submenu::with_items',
'PredefinedMenuItem::copy',
'TrayIconBuilder::with_id',
'register_desktop_tray(app)',
'DESKTOP_TRAY_ID',
'TRAY_MENU_SHOW',
'TRAY_MENU_RELOAD',
'TRAY_MENU_QUIT',
'show_main_window',
'reload_main_window',
'register_desktop_window_close_events',
'resolve_desktop_window_close_action',
'WindowEvent::CloseRequested',
'api.prevent_close()',
'close_window.hide()',
'desktop tray registration failed',
'"appearance.getColorScheme"',
'"host.events"',
'"app.lifecycle"',
'"network.status"',
'"network.statusChanged"',
'"share.open"',
'"share.setTarget"',
'"navigation.openNativePage"',
'"navigation.canGoBack"',
'"app.reloadWebView"',
'"app.setTitle"',
'"app.setBadgeCount"',
'"clipboard.writeText"',
'"clipboard.readText"',
'"file.exportText"',
'"file.importText"',
'"file.exportImage"',
'"file.importImage"',
'"file.importAudio"',
'"file.exportAudio"',
'"file.imageDropped"',
'"notification.showLocal"',
'tauri_plugin_dialog::init()',
'tauri_plugin_notification::init()',
'tauri_plugin_notification::{NotificationExt, PermissionState}',
'DesktopNotificationPermissionAction',
'desktop_notification_permission_action',
'PermissionState::Granted',
'PermissionState::Denied',
'PermissionState::Prompt | PermissionState::PromptWithRationale',
'notification_manager.permission_state()',
'notification_manager.request_permission()',
'"notification permission denied"',
'"copied_to_clipboard"',
'"file export cancelled"',
'"file import cancelled"',
'BASE64_STANDARD.decode',
'blocking_pick_file',
'import_text_file_payload',
'import_image_file_payload',
'import_audio_file_payload',
'export_image_payload',
'export_audio_payload',
'detect_image_mime_type',
'detect_audio_mime_type',
'ensure_image_bytes_match_mime_type',
'ensure_audio_bytes_match_mime_type',
'"image bytes do not match MIME"',
'"audio bytes do not match MIME"',
'set_title',
'set_badge_count',
'window.reload()',
'read_text()',
'normalize_clipboard_text',
'window.theme()',
'WindowEvent::Focused',
'WindowEvent::DragDrop',
'first_valid_desktop_image_drop_payload',
'.filter(|path| path.is_file() && import_image_mime_type(path).is_some())',
'.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())',
'PageLoadEvent',
'host_bridge_event_script',
'origin: window.location.origin',
'source: window',
'should_replay_desktop_webview_state_on_page_load',
'PageLoadEvent::Finished',
'replay_desktop_webview_state',
'window.is_focused()',
'resolve_desktop_network_status',
'network.statusChanged',
'register_desktop_navigation_events',
'desktop_navigation_state_script',
'navigation.canGoBack',
'__GENARRATIVE_DESKTOP_NAVIGATION_STATE_INSTALLED__',
'window.history.pushState',
'window.history.replaceState',
"window.addEventListener('popstate'",
'__genarrativeDesktopHistoryIndex',
'file.imageDropped',
'app.notification().builder()',
'desktop_entry_url_with_platform',
'desktop_h5_url_with_host_context',
'desktop_h5_url_with_host_context(target_url)',
'desktop_h5_url_with_host_context(normalized_url)',
'desktop_window_config_with_runtime_platform',
'desktop_window_config_with_runtime_platform(config)',
'should_allow_desktop_webview_navigation',
'desktop_external_navigation_url',
'open_desktop_external_navigation',
'.on_navigation(move |url|',
'.on_new_window(move |url, _features|',
'.on_page_load(|window, payload|',
'DownloadEvent',
'should_allow_desktop_webview_download',
'DownloadEvent::Requested { .. } => false',
'.on_download(|_webview, event| should_allow_desktop_webview_download(&event))',
'NewWindowResponse::Deny',
'HOST_BRIDGE_METHODS',
'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH',
'normalize_request_id',
'is_host_bridge_method',
'invalid host bridge request id',
'invalid host bridge method',
'HostBridgeReplayState',
'HostBridgeReplayReservation',
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
'.manage(HostBridgeReplayState::default())',
'replay_state.reserve(&request.id)',
'HostBridgeReplayState::wait_for_response',
'execute_host_bridge_request(app, request).await',
'replay_state.complete(slot, response)',
];
assertSameList(
allowedTauriCommands,
['host_bridge_request'],
'shared Tauri HostBridge command',
);
for (const moduleName of requiredRustHostModules) {
if (!collectRustSourceRelativePaths(rustHostSourceFiles).includes(moduleName)) {
throw new Error(`desktop shell Rust bridge module missing ${moduleName}`);
}
}
assertSameList(
extractNativeAppTauriInvokeCommands(nativeAppHostBridgeSource),
['HOST_BRIDGE_TAURI_COMMAND'],
'H5 native app Tauri invoke command source',
);
if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) {
throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND');
}
for (const snippet of [
'function createNativeHostBridgeTimeoutError()',
'async function invokeTauriHostBridgeWithTimeout',
'Promise.race',
'tauriInvoke<HostBridgeResponse<Result>>(HOST_BRIDGE_TAURI_COMMAND',
"message: 'host_bridge_timeout'",
]) {
if (!nativeAppHostBridgeSource.includes(snippet)) {
throw new Error(`H5 native app Tauri transport missing ${snippet}`);
}
}
assertSameList(
capability.windows ?? [],
['main'],
'desktop shell capability windows',
);
assertOnlyMainCapabilityFile();
if (capability.identifier !== 'main') {
throw new Error('desktop shell capability identifier must be main');
}
if (String(capability.description ?? '').length === 0) {
throw new Error('desktop shell capability description must be explicit');
}
assertSameList(
capability.permissions ?? [],
allowedPermissions,
'desktop shell capability permissions',
);
for (const permission of capability.permissions ?? []) {
if (blockedCoreDefaultPermissions.includes(permission)) {
throw new Error(`desktop shell must not expose ${permission} to H5`);
}
if (
blockedCorePermissionPrefixes.some((prefix) => permission.startsWith(prefix))
) {
throw new Error(`desktop shell must not expose Tauri core permission ${permission} to H5`);
}
if (
blockedPluginPermissionPrefixes.some((prefix) => permission.startsWith(prefix))
) {
throw new Error(`desktop shell must not expose plugin permission ${permission} to H5`);
}
}
assertSameList(
extractTauriBuildCommands(buildScript),
allowedTauriCommands,
'desktop shell build manifest commands',
);
assertSameList(
extractTauriInvokeCommands(main),
allowedTauriCommands,
'desktop shell invoke handler commands',
);
assertGeneratedPermissions(allowedTauriCommands);
if (buildScript.includes('resolve_desktop_shell_runtime')) {
throw new Error('desktop shell build manifest exposes an unused runtime command');
}
if (!cargoManifest.includes('features = ["tray-icon"]')) {
throw new Error('desktop shell must enable the Tauri tray-icon feature');
}
if (cargoManifest.includes('"devtools"')) {
throw new Error('desktop shell must not enable Tauri devtools feature');
}
if (
!cargoManifest.includes(
'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }',
)
) {
throw new Error('desktop shell must depend on tauri-plugin-single-instance with deep-link feature');
}
if (!cargoManifest.includes('tauri-plugin-deep-link = "2.4.9"')) {
throw new Error('desktop shell must depend on tauri-plugin-deep-link');
}
if (!cargoManifest.includes('tauri-plugin-window-state = "2.4.1"')) {
throw new Error('desktop shell must depend on tauri-plugin-window-state');
}
if (
!config.plugins ||
!config.plugins['deep-link'] ||
JSON.stringify(config.plugins['deep-link']?.desktop?.schemes ?? []) !==
JSON.stringify(['genarrative'])
) {
throw new Error('desktop shell deep-link plugin must register only the genarrative scheme');
}
if (
cargoManifest.includes('tauri-plugin-updater') ||
JSON.stringify(config).includes('updater')
) {
throw new Error('desktop shell must not configure updater without real signing and endpoint');
}
const orderedDesktopPluginSnippets = [
'tauri_plugin_single_instance::init',
'desktop_window_state_plugin()',
'tauri_plugin_deep_link::init()',
'tauri_plugin_clipboard_manager::init()',
'tauri_plugin_dialog::init()',
'tauri_plugin_notification::init()',
'tauri_plugin_opener::init()',
];
for (const pluginSnippet of orderedDesktopPluginSnippets) {
if (!main.includes(pluginSnippet)) {
throw new Error(`desktop shell main.rs missing plugin ${pluginSnippet}`);
}
}
for (let index = 1; index < orderedDesktopPluginSnippets.length; index += 1) {
const previousPlugin = orderedDesktopPluginSnippets[index - 1];
const currentPlugin = orderedDesktopPluginSnippets[index];
if (main.indexOf(previousPlugin) > main.indexOf(currentPlugin)) {
throw new Error(
`desktop shell plugin order drifted: ${previousPlugin} must be registered before ${currentPlugin}`,
);
}
}
if (main.includes('single-instance",') || main.includes('"single-instance"')) {
throw new Error('desktop shell must not emit secondary-instance argv to H5');
}
for (const snippet of requiredRustHostSnippets) {
if (!rustHostSource.includes(snippet)) {
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
}
}