Files
Genarrative/apps/desktop-shell/scripts/check-config.mjs
T
kdletters 75cd4f1abc 收口原生壳能力来源
新增共享 HostBridge capability profile,区分 Expo 移动通用、iOS 额外和 Tauri 桌面能力

移动壳能力清单改为引用共享 profile,入口 URL 类型支持 readonly 能力数组

两端配置检查和原生壳总门禁改为反查共享 profile,桌面 Rust 清单保持运行时镜像

补充 HostBridge profile 单测和 H5 readonly 能力合并类型兼容

同步 Expo/Tauri 壳方案、HostBridge 协议文档和项目决策记录
2026-06-19 02:02:15 +08:00

1691 lines
51 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 desktopShellUrlPath = new URL(
'../src-tauri/src/shell/url.rs',
import.meta.url,
);
const desktopShellUrlSource = fs.readFileSync(desktopShellUrlPath, '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]*?)\\][^;]*;`),
);
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 extractRustStringConst(source, constName) {
const match = source.match(
new RegExp(`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*&str\\s*=\\s*"([^"]+)";`),
);
if (!match) {
throw new Error(`unable to read Rust const ${constName}`);
}
return match[1];
}
function extractRustNumberConst(source, constName) {
const match = source.match(
new RegExp(
`(?:pub\\(crate\\)\\s+)?const ${constName}\\s*:\\s*(?:u8|u16|u32|u64|usize|i64|i32)\\s*=\\s*([^;]+);`,
),
);
if (!match) {
throw new Error(`unable to read Rust const ${constName}`);
}
return evaluateNumberExpression(match[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 extractTsNumberConst(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 evaluateNumberExpression(match[1]);
}
function evaluateNumberExpression(expression) {
const tokens = expression
.split('*')
.map((token) => token.trim())
.filter(Boolean);
if (
tokens.length === 0 ||
tokens.some((token) => !/^\d+$/.test(token))
) {
throw new Error(`unsupported numeric expression ${expression}`);
}
return tokens.reduce((value, token) => value * Number(token), 1);
}
function extractRustStringMatchArms(source, functionName) {
const match = source.match(
new RegExp(
`(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{[\\s\\S]*?match [^\\{]*\\{([\\s\\S]*?)\\n \\}`,
),
);
if (!match) {
throw new Error(`unable to read Rust match function ${functionName}`);
}
return [...match[1].matchAll(/"([^"]+)"\s*=>/g)].map((entry) => entry[1]);
}
function extractRustSomeStringValues(source, functionName) {
const match = source.match(
new RegExp(`(?:pub\\(crate\\)\\s+)?fn ${functionName}[^\\{]*\\{([\\s\\S]*?)\\n\\}`),
);
if (!match) {
throw new Error(`unable to read Rust function ${functionName}`);
}
return [...match[1].matchAll(/=>\s*Some\("([^"]+)"\)/g)].map(
(entry) => entry[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 assertNoRawHostContextUrl(urlValue, label) {
const rawUrl = String(urlValue ?? '');
const blockedQueryKeys = [
'clientRuntime',
'clientType',
'hostShell',
'hostPlatform',
'hostVersion',
'bridgeVersion',
'hostCapabilities',
];
for (const key of blockedQueryKeys) {
if (rawUrl.includes(`${key}=`)) {
throw new Error(
`${label} must not hardcode ${key}; desktop Rust shell/url.rs must append host context`,
);
}
}
}
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 sharedDesktopCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
);
const sharedHostBridgeProtocol = extractTsStringConst(
sharedContractSource,
'HOST_BRIDGE_PROTOCOL',
);
const sharedHostBridgeVersion = extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_VERSION',
);
const sharedPublicWebOrigin = extractTsStringConst(
sharedContractSource,
'HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
);
const sharedHostBridgePayloadLimits = {
HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH',
),
HOST_BRIDGE_BADGE_COUNT_MAX: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_BADGE_COUNT_MAX',
),
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH',
),
HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_FILE_NAME_MAX_LENGTH',
),
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractTsNumberConst(
sharedContractSource,
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
),
};
const desktopHostBridgePayloadLimits = {
HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH',
),
HOST_BRIDGE_BADGE_COUNT_MAX: extractRustNumberConst(
rustHostSource,
'BADGE_COUNT_MAX',
),
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'CLIPBOARD_TEXT_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
),
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'LOCAL_NOTIFICATION_BODY_MAX_LENGTH',
),
HOST_BRIDGE_FILE_NAME_MAX_LENGTH: extractRustNumberConst(
rustHostSource,
'EXPORT_FILE_NAME_MAX_LENGTH',
),
HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'EXPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'IMPORT_TEXT_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'EXPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'IMPORT_IMAGE_MAX_BYTES',
),
HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'EXPORT_AUDIO_MAX_BYTES',
),
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES: extractRustNumberConst(
rustHostSource,
'IMPORT_AUDIO_MAX_BYTES',
),
};
const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS');
const desktopHostBridgeProtocol = extractRustStringConst(
rustHostSource,
'HOST_BRIDGE_PROTOCOL',
);
const desktopHostBridgeVersion = extractRustNumberConst(
rustHostSource,
'HOST_BRIDGE_VERSION',
);
const desktopPublicWebOrigin = extractRustStringConst(
desktopShellUrlSource,
'WEB_APP_ORIGIN',
);
const desktopCapabilities = extractDesktopCapabilities(
desktopHostBridgeCapabilitiesSource,
);
const desktopHandledMethods = extractDesktopHandledMethods(
desktopHostBridgeDispatchSource,
);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
if (desktopHostBridgeProtocol !== sharedHostBridgeProtocol) {
throw new Error(
`desktop shell HostBridge protocol drifted: expected ${sharedHostBridgeProtocol} but got ${desktopHostBridgeProtocol}`,
);
}
if (desktopHostBridgeVersion !== sharedHostBridgeVersion) {
throw new Error(
`desktop shell HostBridge version drifted: expected ${sharedHostBridgeVersion} but got ${desktopHostBridgeVersion}`,
);
}
if (desktopPublicWebOrigin !== sharedPublicWebOrigin) {
throw new Error(
`desktop shell public web origin drifted: expected ${sharedPublicWebOrigin} but got ${desktopPublicWebOrigin}`,
);
}
for (const [limitName, sharedLimit] of Object.entries(
sharedHostBridgePayloadLimits,
)) {
const desktopLimit = desktopHostBridgePayloadLimits[limitName];
if (desktopLimit !== sharedLimit) {
throw new Error(
`desktop shell ${limitName} drifted: expected ${sharedLimit} but got ${desktopLimit}`,
);
}
}
if (
extractRustStringConst(rustHostSource, 'EXPORT_FILE_NAME_FALLBACK') !==
extractTsStringConst(sharedContractSource, 'HOST_BRIDGE_FILE_NAME_FALLBACK')
) {
throw new Error('desktop shell file name fallback drifted from shared HostBridge contract');
}
const sharedTextMimeTypes = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_TEXT_MIME_TYPES',
);
const sharedImageMimeTypes = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_IMAGE_MIME_TYPES',
);
const sharedAudioMimeTypes = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_AUDIO_MIME_TYPES',
);
assertSameList(
extractRustSomeStringValues(rustHostSource, 'import_text_mime_type'),
sharedTextMimeTypes,
'desktop shell text MIME types',
);
assertSameList(
extractRustStringMatchArms(rustHostSource, 'export_image_extension'),
sharedImageMimeTypes,
'desktop shell export image MIME types',
);
assertSameList(
extractRustSomeStringValues(rustHostSource, 'import_image_mime_type'),
sharedImageMimeTypes,
'desktop shell import image MIME types',
);
assertSameList(
extractRustStringMatchArms(rustHostSource, 'export_audio_extension'),
sharedAudioMimeTypes,
'desktop shell export audio MIME types',
);
assertSameList(
extractRustSomeStringValues(rustHostSource, 'import_audio_mime_type'),
sharedAudioMimeTypes,
'desktop shell import audio MIME types',
);
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(', ')}`,
);
}
assertSameList(
desktopCapabilities,
sharedDesktopCapabilities,
'desktop shell HostBridge capability profile',
);
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 ?? '') !== 'index.html') {
throw new Error('desktop shell release window must enter through packaged index.html');
}
if (String(config.build?.devUrl ?? '') !== 'http://127.0.0.1:3000/') {
throw new Error('desktop shell dev URL must load the local Vite H5 entry');
}
assertNoRawHostContextUrl(mainWindow.url, 'desktop shell main window URL');
assertNoRawHostContextUrl(config.build?.devUrl, 'desktop shell dev URL');
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 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_host_context',
'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}`);
}
}