387a1c26e3
清理已跟踪原始日志与个人本地配置 修复前后端有效门禁、测试和构建问题 补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束 收紧图片编辑器状态、附件与媒体引用测试 排除已下线旧创作入口测试并清理 warning
3530 lines
116 KiB
JavaScript
3530 lines
116 KiB
JavaScript
import { spawnSync } from 'node:child_process';
|
|
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 nativeShellCheckPath = new URL(
|
|
'../../../scripts/check-native-shells.mjs',
|
|
import.meta.url,
|
|
);
|
|
const nativeShellCheckSource = fs.readFileSync(nativeShellCheckPath, 'utf8');
|
|
const capabilityPath = new URL(
|
|
'../src-tauri/capabilities/main.json',
|
|
import.meta.url,
|
|
);
|
|
const capability = JSON.parse(fs.readFileSync(capabilityPath, 'utf8'));
|
|
const macInfoPlistPath = new URL('../src-tauri/Info.plist', import.meta.url);
|
|
const macInfoPlist = fs.readFileSync(macInfoPlistPath, '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 h5HostBridgePath = new URL(
|
|
'../../../src/services/host-bridge/hostBridge.ts',
|
|
import.meta.url,
|
|
);
|
|
const h5HostBridgeSource = fs.readFileSync(h5HostBridgePath, 'utf8');
|
|
const appPath = new URL('../src-tauri/src/app.rs', import.meta.url);
|
|
const app = fs.readFileSync(appPath, 'utf8');
|
|
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
|
|
const main = fs.readFileSync(mainPath, 'utf8');
|
|
if (
|
|
!main.includes(
|
|
'#![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")]',
|
|
)
|
|
) {
|
|
throw new Error('desktop shell release builds on Windows must use the GUI subsystem');
|
|
}
|
|
const rustSourceDir = new URL('../src-tauri/src/', import.meta.url);
|
|
const desktopHostBridgeAppearancePath = new URL(
|
|
'../src-tauri/src/host_bridge/appearance.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeAppearanceSource = fs.readFileSync(
|
|
desktopHostBridgeAppearancePath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeBadgePath = new URL(
|
|
'../src-tauri/src/host_bridge/badge.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeBadgeSource = fs.readFileSync(
|
|
desktopHostBridgeBadgePath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeCapabilitiesPath = new URL(
|
|
'../src-tauri/src/host_bridge/capabilities.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeCapabilitiesSource = fs.readFileSync(
|
|
desktopHostBridgeCapabilitiesPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeClipboardPath = new URL(
|
|
'../src-tauri/src/host_bridge/clipboard.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeClipboardSource = fs.readFileSync(
|
|
desktopHostBridgeClipboardPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeDispatchPath = new URL(
|
|
'../src-tauri/src/host_bridge/dispatch.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeDispatchSource = fs.readFileSync(
|
|
desktopHostBridgeDispatchPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeFilesPath = new URL(
|
|
'../src-tauri/src/host_bridge/files.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeFilesSource = fs.readFileSync(
|
|
desktopHostBridgeFilesPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeFilePayloadsPath = new URL(
|
|
'../src-tauri/src/host_bridge/file_payloads.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeFilePayloadsSource = fs.readFileSync(
|
|
desktopHostBridgeFilePayloadsPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeNavigationPath = new URL(
|
|
'../src-tauri/src/host_bridge/navigation.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeNavigationSource = fs.readFileSync(
|
|
desktopHostBridgeNavigationPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeNetworkPath = new URL(
|
|
'../src-tauri/src/host_bridge/network.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeNetworkSource = fs.readFileSync(
|
|
desktopHostBridgeNetworkPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeNotificationsPath = new URL(
|
|
'../src-tauri/src/host_bridge/notifications.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeNotificationsSource = fs.readFileSync(
|
|
desktopHostBridgeNotificationsPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeProtocolPath = new URL(
|
|
'../src-tauri/src/host_bridge/protocol.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeProtocolSource = fs.readFileSync(
|
|
desktopHostBridgeProtocolPath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeRuntimePath = new URL(
|
|
'../src-tauri/src/host_bridge/runtime.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeRuntimeSource = fs.readFileSync(
|
|
desktopHostBridgeRuntimePath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeSharePath = new URL(
|
|
'../src-tauri/src/host_bridge/share.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeShareSource = fs.readFileSync(
|
|
desktopHostBridgeSharePath,
|
|
'utf8',
|
|
);
|
|
const desktopHostBridgeTitlePath = new URL(
|
|
'../src-tauri/src/host_bridge/title.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopHostBridgeTitleSource = fs.readFileSync(
|
|
desktopHostBridgeTitlePath,
|
|
'utf8',
|
|
);
|
|
const desktopShellUrlPath = new URL(
|
|
'../src-tauri/src/shell/url.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopShellUrlSource = fs.readFileSync(desktopShellUrlPath, 'utf8');
|
|
const desktopShellNetworkPath = new URL(
|
|
'../src-tauri/src/shell/network.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopShellNetworkSource = fs.readFileSync(
|
|
desktopShellNetworkPath,
|
|
'utf8',
|
|
);
|
|
const desktopShellDeepLinkPath = new URL(
|
|
'../src-tauri/src/shell/deep_link.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopShellDeepLinkSource = fs.readFileSync(
|
|
desktopShellDeepLinkPath,
|
|
'utf8',
|
|
);
|
|
const desktopShellNavigationPath = new URL(
|
|
'../src-tauri/src/shell/navigation.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopShellNavigationSource = fs.readFileSync(
|
|
desktopShellNavigationPath,
|
|
'utf8',
|
|
);
|
|
const desktopShellLifecyclePath = new URL(
|
|
'../src-tauri/src/shell/lifecycle.rs',
|
|
import.meta.url,
|
|
);
|
|
const desktopShellLifecycleSource = fs.readFileSync(
|
|
desktopShellLifecyclePath,
|
|
'utf8',
|
|
);
|
|
const stageReleaseBinaryPath = new URL(
|
|
'../scripts/stage-release-binary.mjs',
|
|
import.meta.url,
|
|
);
|
|
const stageReleaseBinarySource = fs.readFileSync(stageReleaseBinaryPath, 'utf8');
|
|
const productionSourceRoots = [
|
|
new URL('../package.json', import.meta.url),
|
|
new URL('../scripts/', import.meta.url),
|
|
new URL('../src-tauri/Cargo.toml', import.meta.url),
|
|
new URL('../src-tauri/Info.plist', 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', '.plist', '.rs', '.toml']);
|
|
const productionSourceExcludedDirectories = new Set([
|
|
'target',
|
|
]);
|
|
const productionSourceExcludedRelativePaths = new Set([
|
|
'src-tauri/gen',
|
|
'src-tauri/permissions/autogenerated',
|
|
]);
|
|
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],
|
|
]);
|
|
const expectedDesktopRustRootEntries = [
|
|
'dir:host_bridge',
|
|
'dir:shell',
|
|
'file:app.rs',
|
|
'file:main.rs',
|
|
];
|
|
const expectedDesktopHostBridgeRustFiles = [
|
|
'appearance.rs',
|
|
'badge.rs',
|
|
'capabilities.rs',
|
|
'clipboard.rs',
|
|
'dispatch.rs',
|
|
'file_payloads.rs',
|
|
'files.rs',
|
|
'mod.rs',
|
|
'navigation.rs',
|
|
'network.rs',
|
|
'notifications.rs',
|
|
'protocol.rs',
|
|
'runtime.rs',
|
|
'share.rs',
|
|
'title.rs',
|
|
];
|
|
const expectedDesktopShellRustFiles = [
|
|
'deep_link.rs',
|
|
'events.rs',
|
|
'file_drop.rs',
|
|
'lifecycle.rs',
|
|
'mod.rs',
|
|
'navigation.rs',
|
|
'network.rs',
|
|
'runtime.rs',
|
|
'tray.rs',
|
|
'url.rs',
|
|
'webview.rs',
|
|
'window_state.rs',
|
|
];
|
|
const expectedDesktopShellScripts = [
|
|
'check-config.mjs',
|
|
'stage-release-binary.mjs',
|
|
];
|
|
|
|
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 relativePath = pathRelativeToDesktopShell(entry);
|
|
if (productionSourceExcludedRelativePaths.has(relativePath)) {
|
|
return [];
|
|
}
|
|
|
|
const directory = entry.href.endsWith('/') ? entry : new URL(`${entry.href}/`);
|
|
return fs
|
|
.readdirSync(entry, { withFileTypes: true })
|
|
.filter(
|
|
(child) =>
|
|
!child.isDirectory() || !productionSourceExcludedDirectories.has(child.name),
|
|
)
|
|
.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.')) {
|
|
return [];
|
|
}
|
|
|
|
return [entry];
|
|
}
|
|
|
|
function pathRelativeToDesktopShell(entry) {
|
|
const desktopShellRoot = new URL('../', import.meta.url);
|
|
return pathRelative(desktopShellRoot.pathname, entry.pathname);
|
|
}
|
|
|
|
function pathRelative(fromPath, toPath) {
|
|
return toPath
|
|
.slice(fromPath.length)
|
|
.replace(/^\/+/, '')
|
|
.replace(/\/$/, '');
|
|
}
|
|
|
|
function cspDirectiveValues(source, directiveName) {
|
|
const directive = source
|
|
.split(';')
|
|
.map((entry) => entry.trim())
|
|
.find((entry) => entry.startsWith(`${directiveName} `));
|
|
if (!directive) {
|
|
return [];
|
|
}
|
|
|
|
return directive
|
|
.split(/\s+/)
|
|
.slice(1)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function assertExactCspDirective(source, directiveName, expectedValues, label) {
|
|
const actualValues = cspDirectiveValues(source, directiveName);
|
|
if (
|
|
actualValues.length !== expectedValues.length ||
|
|
expectedValues.some((value, index) => actualValues[index] !== value)
|
|
) {
|
|
throw new Error(
|
|
`${label} ${directiveName} must be ${[directiveName, ...expectedValues].join(' ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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/config must not include ${term}: ${file.pathname}:${line}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
assertNoDevScaffoldTerms(productionSourceFiles);
|
|
assertNoBlockedNpmDependencies();
|
|
assertNoTauriGuestNpmDependencies(packageConfig, 'desktop shell package');
|
|
assertNoTauriGuestNpmDependencies(rootPackageConfig, 'root H5 package');
|
|
assertNoBlockedNpmLockPackages();
|
|
assertNoBlockedCargoDependencies();
|
|
assertNoBlockedCargoLockPackages();
|
|
assertNoBlockedDesktopSdkSnippets();
|
|
assertDesktopSourceLayout();
|
|
|
|
for (const snippet of [
|
|
'label: \'desktop-shell-release-build-smoke\'',
|
|
"args: ['run', 'desktop-shell:build', '--', '--no-bundle']",
|
|
"label: 'desktop-shell-stage-release-binary'",
|
|
"args: ['run', 'desktop-shell:stage-release-binary']",
|
|
'function assertDesktopReleaseBinaryArtifact()',
|
|
"'build'",
|
|
"'native'",
|
|
"'desktop'",
|
|
'genarrative-desktop-shell.exe',
|
|
'genarrative-desktop-shell',
|
|
'desktop release binary is missing',
|
|
'desktop release binary must be a real non-empty executable file',
|
|
'desktop Linux release binary must be an executable ELF file',
|
|
'desktop macOS release binary must be an executable Mach-O file',
|
|
'desktop Windows release binary must be a PE executable',
|
|
'header[0] === 0x7f',
|
|
'header[1] === 0x45',
|
|
'header[2] === 0x4c',
|
|
'header[3] === 0x46',
|
|
'(stat.mode & 0o111) === 0',
|
|
'header.readUInt32BE(0)',
|
|
'machMagic === 0xfeedfacf',
|
|
'header[0] !== 0x4d',
|
|
'header[1] !== 0x5a',
|
|
"console.log('[check:native-shells] desktop-release-binary-artifact')",
|
|
'assertDesktopReleaseBinaryArtifact();',
|
|
]) {
|
|
if (!nativeShellCheckSource.includes(snippet)) {
|
|
throw new Error(`root native shell gate must keep desktop release artifact check ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
"'apps'",
|
|
"'desktop-shell'",
|
|
"'src-tauri'",
|
|
"'target'",
|
|
"'release'",
|
|
"'build'",
|
|
"'native'",
|
|
"'desktop'",
|
|
'fs.copyFileSync(sourcePath, stagedPath)',
|
|
'fs.chmodSync(stagedPath, sourceMode & 0o777)',
|
|
"console.log(`[desktop-shell:stage-release-binary] ${stagedPath}`)",
|
|
]) {
|
|
if (!stageReleaseBinarySource.includes(snippet)) {
|
|
throw new Error(`desktop shell release staging script missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const [scriptName, expected] of Object.entries({
|
|
dev: 'WEB_PORT=3000 tauri dev',
|
|
build: 'tauri build',
|
|
'stage-release-binary': 'node scripts/stage-release-binary.mjs',
|
|
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:stage-release-binary': 'npm --prefix apps/desktop-shell run stage-release-binary',
|
|
'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 extractTsStringObjectConst(source, constName) {
|
|
const match = source.match(
|
|
new RegExp(`export const ${constName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`),
|
|
);
|
|
if (!match) {
|
|
throw new Error(`unable to read TypeScript const ${constName}`);
|
|
}
|
|
|
|
return Object.fromEntries(
|
|
[...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [
|
|
entry[1],
|
|
entry[2],
|
|
]),
|
|
);
|
|
}
|
|
|
|
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 extractRustGuardedMimeValues(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(/if mime_type == "([^"]+)"/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 {4}\}/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 extractDesktopUnsupportedHandledMethods(source, handledMethods) {
|
|
return handledMethods.filter((method) => {
|
|
const methodBody = extractDesktopHostBridgeMethodBody(source, method);
|
|
return (
|
|
methodBody.includes('"unsupported_method"') ||
|
|
methodBody.includes('"unsupported_capability"') ||
|
|
methodBody.includes('resolve_host_bridge_request(request)')
|
|
);
|
|
});
|
|
}
|
|
|
|
function extractDesktopHostBridgeMethodBody(source, method) {
|
|
const methodStart = source.indexOf(`"${method}" =>`);
|
|
if (methodStart < 0) {
|
|
throw new Error(`desktop shell HostBridge missing method ${method}`);
|
|
}
|
|
|
|
const nextMethodMatch = source
|
|
.slice(methodStart + method.length)
|
|
.match(/\n {8}(?:"[^"]+"|_)\s*=>/);
|
|
const nextMethodStart = nextMethodMatch
|
|
? methodStart + method.length + nextMethodMatch.index
|
|
: -1;
|
|
return source.slice(
|
|
methodStart,
|
|
nextMethodStart > methodStart ? nextMethodStart : undefined,
|
|
);
|
|
}
|
|
|
|
function extractFunctionBody(source, functionName) {
|
|
const functionStart = source.search(
|
|
new RegExp(`(?:pub\\(crate\\)\\s+)?(?:async\\s+)?fn\\s+${functionName}\\s*\\(`),
|
|
);
|
|
if (functionStart < 0) {
|
|
throw new Error(`unable to read Rust function ${functionName}`);
|
|
}
|
|
|
|
const bodyStart = source.indexOf('{', functionStart);
|
|
if (bodyStart < 0) {
|
|
throw new Error(`unable to read Rust function body ${functionName}`);
|
|
}
|
|
|
|
let depth = 0;
|
|
for (let index = bodyStart; index < source.length; index += 1) {
|
|
const character = source[index];
|
|
if (character === '{') {
|
|
depth += 1;
|
|
} else if (character === '}') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return source.slice(bodyStart, index + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(`unable to read Rust function body ${functionName}`);
|
|
}
|
|
|
|
function assertSnippetOrder(source, firstSnippet, secondSnippet, label) {
|
|
const firstIndex = source.indexOf(firstSnippet);
|
|
const secondIndex = source.indexOf(secondSnippet);
|
|
if (firstIndex < 0 || secondIndex < 0 || firstIndex > secondIndex) {
|
|
throw new Error(`${label} must call ${firstSnippet} before ${secondSnippet}`);
|
|
}
|
|
}
|
|
|
|
function extractDesktopDialogFilter(source, ownerName, filterLabel) {
|
|
const ownerBody = extractFunctionBody(source, ownerName);
|
|
const match = ownerBody.match(
|
|
new RegExp(`\\.add_filter\\(\\s*"${filterLabel}",\\s*&\\[([^\\]]*)\\]\\s*,?\\s*\\)`),
|
|
);
|
|
if (!match) {
|
|
throw new Error(
|
|
`desktop shell ${ownerName} must use a ${filterLabel} system dialog filter`,
|
|
);
|
|
}
|
|
|
|
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
|
|
}
|
|
|
|
function assertDesktopDialogBoundary(method, functionName, filterLabel, expected, action) {
|
|
const methodBody = extractDesktopHostBridgeMethodBody(
|
|
desktopHostBridgeDispatchSource,
|
|
method,
|
|
);
|
|
if (!methodBody.includes(`${functionName}(&app, &request).await`)) {
|
|
throw new Error(`desktop shell ${method} must delegate to files module`);
|
|
}
|
|
const fileFunctionBody = extractFunctionBody(
|
|
desktopHostBridgeFilesSource,
|
|
functionName,
|
|
);
|
|
assertSameList(
|
|
extractDesktopDialogFilter(
|
|
desktopHostBridgeFilesSource,
|
|
functionName,
|
|
filterLabel,
|
|
),
|
|
expected,
|
|
`desktop shell ${method} ${filterLabel} dialog filter`,
|
|
);
|
|
|
|
const requiredDialogAction =
|
|
action === 'save' ? '.blocking_save_file()' : '.blocking_pick_file()';
|
|
const blockedDialogAction =
|
|
action === 'save' ? '.blocking_pick_file()' : '.blocking_save_file()';
|
|
if (!fileFunctionBody.includes(requiredDialogAction)) {
|
|
throw new Error(`desktop shell ${method} must use ${requiredDialogAction}`);
|
|
}
|
|
if (fileFunctionBody.includes(blockedDialogAction)) {
|
|
throw new Error(`desktop shell ${method} must not use ${blockedDialogAction}`);
|
|
}
|
|
const unavailableResponse =
|
|
action === 'save'
|
|
? 'return file_export_unavailable_response(request)'
|
|
: 'return file_import_unavailable_response(request)';
|
|
if (!fileFunctionBody.includes(unavailableResponse)) {
|
|
throw new Error(`desktop shell ${method} must hide native path conversion errors`);
|
|
}
|
|
const failureLogger =
|
|
action === 'save'
|
|
? 'log_desktop_file_export_failure("path.convert")'
|
|
: 'log_desktop_file_import_failure("path.convert")';
|
|
if (!fileFunctionBody.includes(failureLogger)) {
|
|
throw new Error(`desktop shell ${method} must log native path conversion errors`);
|
|
}
|
|
if (
|
|
fileFunctionBody.includes(
|
|
'return failed(request.id.clone(), "host_error", error.to_string())',
|
|
)
|
|
) {
|
|
throw new Error(`desktop shell ${method} must not expose native path errors`);
|
|
}
|
|
}
|
|
|
|
function assertDesktopFileExportPayloadOrder(method, functionName, payloadFunction) {
|
|
const fileFunctionBody = extractFunctionBody(
|
|
desktopHostBridgeFilesSource,
|
|
functionName,
|
|
);
|
|
assertSnippetOrder(
|
|
fileFunctionBody,
|
|
payloadFunction,
|
|
'.dialog()',
|
|
`desktop shell ${method} export boundary`,
|
|
);
|
|
}
|
|
|
|
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 readDirectoryEntryList(directory, label) {
|
|
const entries = fs
|
|
.readdirSync(directory, { withFileTypes: true })
|
|
.map((entry) => {
|
|
if (entry.isFile()) {
|
|
return `file:${entry.name}`;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
return `dir:${entry.name}`;
|
|
}
|
|
throw new Error(`${label} must not contain special entries: ${entry.name}`);
|
|
})
|
|
.sort();
|
|
if (entries.length === 0) {
|
|
throw new Error(`${label} must not be empty`);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function readDirectoryFileList(directory, label) {
|
|
const files = [];
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
if (!entry.isFile()) {
|
|
throw new Error(`${label} must not contain nested entries: ${entry.name}`);
|
|
}
|
|
files.push(entry.name);
|
|
}
|
|
return files.sort();
|
|
}
|
|
|
|
function assertDesktopSourceLayout() {
|
|
assertSameList(
|
|
readDirectoryFileList(new URL('../scripts/', import.meta.url), 'desktop shell scripts'),
|
|
expectedDesktopShellScripts,
|
|
'desktop shell scripts',
|
|
);
|
|
assertSameList(
|
|
readDirectoryEntryList(rustSourceDir, 'desktop shell Rust root entries'),
|
|
expectedDesktopRustRootEntries,
|
|
'desktop shell Rust root entries',
|
|
);
|
|
assertSameList(
|
|
readDirectoryFileList(
|
|
new URL('../src-tauri/src/host_bridge/', import.meta.url),
|
|
'desktop host bridge Rust files',
|
|
),
|
|
expectedDesktopHostBridgeRustFiles,
|
|
'desktop host bridge Rust files',
|
|
);
|
|
assertSameList(
|
|
readDirectoryFileList(
|
|
new URL('../src-tauri/src/shell/', import.meta.url),
|
|
'desktop shell Rust files',
|
|
),
|
|
expectedDesktopShellRustFiles,
|
|
'desktop shell Rust files',
|
|
);
|
|
|
|
if (!main.includes('mod app;') || !main.includes('app::run();')) {
|
|
throw new Error('desktop shell main.rs must stay a thin app entrypoint');
|
|
}
|
|
if (
|
|
main.includes('tauri::Builder::default()') ||
|
|
main.includes('tauri::generate_handler!')
|
|
) {
|
|
throw new Error('desktop shell main.rs must not own Tauri app setup');
|
|
}
|
|
if (
|
|
!app.includes('tauri::Builder::default()') ||
|
|
!app.includes('crate::host_bridge::host_bridge_request')
|
|
) {
|
|
throw new Error('desktop shell app.rs must own Tauri app setup');
|
|
}
|
|
}
|
|
|
|
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())
|
|
.map((command) => command.split('::').pop())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
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)) {
|
|
throw new Error('desktop shell generated permissions directory is missing');
|
|
}
|
|
|
|
const expectedPermissionFiles = commandNames
|
|
.map((command) => `${command}.toml`)
|
|
.sort();
|
|
const permissionEntries = fs.readdirSync(generatedPermissionDir, {
|
|
withFileTypes: true,
|
|
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
assertSameList(
|
|
permissionEntries.map((entry) =>
|
|
`${entry.isDirectory() ? 'dir' : 'file'}:${entry.name}`,
|
|
),
|
|
expectedPermissionFiles.map((file) => `file:${file}`),
|
|
'desktop shell generated permission files',
|
|
);
|
|
|
|
for (const entry of permissionEntries) {
|
|
if (entry.isDirectory()) {
|
|
throw new Error(
|
|
`desktop shell generated permissions must not include nested directory ${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 assertNoTrackedDesktopGeneratedTauriFiles() {
|
|
const generatedPaths = [
|
|
'apps/desktop-shell/src-tauri/gen',
|
|
'apps/desktop-shell/src-tauri/permissions/autogenerated',
|
|
];
|
|
const result = spawnSync('git', ['ls-files', ...generatedPaths], {
|
|
cwd: new URL('../../..', import.meta.url),
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(
|
|
`unable to check desktop generated Tauri files: ${result.error.message}`,
|
|
);
|
|
}
|
|
if ((result.status ?? 0) !== 0) {
|
|
throw new Error(
|
|
`unable to check desktop generated Tauri files: ${result.stderr.trim()}`,
|
|
);
|
|
}
|
|
|
|
const trackedGeneratedFiles = result.stdout
|
|
.split('\n')
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
if (trackedGeneratedFiles.length > 0) {
|
|
throw new Error(
|
|
`desktop generated Tauri files must stay untracked: ${trackedGeneratedFiles.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 sharedEvents = extractStringArrayExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_EVENTS',
|
|
);
|
|
const sharedDesktopCapabilities = extractStringArrayExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
|
|
);
|
|
const sharedDesktopEvents = sharedDesktopCapabilities.filter((capability) =>
|
|
sharedEvents.includes(capability),
|
|
);
|
|
const sharedHostBridgeProtocol = extractTsStringConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_PROTOCOL',
|
|
);
|
|
const sharedHostBridgeVersion = extractTsNumberConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_VERSION',
|
|
);
|
|
const sharedPublicWebOrigin = extractTsStringConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
|
|
);
|
|
const sharedPublicWebUrl = extractTsStringConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_PUBLIC_WEB_URL',
|
|
);
|
|
const sharedNativeAppQueryKeys = extractStringArrayExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS',
|
|
);
|
|
const sharedNativeAppQuery = extractTsStringObjectConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_NATIVE_APP_QUERY',
|
|
);
|
|
const sharedHostBridgePayloadLimits = {
|
|
HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: extractTsNumberConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH',
|
|
),
|
|
HOST_BRIDGE_RESPONSE_CACHE_MAX: extractTsNumberConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
|
|
),
|
|
HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS: extractTsNumberConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS',
|
|
),
|
|
HOST_BRIDGE_BADGE_COUNT_MAX: extractTsNumberConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_BADGE_COUNT_MAX',
|
|
),
|
|
HOST_BRIDGE_APP_TITLE_MAX_LENGTH: extractTsNumberConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_APP_TITLE_MAX_LENGTH',
|
|
),
|
|
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_IMPORT_DOCUMENT_MAX_BYTES: extractTsNumberConst(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_IMPORT_DOCUMENT_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_RESPONSE_CACHE_MAX: extractRustNumberConst(
|
|
rustHostSource,
|
|
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
|
|
),
|
|
HOST_BRIDGE_DESKTOP_NETWORK_CHECK_TIMEOUT_MS: extractRustNumberConst(
|
|
desktopShellNetworkSource,
|
|
'DESKTOP_NETWORK_CHECK_TIMEOUT_MS',
|
|
),
|
|
HOST_BRIDGE_BADGE_COUNT_MAX: extractRustNumberConst(
|
|
desktopHostBridgeBadgeSource,
|
|
'BADGE_COUNT_MAX',
|
|
),
|
|
HOST_BRIDGE_APP_TITLE_MAX_LENGTH: extractRustNumberConst(
|
|
desktopHostBridgeTitleSource,
|
|
'WINDOW_TITLE_MAX_LENGTH',
|
|
),
|
|
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH: extractRustNumberConst(
|
|
desktopHostBridgeClipboardSource,
|
|
'CLIPBOARD_TEXT_MAX_LENGTH',
|
|
),
|
|
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: extractRustNumberConst(
|
|
desktopHostBridgeNotificationsSource,
|
|
'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
|
|
),
|
|
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH: extractRustNumberConst(
|
|
desktopHostBridgeNotificationsSource,
|
|
'HOST_BRIDGE_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_IMPORT_DOCUMENT_MAX_BYTES: extractRustNumberConst(
|
|
rustHostSource,
|
|
'IMPORT_DOCUMENT_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 desktopEvents = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_EVENTS');
|
|
const desktopErrorCodes = extractRustStringArrayConst(
|
|
rustHostSource,
|
|
'HOST_BRIDGE_ERROR_CODES',
|
|
);
|
|
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 desktopUnsupportedHandledMethods = extractDesktopUnsupportedHandledMethods(
|
|
desktopHostBridgeDispatchSource,
|
|
desktopHandledMethods,
|
|
);
|
|
const hostBridgeAcceptedUnsupportedMethods = sharedMethods.filter(
|
|
(method) => !desktopCapabilities.includes(method),
|
|
);
|
|
const desktopCapabilityDelegationContracts = {
|
|
'host.getRuntime': 'desktop_host_bridge_runtime_response(&request)',
|
|
'appearance.getColorScheme': 'desktop_appearance_color_scheme(&app, &request)',
|
|
'share.open': 'open_desktop_host_bridge_share(&app, &request)',
|
|
'share.setTarget': 'set_desktop_host_bridge_share_target(&app, &request)',
|
|
'navigation.openNativePage': 'open_desktop_host_bridge_native_page(&app, &request)',
|
|
'app.reloadWebView': 'reload_desktop_host_bridge_webview(&app, &request)',
|
|
'app.openExternalUrl': 'open_desktop_host_bridge_external_url(&app, &request)',
|
|
'app.setTitle': 'set_desktop_host_bridge_window_title(&app, &request)',
|
|
'app.setBadgeCount': 'set_desktop_app_badge_count(&app, &request)',
|
|
'network.status': 'resolve_desktop_host_bridge_network_status(&request).await',
|
|
'clipboard.writeText': 'write_desktop_host_bridge_clipboard_text(&app, &request)',
|
|
'clipboard.readText': 'read_desktop_host_bridge_clipboard_text(&app, &request)',
|
|
'file.exportText': 'export_desktop_host_bridge_text_file(&app, &request).await',
|
|
'file.importText': 'import_desktop_host_bridge_text_file(&app, &request).await',
|
|
'file.importDocument': 'import_desktop_host_bridge_document_file(&app, &request).await',
|
|
'file.exportImage': 'export_desktop_host_bridge_image_file(&app, &request).await',
|
|
'file.importImage': 'import_desktop_host_bridge_image_file(&app, &request).await',
|
|
'file.importAudio': 'import_desktop_host_bridge_audio_file(&app, &request).await',
|
|
'file.exportAudio': 'export_desktop_host_bridge_audio_file(&app, &request).await',
|
|
'notification.showLocal': 'show_desktop_local_notification(&app, &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}`,
|
|
);
|
|
}
|
|
|
|
assertSameList(
|
|
desktopErrorCodes,
|
|
[
|
|
'invalid_request',
|
|
'unsupported_method',
|
|
'unsupported_capability',
|
|
'timeout',
|
|
'cancelled',
|
|
'host_error',
|
|
],
|
|
'desktop shell HostBridge error codes',
|
|
);
|
|
if (
|
|
!rustHostSource.includes('HOST_BRIDGE_ERROR_CODES.contains(&code)') ||
|
|
!rustHostSource.includes('desktop host bridge request failed')
|
|
) {
|
|
throw new Error('desktop shell protocol must normalize non-contract host errors');
|
|
}
|
|
|
|
if (desktopPublicWebOrigin !== sharedPublicWebOrigin) {
|
|
throw new Error(
|
|
`desktop shell public web origin drifted: expected ${sharedPublicWebOrigin} but got ${desktopPublicWebOrigin}`,
|
|
);
|
|
}
|
|
|
|
assertSameList(
|
|
extractRustStringArrayConst(desktopShellUrlSource, 'HOST_CONTEXT_QUERY_KEYS'),
|
|
sharedNativeAppQueryKeys,
|
|
'desktop shell host-context query keys',
|
|
);
|
|
|
|
if (!desktopShellUrlSource.includes('fn append_desktop_host_context(url: &mut Url)')) {
|
|
throw new Error('desktop shell host-context query appending must stay centralized');
|
|
}
|
|
|
|
for (const [key, value, label] of [
|
|
['clientRuntime', sharedNativeAppQuery.clientRuntime, 'client runtime'],
|
|
['clientType', sharedNativeAppQuery.clientType, 'client type'],
|
|
['hostShell', sharedNativeAppQuery.hostShellTauriDesktop, 'host shell'],
|
|
]) {
|
|
if (!desktopShellUrlSource.includes(`.append_pair("${key}", "${value}")`)) {
|
|
throw new Error(`desktop shell host-context ${label} drifted from shared contract`);
|
|
}
|
|
}
|
|
|
|
for (const expectedNetworkSnippet of [
|
|
'use crate::shell::url::WEB_APP_ORIGIN',
|
|
'Url::parse(WEB_APP_ORIGIN)',
|
|
'url.host_str()',
|
|
'url.port_or_known_default()',
|
|
]) {
|
|
if (!desktopShellNetworkSource.includes(expectedNetworkSnippet)) {
|
|
throw new Error(
|
|
`desktop shell network probe must derive its target from WEB_APP_ORIGIN: missing ${expectedNetworkSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (desktopShellNetworkSource.includes('"www.genarrative.world"')) {
|
|
throw new Error('desktop shell network probe must not duplicate the public web host');
|
|
}
|
|
for (const blockedDesktopNetworkEventSnippet of [
|
|
'register_desktop_network_events',
|
|
'navigator.onLine',
|
|
'window.addEventListener(\'online\'',
|
|
'window.addEventListener(\'offline\'',
|
|
]) {
|
|
if (desktopShellNetworkSource.includes(blockedDesktopNetworkEventSnippet)) {
|
|
throw new Error(
|
|
`desktop shell must not declare network.statusChanged until Rust owns the event source: ${blockedDesktopNetworkEventSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const expectedShareSnippet of [
|
|
'normalize_public_share_url',
|
|
'raw_url.starts_with("//")',
|
|
'url.origin() != base_url.origin()',
|
|
'DesktopSharePayload::Invalid',
|
|
'"share target is invalid"',
|
|
'log_desktop_share_failure',
|
|
'desktop share failed for {label}',
|
|
'share_unavailable_response',
|
|
'"share unavailable"',
|
|
'set_desktop_host_bridge_share_target',
|
|
'share_text_from_value(target)',
|
|
'log_desktop_share_failure("target.lock")',
|
|
'log_desktop_share_failure("target.store")',
|
|
'share_target_payload_must_be_valid_before_cache',
|
|
'share_unavailable_response_is_stable',
|
|
'share_failures_are_logged_without_exposing_native_detail',
|
|
'share_failures_log_stable_label_only',
|
|
'open_desktop_host_bridge_share',
|
|
'write_desktop_clipboard_text(app, &share_text)',
|
|
'"copied_to_clipboard"',
|
|
]) {
|
|
if (!desktopHostBridgeShareSource.includes(expectedShareSnippet)) {
|
|
throw new Error(
|
|
`desktop shell share URL boundary drifted: missing ${expectedShareSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
desktopHostBridgeShareSource.includes('failed(request.id.clone(), "host_error", "share target lock poisoned")') ||
|
|
desktopHostBridgeShareSource.includes('failed(request.id.clone(), "host_error", error.to_string())') ||
|
|
desktopHostBridgeShareSource.includes('desktop share failed for {label}: {error}') ||
|
|
desktopHostBridgeShareSource.includes('log_desktop_share_failure("target.lock", "share target lock poisoned")') ||
|
|
desktopHostBridgeShareSource.includes('log_desktop_share_failure("target.store", "share target lock poisoned")')
|
|
) {
|
|
throw new Error('desktop shell share module must hide native share errors');
|
|
}
|
|
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'set_desktop_host_bridge_share_target(&app, &request)',
|
|
) ||
|
|
!desktopHostBridgeDispatchSource.includes('open_desktop_host_bridge_share(&app, &request)') ||
|
|
desktopHostBridgeDispatchSource.includes('DesktopShareState') ||
|
|
desktopHostBridgeDispatchSource.includes('share_text_from_request') ||
|
|
desktopHostBridgeDispatchSource.includes('"copied_to_clipboard"')
|
|
) {
|
|
throw new Error('desktop shell share HostBridge methods must delegate to share module');
|
|
}
|
|
|
|
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(
|
|
extractRustGuardedMimeValues(rustHostSource, 'normalize_export_text_mime_type'),
|
|
sharedTextMimeTypes,
|
|
'desktop shell export 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');
|
|
assertSameList(
|
|
desktopEvents,
|
|
sharedDesktopEvents,
|
|
'desktop shell HostBridge event whitelist',
|
|
);
|
|
if (sharedDesktopCapabilities.includes('network.statusChanged')) {
|
|
throw new Error(
|
|
'desktop shell must not declare network.statusChanged until Rust owns a real event source',
|
|
);
|
|
}
|
|
if (desktopEvents.includes('network.statusChanged')) {
|
|
throw new Error(
|
|
'desktop shell event whitelist must not include network.statusChanged until Rust owns a real event source',
|
|
);
|
|
}
|
|
for (const eventName of sharedEvents) {
|
|
if (!sharedCapabilities.includes(eventName)) {
|
|
throw new Error(`shared HostBridge event must also be a capability: ${eventName}`);
|
|
}
|
|
}
|
|
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 snippet of [
|
|
'fn runtime_capability_list_keeps_desktop_boundaries()',
|
|
'let unique_capabilities = desktop_capabilities.iter().copied().collect::<HashSet<_>>();',
|
|
'assert_eq!(unique_capabilities.len(), desktop_capabilities.len());',
|
|
'"notification.showLocal"',
|
|
'"auth.requestLogin"',
|
|
'"payment.request"',
|
|
'"file.captureImage"',
|
|
'"scanner.scanQrCode"',
|
|
'"haptics.impact"',
|
|
'assert!(!desktop_capabilities.contains(&capability));',
|
|
]) {
|
|
if (!desktopHostBridgeCapabilitiesSource.includes(snippet)) {
|
|
throw new Error(
|
|
`desktop shell capability list boundary test is missing coverage: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const capability of ['auth.requestLogin', 'payment.request']) {
|
|
if (desktopCapabilities.includes(capability)) {
|
|
throw new Error(
|
|
`desktop shell must not declare ${capability} until a real SDK/channel flow is implemented`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'format!("{} unsupported in desktop shell", request.method)',
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'desktop shell unsupported HostBridge methods must return explicit unsupported_method errors',
|
|
);
|
|
}
|
|
const desktopUnsupportedMethodTestMatch = desktopHostBridgeDispatchSource.match(
|
|
/fn unsupported_method_is_explicit\(\)[\s\S]*?assert_eq!\(\s*unsupported_methods,\s*vec!\[([^\]]+)\]/,
|
|
);
|
|
if (!desktopUnsupportedMethodTestMatch) {
|
|
throw new Error('desktop shell unsupported HostBridge method test is missing');
|
|
}
|
|
for (const snippet of [
|
|
'use crate::host_bridge::capabilities::capabilities;',
|
|
'use crate::host_bridge::protocol::HOST_BRIDGE_METHODS;',
|
|
'let desktop_capabilities = capabilities();',
|
|
'let unsupported_methods = HOST_BRIDGE_METHODS',
|
|
'.filter(|method| !desktop_capabilities.contains(method))',
|
|
'for method in unsupported_methods',
|
|
]) {
|
|
if (!desktopHostBridgeDispatchSource.includes(snippet)) {
|
|
throw new Error(
|
|
`desktop shell unsupported HostBridge method test must derive coverage from method/capability diff: ${snippet}`,
|
|
);
|
|
}
|
|
}
|
|
assertSameList(
|
|
[...desktopUnsupportedMethodTestMatch[1].matchAll(/"([^"]+)"/g)].map(
|
|
(match) => match[1],
|
|
),
|
|
hostBridgeAcceptedUnsupportedMethods,
|
|
'desktop shell unsupported HostBridge method coverage',
|
|
);
|
|
|
|
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 unsupportedDesktopCapabilities = desktopCapabilities.filter(
|
|
(capability) =>
|
|
sharedMethods.includes(capability) &&
|
|
desktopUnsupportedHandledMethods.includes(capability),
|
|
);
|
|
if (unsupportedDesktopCapabilities.length > 0) {
|
|
throw new Error(
|
|
`desktop shell declares request capabilities backed by fallback-only handlers: ${unsupportedDesktopCapabilities.join(', ')}`,
|
|
);
|
|
}
|
|
|
|
for (const capability of desktopCapabilities.filter((entry) =>
|
|
sharedMethods.includes(entry),
|
|
)) {
|
|
const requiredDelegation = desktopCapabilityDelegationContracts[capability];
|
|
if (!requiredDelegation) {
|
|
throw new Error(
|
|
`desktop shell declared request capability is missing a delegation contract: ${capability}`,
|
|
);
|
|
}
|
|
|
|
const methodBody = extractDesktopHostBridgeMethodBody(
|
|
desktopHostBridgeDispatchSource,
|
|
capability,
|
|
);
|
|
if (!methodBody.includes(requiredDelegation)) {
|
|
throw new Error(
|
|
`desktop shell ${capability} must delegate to ${requiredDelegation}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const undeclaredDesktopMethodHandlers = desktopHandledMethods.filter(
|
|
(method) =>
|
|
!desktopCapabilities.includes(method) &&
|
|
!hostBridgeAcceptedUnsupportedMethods.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');
|
|
}
|
|
|
|
for (const runtimeSnippet of [
|
|
'pub(crate) fn desktop_runtime() -> HostBridgeRuntime',
|
|
'pub(crate) fn desktop_host_bridge_runtime_response(',
|
|
'request: &HostBridgeRequest',
|
|
'ok(request.id.clone(), json!(desktop_runtime()))',
|
|
'shell: "tauri_desktop"',
|
|
'platform: desktop_platform()',
|
|
'host_version: env!("CARGO_PKG_VERSION")',
|
|
'bridge_version: HOST_BRIDGE_VERSION',
|
|
'capabilities: capabilities()',
|
|
]) {
|
|
if (!desktopHostBridgeRuntimeSource.includes(runtimeSnippet)) {
|
|
throw new Error(`desktop shell runtime response missing ${runtimeSnippet}`);
|
|
}
|
|
}
|
|
|
|
const desktopDispatchRuntimeDelegationSource = [
|
|
extractFunctionBody(desktopHostBridgeDispatchSource, 'resolve_host_bridge_request'),
|
|
extractFunctionBody(desktopHostBridgeDispatchSource, 'execute_host_bridge_request'),
|
|
].join('\n');
|
|
if (
|
|
!desktopDispatchRuntimeDelegationSource.includes(
|
|
'"host.getRuntime" => desktop_host_bridge_runtime_response(&request)',
|
|
) ||
|
|
desktopDispatchRuntimeDelegationSource.includes('json!(desktop_runtime())') ||
|
|
desktopDispatchRuntimeDelegationSource.includes(' ok(') ||
|
|
desktopDispatchRuntimeDelegationSource.includes('HostBridgeRuntime') ||
|
|
desktopDispatchRuntimeDelegationSource.includes('HOST_BRIDGE_VERSION') ||
|
|
desktopDispatchRuntimeDelegationSource.includes('capabilities()') ||
|
|
desktopDispatchRuntimeDelegationSource.includes('desktop_platform()') ||
|
|
desktopDispatchRuntimeDelegationSource.includes('env!("CARGO_PKG_VERSION")')
|
|
) {
|
|
throw new Error('desktop shell dispatch must delegate host.getRuntime to runtime.rs');
|
|
}
|
|
|
|
if (desktopDispatchRuntimeDelegationSource.includes('json!(HostBridgeRuntime {')) {
|
|
throw new Error('desktop shell host.getRuntime must be built by runtime.rs');
|
|
}
|
|
|
|
if (
|
|
!desktopShellNavigationSource.includes('pub(crate) fn open_normalized_desktop_external_url') ||
|
|
!desktopShellNavigationSource.includes(
|
|
'open_normalized_desktop_external_url(app, external_url)',
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'desktop shell WebView external navigation must use the shared external opener helper',
|
|
);
|
|
}
|
|
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'open_desktop_host_bridge_external_url(&app, &request)',
|
|
) ||
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'open_desktop_host_bridge_native_page(&app, &request)',
|
|
) ||
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'reload_desktop_host_bridge_webview(&app, &request)',
|
|
) ||
|
|
desktopHostBridgeDispatchSource.includes('open_normalized_desktop_external_url(&app, url)') ||
|
|
desktopHostBridgeDispatchSource.includes('normalize_external_url') ||
|
|
desktopHostBridgeDispatchSource.includes('normalize_native_page_url') ||
|
|
desktopHostBridgeDispatchSource.includes('app.opener().open_url(')
|
|
) {
|
|
throw new Error(
|
|
'desktop shell navigation HostBridge methods must delegate to navigation module',
|
|
);
|
|
}
|
|
for (const snippet of [
|
|
'open_desktop_host_bridge_external_url',
|
|
'external_url_unavailable_response',
|
|
'log_desktop_navigation_failure',
|
|
'desktop navigation failed for {label}',
|
|
'desktop_external_url_from_request',
|
|
'normalize_external_url',
|
|
'open_normalized_desktop_external_url(app, url)',
|
|
'log_desktop_navigation_failure("external.open")',
|
|
'desktop_external_url_request_accepts_only_safe_system_protocols',
|
|
'"external URL cannot be opened"',
|
|
'external_url_unavailable_response_is_stable',
|
|
'open_desktop_host_bridge_native_page',
|
|
'native_page_unavailable_response',
|
|
'desktop_native_page_url_from_request',
|
|
'normalize_native_page_url',
|
|
'window.navigate(url)',
|
|
'log_desktop_navigation_failure("native.navigate")',
|
|
'log_desktop_navigation_failure("native.window")',
|
|
'desktop_native_page_request_accepts_only_same_origin_h5_routes',
|
|
'navigation_failures_are_logged_without_exposing_native_detail',
|
|
'navigation_failures_log_stable_label_only',
|
|
'"native page unavailable"',
|
|
'native_page_unavailable_response_is_stable',
|
|
'reload_desktop_host_bridge_webview',
|
|
'webview_reload_unavailable_response',
|
|
'window.reload()',
|
|
'log_desktop_navigation_failure("webview.reload")',
|
|
'log_desktop_navigation_failure("webview.window")',
|
|
'webview_reload_failures_are_logged_without_exposing_native_detail',
|
|
'webview_reload_failures_log_stable_label_only',
|
|
'"webview reload unavailable"',
|
|
'webview_reload_unavailable_response_is_stable',
|
|
]) {
|
|
if (!desktopHostBridgeNavigationSource.includes(snippet)) {
|
|
throw new Error(`desktop shell navigation module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (
|
|
desktopHostBridgeNavigationSource.includes('failed(request.id.clone(), "host_error", error.to_string())') ||
|
|
desktopHostBridgeNavigationSource.includes('"main window not found"') ||
|
|
desktopHostBridgeNavigationSource.includes('desktop navigation failed for {label}: {error}') ||
|
|
desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("external.open", &error.to_string())') ||
|
|
desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("native.navigate", &error.to_string())') ||
|
|
desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("webview.reload", &error.to_string())') ||
|
|
desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("native.window", "main window unavailable")') ||
|
|
desktopHostBridgeNavigationSource.includes('log_desktop_navigation_failure("webview.window", "main window unavailable")')
|
|
) {
|
|
throw new Error('desktop shell navigation module must hide native navigation errors');
|
|
}
|
|
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'resolve_desktop_host_bridge_network_status(&request).await',
|
|
) ||
|
|
desktopHostBridgeDispatchSource.includes('resolve_desktop_network_status') ||
|
|
desktopHostBridgeDispatchSource.includes('spawn_blocking(resolve_desktop_network_status)') ||
|
|
desktopHostBridgeDispatchSource.includes('Err(error) => failed(request.id, "host_error"')
|
|
) {
|
|
throw new Error('desktop shell network HostBridge method must delegate to network module');
|
|
}
|
|
for (const snippet of [
|
|
'resolve_desktop_host_bridge_network_status',
|
|
'map_desktop_host_bridge_network_status_result',
|
|
'resolve_desktop_host_bridge_network_status_payload',
|
|
'resolve_desktop_host_bridge_network_status_payload_with',
|
|
'network_status_unavailable_response',
|
|
'log_desktop_network_failure',
|
|
'desktop network failed for {label}',
|
|
'spawn_blocking(resolver)',
|
|
'resolve_desktop_host_bridge_network_status_payload_with(resolve_desktop_network_status)',
|
|
'log_desktop_network_failure("status.resolve")',
|
|
'"isConnected": true',
|
|
'"connectionType": "unknown"',
|
|
'"network status unavailable"',
|
|
'network_status_unavailable_response_is_stable',
|
|
'network_status_success_response_reports_contract_shape',
|
|
'network_status_failure_hides_native_error_detail',
|
|
'network_status_failures_are_logged_without_exposing_native_detail',
|
|
'network_status_failures_log_stable_label_only',
|
|
'private native resolver detail',
|
|
]) {
|
|
if (!desktopHostBridgeNetworkSource.includes(snippet)) {
|
|
throw new Error(`desktop shell network module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (
|
|
desktopHostBridgeNetworkSource.includes('desktop network failed for {label}: {error}') ||
|
|
desktopHostBridgeNetworkSource.includes('log_desktop_network_failure("status.resolve", &error)')
|
|
) {
|
|
throw new Error('desktop shell network module must hide native network errors');
|
|
}
|
|
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'write_desktop_host_bridge_clipboard_text(&app, &request)',
|
|
) ||
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'read_desktop_host_bridge_clipboard_text(&app, &request)',
|
|
) ||
|
|
desktopHostBridgeDispatchSource.includes('required_string_payload(&request, "text")') ||
|
|
desktopHostBridgeDispatchSource.includes('read_desktop_clipboard_text(&app)')
|
|
) {
|
|
throw new Error('desktop shell clipboard HostBridge methods must delegate to clipboard module');
|
|
}
|
|
for (const snippet of [
|
|
'write_desktop_clipboard_text(app, text)',
|
|
'read_desktop_clipboard_text(app)',
|
|
'normalize_clipboard_text(&text)',
|
|
'log_desktop_clipboard_failure',
|
|
'desktop clipboard failed for {label}',
|
|
'log_desktop_clipboard_failure("write.text")',
|
|
'log_desktop_clipboard_failure("read.text")',
|
|
'clipboard_write_unavailable_response',
|
|
'clipboard_read_unavailable_response',
|
|
'required_string_payload(request, "text")',
|
|
'truncated.chars().count()',
|
|
'"text": text',
|
|
'"clipboard write unavailable"',
|
|
'"clipboard read unavailable"',
|
|
'clipboard_unavailable_responses_are_stable',
|
|
'clipboard_failures_are_logged_without_exposing_native_detail',
|
|
'clipboard_failures_log_stable_label_only',
|
|
]) {
|
|
if (!desktopHostBridgeClipboardSource.includes(snippet)) {
|
|
throw new Error(`desktop shell clipboard module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (
|
|
desktopHostBridgeClipboardSource.includes('desktop clipboard failed for {label}: {error}') ||
|
|
desktopHostBridgeClipboardSource.includes('log_desktop_clipboard_failure("write.text", &error)') ||
|
|
desktopHostBridgeClipboardSource.includes('log_desktop_clipboard_failure("read.text", &error)')
|
|
) {
|
|
throw new Error('desktop shell clipboard module must hide native clipboard errors');
|
|
}
|
|
|
|
if (Object.hasOwn(config.build ?? {}, 'frontendDist')) {
|
|
throw new Error('desktop shell release must load the shared public H5 URL without packaging root dist');
|
|
}
|
|
|
|
if (config.build?.beforeBuildCommand !== '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 -- --web-port 3000 --strict-web-port') {
|
|
throw new Error('desktop shell dev command must run the root H5 Vite dev server on the Tauri devUrl port');
|
|
}
|
|
|
|
if (
|
|
packageConfig.scripts?.dev !== 'WEB_PORT=3000 tauri dev' ||
|
|
config.build?.devUrl !== 'http://127.0.0.1:3000/'
|
|
) {
|
|
throw new Error(
|
|
'desktop shell dev script and beforeDevCommand must pin WEB_PORT=3000 to match Tauri devUrl',
|
|
);
|
|
}
|
|
|
|
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 ?? '') !== sharedPublicWebUrl) {
|
|
throw new Error('desktop shell release window must load the shared public H5 URL');
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
if (config.bundle?.macOS?.infoPlist !== 'Info.plist') {
|
|
throw new Error('desktop shell macOS bundle must merge the checked Info.plist');
|
|
}
|
|
|
|
for (const [key, value] of [
|
|
[
|
|
'NSCameraUsageDescription',
|
|
'允许 Genarrative 使用摄像头运行需要实时动作输入的同源 H5 体验。',
|
|
],
|
|
[
|
|
'NSMicrophoneUsageDescription',
|
|
'允许 Genarrative 使用麦克风运行需要实时声音输入的同源 H5 玩法。',
|
|
],
|
|
]) {
|
|
if (!macInfoPlist.includes(`<key>${key}</key>`)) {
|
|
throw new Error(`desktop shell macOS Info.plist missing ${key}`);
|
|
}
|
|
if (!macInfoPlist.includes(`<string>${value}</string>`)) {
|
|
throw new Error(`desktop shell macOS Info.plist ${key} text drifted`);
|
|
}
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
assertExactCspDirective(csp, 'script-src', ["'self'"], 'desktop shell release CSP');
|
|
assertExactCspDirective(devCsp, 'script-src', ["'self'"], 'desktop shell dev CSP');
|
|
|
|
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 expectedRustRootEntries = [
|
|
'dir:host_bridge',
|
|
'dir:shell',
|
|
'file:app.rs',
|
|
'file:main.rs',
|
|
];
|
|
const expectedRustHostBridgeFiles = [
|
|
'appearance.rs',
|
|
'badge.rs',
|
|
'capabilities.rs',
|
|
'clipboard.rs',
|
|
'dispatch.rs',
|
|
'file_payloads.rs',
|
|
'files.rs',
|
|
'mod.rs',
|
|
'navigation.rs',
|
|
'network.rs',
|
|
'notifications.rs',
|
|
'protocol.rs',
|
|
'runtime.rs',
|
|
'share.rs',
|
|
'title.rs',
|
|
];
|
|
const expectedRustShellFiles = [
|
|
'deep_link.rs',
|
|
'events.rs',
|
|
'file_drop.rs',
|
|
'lifecycle.rs',
|
|
'mod.rs',
|
|
'navigation.rs',
|
|
'network.rs',
|
|
'runtime.rs',
|
|
'tray.rs',
|
|
'url.rs',
|
|
'webview.rs',
|
|
'window_state.rs',
|
|
];
|
|
const requiredRustHostModules = [
|
|
'app.rs',
|
|
...expectedRustHostBridgeFiles.map((fileName) => `host_bridge/${fileName}`),
|
|
'main.rs',
|
|
...expectedRustShellFiles.map((fileName) => `shell/${fileName}`),
|
|
];
|
|
const requiredRustHostSnippets = [
|
|
'tauri_plugin_single_instance::init',
|
|
'desktop_window_state_plugin()',
|
|
'tauri_plugin_window_state::Builder::default()',
|
|
'desktop_window_state_flags()',
|
|
'StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED',
|
|
'fn desktop_window_state_flags_keep_visible_state_out_of_persistence()',
|
|
'assert!(!flags.contains(StateFlags::VISIBLE));',
|
|
'assert!(!flags.contains(StateFlags::FULLSCREEN));',
|
|
'assert!(!flags.contains(StateFlags::DECORATIONS));',
|
|
'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',
|
|
'log_desktop_deep_link_open_result',
|
|
'open_desktop_deep_link_url(window: &WebviewWindow, url: &Url) -> tauri::Result<()>',
|
|
'log_desktop_deep_link_current_result',
|
|
'desktop host event failed for deep_link.current',
|
|
'log_desktop_deep_link_window_missing',
|
|
'desktop host event failed for deep_link.window',
|
|
'main window unavailable during',
|
|
'window.navigate(target_url)?',
|
|
'show_main_window(window.app_handle())',
|
|
'log_desktop_host_event_result("tray.show", show_main_window(app))',
|
|
'log_desktop_host_event_result("tray.reload", reload_main_window(app))',
|
|
'log_desktop_host_event_result("single_instance.show", show_main_window(app))',
|
|
'"navigation.external"',
|
|
'"webview.new_window.external"',
|
|
'"tray.close.lifecycle"',
|
|
'log_desktop_host_event_result("tray.close.hide", close_window.hide())',
|
|
'DESKTOP_DEEP_LINK_HOSTS',
|
|
'resolve_desktop_single_instance_action',
|
|
'tauri_plugin_clipboard_manager::init()',
|
|
'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"',
|
|
'"share.open"',
|
|
'"share.setTarget"',
|
|
'"navigation.openNativePage"',
|
|
'"navigation.canGoBack"',
|
|
'"app.reloadWebView"',
|
|
'"app.setTitle"',
|
|
'"app.setBadgeCount"',
|
|
'"clipboard.writeText"',
|
|
'"clipboard.readText"',
|
|
'"file.exportText"',
|
|
'"file.importText"',
|
|
'"file.importDocument"',
|
|
'"file.exportImage"',
|
|
'"file.importImage"',
|
|
'"file.importAudio"',
|
|
'"file.exportAudio"',
|
|
'"file.imageDropped"',
|
|
'"notification.showLocal"',
|
|
'write_desktop_host_bridge_clipboard_text(&app, &request)',
|
|
'read_desktop_host_bridge_clipboard_text(&app, &request)',
|
|
'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 permission denied"',
|
|
'"copied_to_clipboard"',
|
|
'file_export_cancelled_response',
|
|
'file_import_cancelled_response',
|
|
'file_export_unavailable_response',
|
|
'file_import_unavailable_response',
|
|
'file_import_payload_error_response',
|
|
'log_desktop_file_export_failure',
|
|
'log_desktop_file_import_failure',
|
|
'desktop file export failed for {label}',
|
|
'desktop file import failed for {label}',
|
|
'ImportFilePayloadError::NativeRead',
|
|
'ImportFilePayloadError::InvalidRequest',
|
|
'log_desktop_file_export_failure("path.convert")',
|
|
'log_desktop_file_import_failure("path.convert")',
|
|
'log_desktop_file_export_failure("write.text")',
|
|
'log_desktop_file_export_failure("write.image")',
|
|
'log_desktop_file_export_failure("write.audio")',
|
|
'file_import_payload_error_response(request, "read.text", error)',
|
|
'file_import_payload_error_response(request, "read.document", error)',
|
|
'file_import_payload_error_response(request, "read.image", error)',
|
|
'file_import_payload_error_response(request, "read.audio", error)',
|
|
'log_desktop_file_import_failure(label)',
|
|
'log_desktop_file_import_failure("read.text.join")',
|
|
'log_desktop_file_import_failure("read.document.join")',
|
|
'log_desktop_file_import_failure("read.image.join")',
|
|
'log_desktop_file_import_failure("read.audio.join")',
|
|
'desktop_file_cancelled_responses_are_stable',
|
|
'desktop_file_unavailable_responses_are_stable',
|
|
'desktop_file_failures_are_logged_without_exposing_native_detail',
|
|
'desktop_file_failures_log_stable_label_only',
|
|
'desktop_file_import_validation_errors_keep_stable_invalid_request',
|
|
'desktop_file_import_native_read_errors_use_stable_host_error',
|
|
'"file export cancelled"',
|
|
'"file import cancelled"',
|
|
'"file export unavailable"',
|
|
'"file import unavailable"',
|
|
'BASE64_STANDARD.decode',
|
|
'fs::metadata(&path).map_err(|_error| ImportFilePayloadError::NativeRead)',
|
|
'fs::read(&path).map_err(|_error| ImportFilePayloadError::NativeRead)',
|
|
'fs::read_to_string(&path).map_err(|_error| ImportFilePayloadError::NativeRead)',
|
|
'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::Resized',
|
|
'resolve_desktop_lifecycle_payload',
|
|
'resolve_desktop_lifecycle_window_flag',
|
|
'emit_current_desktop_lifecycle_event',
|
|
'desktop_lifecycle_window_flag_logs_failure_and_uses_default',
|
|
'desktop_lifecycle_window_flag_logs_stable_label_only',
|
|
'"background"',
|
|
'"hidden"',
|
|
'"minimized"',
|
|
'WindowEvent::DragDrop',
|
|
'first_valid_desktop_image_drop_payload',
|
|
'if !path.is_file() || import_image_mime_type(path).is_none()',
|
|
'match import_image_file_payload(path.clone(), "dropped", Some(position))',
|
|
'import_image_file_payload(path.clone(), "dropped", Some(position))',
|
|
'log_desktop_image_drop_payload_failure()',
|
|
'file.imageDropped.payload',
|
|
'fn image_drop_without_valid_image_does_not_emit_payload()',
|
|
'fn image_drop_logs_invalid_candidate_then_uses_next_valid_image()',
|
|
'fn image_drop_payload_failure_reports_failure()',
|
|
'fn image_drop_payload_failure_logs_stable_label_only()',
|
|
'fs::create_dir_all(&directory_path)',
|
|
'assert_eq!(payload, None);',
|
|
'PageLoadEvent',
|
|
'host_bridge_event_script',
|
|
'is_host_bridge_event_name',
|
|
'origin: window.location.origin',
|
|
'source: window',
|
|
'should_replay_desktop_webview_state_on_page_load',
|
|
'PageLoadEvent::Finished',
|
|
'replay_desktop_webview_state',
|
|
'log_desktop_host_event_result',
|
|
'desktop_host_event_result_logs_stable_label_only',
|
|
'resolve_desktop_lifecycle_window_flag("visible",',
|
|
'resolve_desktop_lifecycle_window_flag("minimized",',
|
|
'resolve_desktop_lifecycle_window_flag(\n "focused",',
|
|
'resolve_desktop_network_status',
|
|
'resolve_desktop_network_reachability',
|
|
'log_desktop_network_probe_failure',
|
|
'desktop network reachability probe failed',
|
|
'desktop_network_reachability_reports_missing_probe_target',
|
|
'desktop_network_probe_failure_logs_stable_label_only',
|
|
'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',
|
|
"console.warn('desktop navigation state sync failed')",
|
|
'file.imageDropped',
|
|
'normalize_desktop_drop_position',
|
|
'.round().max(0.0) as i32',
|
|
'desktop_drop_position_is_rounded_and_clamped_to_window_bounds',
|
|
'emit_desktop_image_drop_event(&drop_window, paths, drop_position)',
|
|
'app.notification().builder()',
|
|
'desktop_entry_url_with_host_context',
|
|
'desktop_entry_url_removes_stale_host_context_before_appending_current_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',
|
|
'apply_desktop_dev_url_to_main_window_config',
|
|
'DESKTOP_MAIN_WINDOW_LABEL: &str = "main"',
|
|
'desktop_main_window_config_from_windows',
|
|
'.find(|window| window.label == DESKTOP_MAIN_WINDOW_LABEL)',
|
|
'tauri::Error::WindowNotFound',
|
|
'desktop_main_window_config(app)?',
|
|
'desktop_main_window_config_uses_labeled_main_window',
|
|
'desktop_main_window_config_uses_dev_url_in_dev_builds',
|
|
'config.url = tauri::WebviewUrl::External(dev_url.clone())',
|
|
'assert_eq!(url.origin().ascii_serialization(), "http://127.0.0.1:3000")',
|
|
'path.starts_with("index.html?")',
|
|
'path.contains("clientRuntime=native_app")',
|
|
'path.contains("clientType=native_app")',
|
|
'path.contains("hostShell=tauri_desktop")',
|
|
'path.contains("hostPlatform=")',
|
|
'path.contains(&format!("hostVersion={}", env!("CARGO_PKG_VERSION")))',
|
|
'path.contains("bridgeVersion=")',
|
|
'path.contains("hostCapabilities=")',
|
|
'should_allow_desktop_webview_navigation',
|
|
'matches!(url.scheme(), "http" | "https")',
|
|
'url.host_str() == Some("127.0.0.1")',
|
|
'url.port_or_known_default() == Some(3000)',
|
|
'Url::parse("http://tauri.localhost/index.html")',
|
|
'Url::parse("http://127.0.0.1:3000/works/detail?work=PZ-1")',
|
|
'desktop_external_navigation_url',
|
|
'open_normalized_desktop_external_url',
|
|
'open_desktop_external_navigation',
|
|
'desktop_new_window_external_url',
|
|
'desktop_new_window_response',
|
|
'.on_navigation(move |url|',
|
|
'.on_new_window(move |url, _features|',
|
|
'.on_page_load(|window, payload|',
|
|
'DownloadEvent',
|
|
'should_allow_desktop_webview_download',
|
|
'DownloadEvent::Requested { .. } => false',
|
|
'handle_desktop_webview_download',
|
|
'log_desktop_webview_download_blocked',
|
|
'desktop host event blocked for webview.download',
|
|
'desktop_webview_download_blocked_logs_stable_label_only',
|
|
'desktop_webview_download_handler_logs_blocked_requests',
|
|
'.on_download(|_webview, event| handle_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',
|
|
'Result<HostBridgeReplayReservation, HostBridgeResponse>',
|
|
'log_desktop_replay_failure("cache.lock")',
|
|
'desktop host bridge replay failed for {label}',
|
|
'replay_unavailable_response(request_id)',
|
|
'host_bridge_replay_state_returns_stable_error_when_cache_lock_is_unavailable',
|
|
'desktop_host_bridge_replay_logs_stable_label_only',
|
|
'.manage(HostBridgeReplayState::default())',
|
|
'fn prepare_host_bridge_request(',
|
|
'prepare_host_bridge_request(&mut request)',
|
|
'match replay_state.reserve(&request.id)',
|
|
'Err(response) => response',
|
|
'HostBridgeReplayState::wait_for_response',
|
|
'execute_host_bridge_request(app, request).await',
|
|
'replay_state.complete(slot, response)',
|
|
];
|
|
|
|
for (const blockedDesktopFileLogSnippet of [
|
|
'desktop file export failed for {label}: {error}',
|
|
'desktop file import failed for {label}: {error}',
|
|
'Ok(Err(error)) => failed(request.id.clone(), "invalid_request", error)',
|
|
'fs::metadata(&path).map_err(|error| error.to_string())',
|
|
'fs::read(&path).map_err(|error| error.to_string())',
|
|
'fs::read_to_string(&path).map_err(|error| error.to_string())',
|
|
'log_desktop_file_export_failure("path.convert", &error.to_string())',
|
|
'log_desktop_file_import_failure("path.convert", &error.to_string())',
|
|
'log_desktop_file_export_failure("write.text", &error.to_string())',
|
|
'log_desktop_file_export_failure("write.image", &error.to_string())',
|
|
'log_desktop_file_export_failure("write.audio", &error.to_string())',
|
|
'log_desktop_file_import_failure("read.text.join", &error.to_string())',
|
|
'log_desktop_file_import_failure("read.document.join", &error.to_string())',
|
|
'log_desktop_file_import_failure("read.image.join", &error.to_string())',
|
|
'log_desktop_file_import_failure("read.audio.join", &error.to_string())',
|
|
]) {
|
|
if (desktopHostBridgeFilesSource.includes(blockedDesktopFileLogSnippet)) {
|
|
throw new Error('desktop shell file module must hide native file errors');
|
|
}
|
|
}
|
|
|
|
const desktopNavigationStateScriptBody = extractFunctionBody(
|
|
desktopShellNavigationSource,
|
|
'desktop_navigation_state_script',
|
|
);
|
|
if (
|
|
desktopNavigationStateScriptBody.includes(
|
|
"console.warn('desktop navigation state sync failed', error)",
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'desktop shell navigation state diagnostics must not log native error objects',
|
|
);
|
|
}
|
|
|
|
if (
|
|
desktopShellNavigationSource.includes(
|
|
'eprintln!("desktop host event blocked for webview.download: {url}")',
|
|
)
|
|
) {
|
|
throw new Error('desktop WebView download diagnostics must not log URLs');
|
|
}
|
|
|
|
if (
|
|
desktopHostBridgeProtocolSource.includes(
|
|
'desktop host bridge replay failed for {label}: {error}',
|
|
) ||
|
|
desktopHostBridgeProtocolSource.includes('&error.to_string()')
|
|
) {
|
|
throw new Error('desktop HostBridge replay diagnostics must not log Rust error details');
|
|
}
|
|
|
|
if (desktopShellNetworkSource.includes('desktop network reachability probe failed: {reason}')) {
|
|
throw new Error('desktop network probe diagnostics must not log resolver or connection details');
|
|
}
|
|
|
|
assertSameList(
|
|
allowedTauriCommands,
|
|
['host_bridge_request'],
|
|
'shared Tauri HostBridge command',
|
|
);
|
|
|
|
for (const snippet of [
|
|
'fn host_bridge_request_rejects_invalid_requests_before_replay()',
|
|
'let replay_state = HostBridgeReplayState::default();',
|
|
'prepare_host_bridge_request(&mut invalid).expect("invalid envelope")',
|
|
'assert_eq!(response.id, "request-1");',
|
|
'assert_eq!(response.error.expect("error").code, "invalid_request");',
|
|
'.reserve("request-1")',
|
|
'invalid request must not reserve replay slot',
|
|
]) {
|
|
if (!rustHostSource.includes(snippet)) {
|
|
throw new Error(`desktop shell host_bridge command facade test missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'expect("host bridge replay cache lock")',
|
|
'expect("host bridge replay slot lock")',
|
|
'expect("host bridge replay slot wait")',
|
|
'expect("host bridge replay response")',
|
|
]) {
|
|
if (desktopHostBridgeProtocolSource.includes(snippet)) {
|
|
throw new Error(`desktop shell HostBridge replay path must not panic on ${snippet}`);
|
|
}
|
|
}
|
|
|
|
assertSameList(
|
|
readDirectoryEntryList(rustSourceDir, 'desktop shell Rust root entries'),
|
|
expectedRustRootEntries,
|
|
'desktop shell Rust root entries',
|
|
);
|
|
assertSameList(
|
|
readDirectoryFileList(
|
|
new URL('host_bridge/', rustSourceDir),
|
|
'desktop shell host_bridge Rust files',
|
|
),
|
|
expectedRustHostBridgeFiles,
|
|
'desktop shell host_bridge Rust files',
|
|
);
|
|
assertSameList(
|
|
readDirectoryFileList(
|
|
new URL('shell/', rustSourceDir),
|
|
'desktop shell shell Rust files',
|
|
),
|
|
expectedRustShellFiles,
|
|
'desktop shell shell Rust files',
|
|
);
|
|
assertSameList(
|
|
collectRustSourceRelativePaths(rustHostSourceFiles),
|
|
requiredRustHostModules,
|
|
'desktop shell Rust bridge modules',
|
|
);
|
|
const desktopExportImagePayloadBody = extractFunctionBody(
|
|
desktopHostBridgeFilePayloadsSource,
|
|
'export_image_payload',
|
|
);
|
|
if (
|
|
!desktopExportImagePayloadBody.includes(
|
|
'bytes.is_empty() || bytes.len() > EXPORT_IMAGE_MAX_BYTES',
|
|
)
|
|
) {
|
|
throw new Error('desktop shell image export must reject empty and oversized bytes');
|
|
}
|
|
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');
|
|
}
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'desktop_appearance_color_scheme(&app, &request)',
|
|
) ||
|
|
desktopHostBridgeDispatchSource.includes('window.theme()') ||
|
|
desktopHostBridgeDispatchSource.includes('color_scheme_from_theme')
|
|
) {
|
|
throw new Error('desktop shell appearance HostBridge method must delegate to appearance module');
|
|
}
|
|
for (const snippet of [
|
|
'desktop_appearance_color_scheme',
|
|
'appearance_unavailable_response',
|
|
'log_desktop_appearance_failure',
|
|
'desktop appearance failed for {label}',
|
|
'color_scheme_from_theme(theme)',
|
|
'window.theme()',
|
|
'log_desktop_appearance_failure("theme")',
|
|
'log_desktop_appearance_failure("window")',
|
|
'"colorScheme"',
|
|
'"appearance unavailable"',
|
|
'appearance_unavailable_response_is_stable',
|
|
'appearance_failures_are_logged_without_exposing_native_detail',
|
|
'appearance_failures_log_stable_label_only',
|
|
]) {
|
|
if (!desktopHostBridgeAppearanceSource.includes(snippet)) {
|
|
throw new Error(`desktop shell appearance module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (
|
|
desktopHostBridgeAppearanceSource.includes('failed(request.id.clone(), "host_error", error.to_string())') ||
|
|
desktopHostBridgeAppearanceSource.includes('"main window not found"') ||
|
|
desktopHostBridgeAppearanceSource.includes('desktop appearance failed for {label}: {error}') ||
|
|
desktopHostBridgeAppearanceSource.includes('&error.to_string()')
|
|
) {
|
|
throw new Error('desktop shell appearance module must hide native window errors');
|
|
}
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'set_desktop_app_badge_count(&app, &request)',
|
|
) ||
|
|
desktopHostBridgeDispatchSource.includes('"app.setBadgeCount" => match') ||
|
|
desktopHostBridgeDispatchSource.includes('Ok(()) => ok(request.id, json!(true))') ||
|
|
desktopHostBridgeDispatchSource.includes('set_badge_count') ||
|
|
desktopHostBridgeDispatchSource.includes('BADGE_COUNT_MAX')
|
|
) {
|
|
throw new Error('desktop shell badge HostBridge method must delegate to badge module');
|
|
}
|
|
for (const snippet of [
|
|
'BADGE_COUNT_MAX',
|
|
'fn badge_count_payload',
|
|
'set_desktop_app_badge_count',
|
|
'badge_unavailable_response',
|
|
'log_desktop_badge_failure',
|
|
'desktop app badge failed for {label}',
|
|
'log_desktop_badge_failure("window")',
|
|
'log_desktop_badge_failure("set")',
|
|
'ok(request.id.clone(), json!(true))',
|
|
'"count must be an integer between 0 and 99999"',
|
|
'"badge unavailable"',
|
|
'badge_unavailable_response_is_stable',
|
|
'badge_failures_are_logged_without_exposing_native_detail',
|
|
'badge_failures_log_stable_label_only',
|
|
]) {
|
|
if (!desktopHostBridgeBadgeSource.includes(snippet)) {
|
|
throw new Error(`desktop shell badge module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (
|
|
desktopHostBridgeBadgeSource.includes('failed(request.id.clone(), "host_error", error.to_string())') ||
|
|
desktopHostBridgeBadgeSource.includes('"main window not found"') ||
|
|
desktopHostBridgeBadgeSource.includes('desktop app badge failed for {label}: {error}') ||
|
|
desktopHostBridgeBadgeSource.includes('&error.to_string()')
|
|
) {
|
|
throw new Error('desktop shell badge module must hide native window errors');
|
|
}
|
|
if (!desktopHostBridgeBadgeSource.match(/window\s*\.\s*set_badge_count\s*\(\s*count\s*\)/)) {
|
|
throw new Error('desktop shell badge module must call window.set_badge_count(count)');
|
|
}
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'set_desktop_host_bridge_window_title(&app, &request)',
|
|
) ||
|
|
desktopHostBridgeDispatchSource.includes('WINDOW_TITLE_MAX_LENGTH') ||
|
|
desktopHostBridgeDispatchSource.includes('required_string_payload(&request, "title")') ||
|
|
desktopHostBridgeDispatchSource.includes('window.set_title')
|
|
) {
|
|
throw new Error('desktop shell title HostBridge method must delegate to title module');
|
|
}
|
|
for (const snippet of [
|
|
'WINDOW_TITLE_MAX_LENGTH',
|
|
'normalize_window_title',
|
|
'window_title_from_request',
|
|
'window_title_unavailable_response',
|
|
'log_desktop_window_title_failure',
|
|
'desktop window title failed for {label}',
|
|
'required_string_payload(request, "title")',
|
|
'window.set_title(&title)',
|
|
'log_desktop_window_title_failure("set")',
|
|
'log_desktop_window_title_failure("window")',
|
|
'"title is required"',
|
|
'"window title unavailable"',
|
|
'window_title_request_rejects_missing_or_invalid_payload',
|
|
'window_title_request_trims_and_truncates_shared_boundary',
|
|
'window_title_unavailable_response_is_stable',
|
|
'window_title_failures_are_logged_without_exposing_native_detail',
|
|
'window_title_failures_log_stable_label_only',
|
|
]) {
|
|
if (!desktopHostBridgeTitleSource.includes(snippet)) {
|
|
throw new Error(`desktop shell title module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (
|
|
desktopHostBridgeTitleSource.includes('failed(request.id.clone(), "host_error", error.to_string())') ||
|
|
desktopHostBridgeTitleSource.includes('"main window not found"') ||
|
|
desktopHostBridgeTitleSource.includes('desktop window title failed for {label}: {error}') ||
|
|
desktopHostBridgeTitleSource.includes('&error.to_string()')
|
|
) {
|
|
throw new Error('desktop shell title module must hide native window errors');
|
|
}
|
|
if (
|
|
!desktopHostBridgeDispatchSource.includes(
|
|
'show_desktop_local_notification(&app, &request)',
|
|
) ||
|
|
desktopHostBridgeDispatchSource.includes('"notification.showLocal" => match') ||
|
|
desktopHostBridgeDispatchSource.includes('Ok(()) => ok(request.id, json!(true))') ||
|
|
desktopHostBridgeDispatchSource.includes('app.notification()') ||
|
|
desktopHostBridgeDispatchSource.includes('NotificationExt') ||
|
|
desktopHostBridgeDispatchSource.includes('PermissionState')
|
|
) {
|
|
throw new Error(
|
|
'desktop shell notification HostBridge method must delegate to notifications module',
|
|
);
|
|
}
|
|
assertSnippetOrder(
|
|
extractFunctionBody(desktopHostBridgeNotificationsSource, 'show_desktop_local_notification'),
|
|
'local_notification_payload(request)',
|
|
'app.notification()',
|
|
'desktop shell notification boundary',
|
|
);
|
|
for (const snippet of [
|
|
'tauri_plugin_notification::{NotificationExt, PermissionState}',
|
|
'HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION',
|
|
'HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH',
|
|
'HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH',
|
|
'DesktopNotificationPermissionAction',
|
|
'desktop_notification_permission_action',
|
|
'notification_permission_unavailable_response',
|
|
'notification_delivery_unavailable_response',
|
|
'log_desktop_notification_failure',
|
|
'desktop notification failed for {label}',
|
|
'log_desktop_notification_failure("permission.state")',
|
|
'log_desktop_notification_failure("permission.request")',
|
|
'log_desktop_notification_failure("delivery.show")',
|
|
'PermissionState::Granted',
|
|
'PermissionState::Denied',
|
|
'PermissionState::Prompt | PermissionState::PromptWithRationale',
|
|
'desktop_notification_delivered_to_system_result',
|
|
'json!({"action": HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION})',
|
|
'local_notification_payload_truncates_to_shared_contract_limits',
|
|
'notification_permission_unavailable_response_is_stable',
|
|
'notification_delivery_unavailable_response_is_stable',
|
|
'notification_failures_are_logged_without_exposing_native_detail',
|
|
'notification_failures_log_stable_label_only',
|
|
'"a".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH)',
|
|
'"b".repeat(HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH)',
|
|
'"notification permission unavailable"',
|
|
'"notification delivery unavailable"',
|
|
'"notification permission denied"',
|
|
'app.notification().builder()',
|
|
]) {
|
|
if (!desktopHostBridgeNotificationsSource.includes(snippet)) {
|
|
throw new Error(`desktop shell notification module is missing ${snippet}`);
|
|
}
|
|
}
|
|
if (
|
|
!desktopHostBridgeNotificationsSource.match(
|
|
/notification_manager\s*\.\s*permission_state\s*\(\s*\)/,
|
|
) ||
|
|
!desktopHostBridgeNotificationsSource.match(
|
|
/notification_manager\s*\.\s*request_permission\s*\(\s*\)/,
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'desktop shell notification module must check and request notification permission',
|
|
);
|
|
}
|
|
if (
|
|
desktopHostBridgeNotificationsSource.includes('desktop notification failed for {label}: {error}') ||
|
|
desktopHostBridgeNotificationsSource.includes('log_desktop_notification_failure("permission.state", &error.to_string())') ||
|
|
desktopHostBridgeNotificationsSource.includes('log_desktop_notification_failure("permission.request", &error.to_string())') ||
|
|
desktopHostBridgeNotificationsSource.includes('log_desktop_notification_failure("delivery.show", &error.to_string())')
|
|
) {
|
|
throw new Error('desktop shell notification module must hide native notification errors');
|
|
}
|
|
if (!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,')) {
|
|
throw new Error('H5 HostBridge facade must import shared runtime refresh timeout');
|
|
}
|
|
if (!h5HostBridgeSource.includes('HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,')) {
|
|
throw new Error('H5 HostBridge facade must import shared user interaction timeout');
|
|
}
|
|
if (!h5HostBridgeSource.includes('HOST_BRIDGE_SCANNER_TIMEOUT_MS,')) {
|
|
throw new Error('H5 HostBridge facade must import shared scanner timeout');
|
|
}
|
|
if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) {
|
|
throw new Error('H5 HostBridge facade must not redeclare runtime refresh timeout');
|
|
}
|
|
if (h5HostBridgeSource.includes('timeoutMs: 30000')) {
|
|
throw new Error('H5 HostBridge facade must not redeclare user interaction timeout');
|
|
}
|
|
if (h5HostBridgeSource.includes('timeoutMs: 60000')) {
|
|
throw new Error('H5 HostBridge facade must not redeclare scanner timeout');
|
|
}
|
|
for (const snippet of [
|
|
'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS',
|
|
'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS',
|
|
'function createNativeHostBridgeTimeoutError()',
|
|
'isHostBridgeEventName(candidate.event)',
|
|
'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}`);
|
|
}
|
|
}
|
|
for (const staleTimeoutBoundary of [
|
|
'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS',
|
|
'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS',
|
|
]) {
|
|
if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) {
|
|
throw new Error(
|
|
`H5 native app transport must use shared HostBridge timeout boundary instead of ${staleTimeoutBoundary}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
assertDesktopDialogBoundary(
|
|
'file.exportText',
|
|
'export_desktop_host_bridge_text_file',
|
|
'Text',
|
|
['txt', 'json', 'md', 'csv'],
|
|
'save',
|
|
);
|
|
assertDesktopDialogBoundary(
|
|
'file.importText',
|
|
'import_desktop_host_bridge_text_file',
|
|
'Text',
|
|
['txt', 'md', 'markdown', 'csv', 'json'],
|
|
'pick',
|
|
);
|
|
assertDesktopDialogBoundary(
|
|
'file.importDocument',
|
|
'import_desktop_host_bridge_document_file',
|
|
'Document',
|
|
['txt', 'md', 'markdown', 'csv', 'json', 'docx'],
|
|
'pick',
|
|
);
|
|
assertDesktopDialogBoundary(
|
|
'file.exportImage',
|
|
'export_desktop_host_bridge_image_file',
|
|
'Image',
|
|
['png', 'jpg', 'jpeg', 'webp'],
|
|
'save',
|
|
);
|
|
assertDesktopDialogBoundary(
|
|
'file.importImage',
|
|
'import_desktop_host_bridge_image_file',
|
|
'Image',
|
|
['png', 'jpg', 'jpeg', 'webp'],
|
|
'pick',
|
|
);
|
|
assertDesktopDialogBoundary(
|
|
'file.importAudio',
|
|
'import_desktop_host_bridge_audio_file',
|
|
'Audio',
|
|
['mp3', 'm4a', 'mp4', 'wav', 'ogg', 'webm'],
|
|
'pick',
|
|
);
|
|
assertDesktopDialogBoundary(
|
|
'file.exportAudio',
|
|
'export_desktop_host_bridge_audio_file',
|
|
'Audio',
|
|
['mp3', 'm4a', 'wav', 'ogg', 'webm'],
|
|
'save',
|
|
);
|
|
assertDesktopFileExportPayloadOrder(
|
|
'file.exportText',
|
|
'export_desktop_host_bridge_text_file',
|
|
'export_text_payload(request)',
|
|
);
|
|
assertDesktopFileExportPayloadOrder(
|
|
'file.exportImage',
|
|
'export_desktop_host_bridge_image_file',
|
|
'export_image_payload(request)',
|
|
);
|
|
assertDesktopFileExportPayloadOrder(
|
|
'file.exportAudio',
|
|
'export_desktop_host_bridge_audio_file',
|
|
'export_audio_payload(request)',
|
|
);
|
|
for (const snippet of [
|
|
'.dialog()',
|
|
'blocking_save_file',
|
|
'blocking_pick_file',
|
|
'export_text_payload',
|
|
'import_text_file_payload',
|
|
'import_document_file_payload',
|
|
'export_image_payload',
|
|
'import_image_file_payload',
|
|
'import_audio_file_payload',
|
|
'export_audio_payload',
|
|
'write_export_text_file',
|
|
'write_export_bytes_file',
|
|
]) {
|
|
if (desktopHostBridgeDispatchSource.includes(snippet)) {
|
|
throw new Error(`desktop shell dispatch must delegate file boundary instead of ${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(app),
|
|
allowedTauriCommands,
|
|
'desktop shell invoke handler commands',
|
|
);
|
|
assertGeneratedPermissions(allowedTauriCommands);
|
|
assertNoTrackedDesktopGeneratedTauriFiles();
|
|
|
|
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 (!app.includes(pluginSnippet)) {
|
|
throw new Error(`desktop shell app.rs missing plugin ${pluginSnippet}`);
|
|
}
|
|
}
|
|
for (let index = 1; index < orderedDesktopPluginSnippets.length; index += 1) {
|
|
const previousPlugin = orderedDesktopPluginSnippets[index - 1];
|
|
const currentPlugin = orderedDesktopPluginSnippets[index];
|
|
if (app.indexOf(previousPlugin) > app.indexOf(currentPlugin)) {
|
|
throw new Error(
|
|
`desktop shell plugin order drifted: ${previousPlugin} must be registered before ${currentPlugin}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (app.includes('single-instance",') || app.includes('"single-instance"')) {
|
|
throw new Error('desktop shell must not emit secondary-instance argv to H5');
|
|
}
|
|
|
|
if (!main.includes('mod app;') || !main.includes('app::run();')) {
|
|
throw new Error('desktop shell main.rs must stay a thin app entrypoint');
|
|
}
|
|
|
|
for (const entrypointOnlySnippet of [
|
|
'tauri::Builder::default()',
|
|
'tauri::generate_handler!',
|
|
'WebviewWindowBuilder::from_config',
|
|
]) {
|
|
if (main.includes(entrypointOnlySnippet)) {
|
|
throw new Error(
|
|
`desktop shell main.rs must not own Tauri app setup: ${entrypointOnlySnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const appSetupSnippet of [
|
|
'tauri::Builder::default()',
|
|
'crate::host_bridge::host_bridge_request',
|
|
'desktop_main_window_config(app)?',
|
|
'WebviewWindowBuilder::from_config',
|
|
'desktop_new_window_external_url(&url)',
|
|
'desktop_new_window_response',
|
|
'register_desktop_navigation_events(&window)?',
|
|
'log_desktop_host_event_result(',
|
|
]) {
|
|
if (!app.includes(appSetupSnippet)) {
|
|
throw new Error(`desktop shell app.rs missing setup snippet ${appSetupSnippet}`);
|
|
}
|
|
}
|
|
|
|
for (const blockedAppSetupSnippet of [
|
|
'app.config().app.windows.get(0)',
|
|
'app.config().app.windows[0]',
|
|
'if let Some(config) = window_config',
|
|
'register_desktop_network_events(&window)',
|
|
'let _ = register_desktop_network_events(&window)',
|
|
'let _ = register_desktop_navigation_events(&window)',
|
|
'let _ = emit_current_desktop_lifecycle_event(&window)',
|
|
]) {
|
|
if (app.includes(blockedAppSetupSnippet)) {
|
|
throw new Error(
|
|
`desktop shell app.rs must resolve the labeled main window before startup, not ${blockedAppSetupSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const blockedLifecycleSnippet of [
|
|
'let _ = register_desktop_network_events(window)',
|
|
'let _ = register_desktop_navigation_events(window)',
|
|
'let _ = emit_current_desktop_lifecycle_event(window)',
|
|
'let _ = emit_current_desktop_lifecycle_event(&lifecycle_window)',
|
|
'let _ = emit_desktop_image_drop_event(&drop_window',
|
|
'.find_map(|path| import_image_file_payload(path.clone(), "dropped", Some(position)).ok())',
|
|
'window.is_visible().unwrap_or(',
|
|
'window.is_minimized().unwrap_or(',
|
|
'window.is_focused().unwrap_or(',
|
|
'let _ = app.deep_link().register_all()',
|
|
'if let Ok(Some(urls)) = app.deep_link().get_current()',
|
|
'let _ = window.navigate(target_url)',
|
|
'let _ = show_main_window(window.app_handle())',
|
|
'let _ = show_main_window(app)',
|
|
'let _ = eval_main_window_history_back(app)',
|
|
'let _ = eval_main_window_history_forward(app)',
|
|
'let _ = reload_main_window(app)',
|
|
'let _ = open_normalized_desktop_external_url(',
|
|
'let _ = emit_desktop_lifecycle_event(&close_window',
|
|
'let _ = close_window.hide()',
|
|
'catch (_error) {}',
|
|
'catch (error) {}',
|
|
'.to_socket_addrs().ok()',
|
|
'TcpStream::connect_timeout(&address, timeout)\n .map(',
|
|
]) {
|
|
if (rustHostSource.includes(blockedLifecycleSnippet)) {
|
|
throw new Error(
|
|
`desktop shell WebView state replay failures must be logged or propagated, not ${blockedLifecycleSnippet}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const snippet of requiredRustHostSnippets) {
|
|
if (!rustHostSource.includes(snippet)) {
|
|
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
for (const snippet of [
|
|
'register_desktop_deep_link_schemes(app: &tauri::App) -> bool',
|
|
'log_desktop_deep_link_register_result(app.deep_link().register_all())',
|
|
'desktop host event failed for deep_link.register',
|
|
'desktop_deep_link_register_result_reports_success_and_failure',
|
|
'desktop_deep_link_register_result_logs_stable_label_only',
|
|
'log_desktop_deep_link_current_result(app.deep_link().get_current())',
|
|
'desktop host event failed for deep_link.current',
|
|
'desktop_deep_link_current_result_reports_success_empty_and_failure',
|
|
'desktop_deep_link_current_result_logs_stable_label_only',
|
|
'log_desktop_deep_link_window_missing("open")',
|
|
'log_desktop_deep_link_window_missing("current")',
|
|
'desktop_deep_link_window_missing_reports_failure',
|
|
]) {
|
|
if (!desktopShellDeepLinkSource.includes(snippet)) {
|
|
throw new Error(`desktop shell deep-link registration logging missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
if (
|
|
desktopShellDeepLinkSource.includes(
|
|
'desktop host event failed for deep_link.register: {error}',
|
|
) ||
|
|
desktopShellDeepLinkSource.includes(
|
|
'desktop host event failed for deep_link.current: {error}',
|
|
)
|
|
) {
|
|
throw new Error('desktop deep-link diagnostics must not log plugin error details');
|
|
}
|
|
|
|
if (app.includes('desktop tray registration failed: {error}')) {
|
|
throw new Error('desktop tray registration diagnostics must not log Tauri error details');
|
|
}
|
|
|
|
if (
|
|
desktopShellLifecycleSource.includes(
|
|
'desktop host event failed for app.lifecycle.{label}: {error}',
|
|
) ||
|
|
desktopShellLifecycleSource.includes(
|
|
'desktop host event failed for {label}: {error}',
|
|
)
|
|
) {
|
|
throw new Error('desktop lifecycle diagnostics must not log Tauri error details');
|
|
}
|
|
|
|
if (
|
|
rustHostSource.includes(
|
|
'desktop host event failed for file.imageDropped.payload: {error}',
|
|
) ||
|
|
rustHostSource.includes('log_desktop_image_drop_payload_failure(&error)')
|
|
) {
|
|
throw new Error('desktop image drop diagnostics must not log payload error details');
|
|
}
|