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