0e0a5bb0c0
移动壳检查展开能力列表并校验request capability都有handler 桌面壳检查capabilities与Rust request分发一致 更新原生壳方案和共享决策记录
862 lines
26 KiB
JavaScript
862 lines
26 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 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 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 mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
|
|
const main = fs.readFileSync(mainPath, '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 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 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 assertNoBlockedDesktopSdkSnippets() {
|
|
const sources = [
|
|
['tauri.conf.json', JSON.stringify(config)],
|
|
['build.rs', buildScript],
|
|
['main.rs', main],
|
|
];
|
|
|
|
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 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];
|
|
}
|
|
|
|
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(
|
|
productionSourceRoots.flatMap((root) => collectProductionSourceFiles(root)),
|
|
);
|
|
assertNoBlockedNpmDependencies();
|
|
assertNoTauriGuestNpmDependencies(packageConfig, 'desktop shell package');
|
|
assertNoTauriGuestNpmDependencies(rootPackageConfig, 'root H5 package');
|
|
assertNoBlockedCargoDependencies();
|
|
assertNoBlockedDesktopSdkSnippets();
|
|
|
|
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 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 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(main, 'HOST_BRIDGE_METHODS');
|
|
const desktopCapabilities = extractDesktopCapabilities(main);
|
|
const desktopHandledMethods = extractDesktopHandledMethods(main);
|
|
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 (!main.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');
|
|
}
|
|
|
|
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 = [
|
|
'core:default',
|
|
'allow-host-bridge-request',
|
|
];
|
|
const allowedTauriCommands = ['host_bridge_request'];
|
|
const requiredMainSnippets = [
|
|
'tauri_plugin_single_instance::init',
|
|
'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"',
|
|
'"app.lifecycle"',
|
|
'"network.status"',
|
|
'"network.statusChanged"',
|
|
'"share.open"',
|
|
'"share.setTarget"',
|
|
'"navigation.openNativePage"',
|
|
'"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',
|
|
'"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_audio_payload',
|
|
'set_title',
|
|
'set_badge_count',
|
|
'window.reload()',
|
|
'read_text()',
|
|
'normalize_clipboard_text',
|
|
'window.theme()',
|
|
'WindowEvent::Focused',
|
|
'WindowEvent::DragDrop',
|
|
'host_bridge_event_script',
|
|
'origin: window.location.origin',
|
|
'source: window',
|
|
'resolve_desktop_network_status',
|
|
'network.statusChanged',
|
|
'file.imageDropped',
|
|
'app.notification().builder()',
|
|
'desktop_entry_url_with_platform',
|
|
'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|',
|
|
'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(
|
|
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',
|
|
);
|
|
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 = "2.4.2"')) {
|
|
throw new Error('desktop shell must depend on tauri-plugin-single-instance');
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
if (
|
|
main.indexOf('tauri_plugin_single_instance::init') >
|
|
main.indexOf('tauri_plugin_clipboard_manager::init()')
|
|
) {
|
|
throw new Error('desktop shell must register the single-instance plugin first');
|
|
}
|
|
|
|
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 requiredMainSnippets) {
|
|
if (!main.includes(snippet)) {
|
|
throw new Error(`desktop shell Rust host bridge missing ${snippet}`);
|
|
}
|
|
}
|