Files
openchamber/packages/electron/scripts/verify-linux-appimage.mjs
T
Bohdan Triapitsyn 56f2b972f0 chore(deps): drop better-sqlite3 and its desktop packaging support
The SQLite write into OpenCode's database was the only consumer of
better-sqlite3 in the repository. Everything that existed to ship its native
binary went with it:

- the dependency in @openchamber/web and @openchamber/electron
- the afterPack hook staging better_sqlite3.node into app.asar.unpacked
- a dedicated @electron/rebuild pass (onlyModules) and its binary assertion,
  so desktop packaging now runs one native rebuild instead of two
- the bundler external entry and the AppImage required-native-module check

Desktop packaging, the AppImage verification tests, and the extension bundle
were re-validated after a clean reinstall, so no stale module could satisfy a
missed import.
2026-08-03 23:38:01 +03:00

165 lines
7.1 KiB
JavaScript

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { normalizeTargetArchitecture } from './target-architecture.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const electronRoot = path.resolve(__dirname, '..');
const workspaceRoot = path.resolve(electronRoot, '../..');
const ELF_MACHINE = { x64: 62, arm64: 183 };
// sherpa-onnx-node loads this Node-API addon from its platform-specific prebuilt
// package in the separate server worker, so verify its architecture here rather
// than Electron-rebuilding it with the source-built modules.
const REQUIRED_NATIVE_MODULES = ['pty.node', 'sherpa-onnx.node'];
/** electron-builder AppImage arch token: x64 → x86_64, arm64 → arm64 */
export const linuxAppImageArchSuffix = (architecture) => (
architecture === 'x64' ? 'x86_64' : 'arm64'
);
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8'));
export const readElfArchitecture = (filePath) => {
const header = Buffer.alloc(20);
const descriptor = fs.openSync(filePath, 'r');
try {
if (fs.readSync(descriptor, header, 0, header.length, 0) !== header.length) {
throw new Error(`ELF header is truncated: ${filePath}`);
}
} finally {
fs.closeSync(descriptor);
}
if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) {
throw new Error(`Expected an ELF binary: ${filePath}`);
}
const byteOrder = header[5];
if (byteOrder !== 1 && byteOrder !== 2) throw new Error(`Unsupported ELF byte order: ${filePath}`);
const machine = byteOrder === 1 ? header.readUInt16LE(18) : header.readUInt16BE(18);
const architecture = Object.entries(ELF_MACHINE).find(([, value]) => value === machine)?.[0];
if (!architecture) throw new Error(`Unsupported ELF machine ${machine}: ${filePath}`);
return architecture;
};
export const assertElfArchitecture = (filePath, expectedArchitecture, label) => {
if (!fs.existsSync(filePath)) throw new Error(`Missing ${label}: ${filePath}`);
const actual = readElfArchitecture(filePath);
if (actual !== expectedArchitecture) {
throw new Error(`${label} architecture mismatch: expected ${expectedArchitecture}, got ${actual} (${filePath})`);
}
};
const collectFiles = (root, predicate) => {
const matches = [];
const visit = (directory) => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) visit(fullPath);
else if (entry.isFile() && predicate(entry.name, fullPath)) matches.push(fullPath);
}
};
visit(root);
return matches;
};
const defaultCliVersion = (binaryPath) => {
const result = spawnSync(binaryPath, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
});
if (result.status !== 0) throw new Error(`Failed to run packaged OpenCode CLI: ${binaryPath}`);
return (result.stdout || '').trim().split(/\s+/)[0] || '';
};
export const verifyExtractedPayload = ({
root,
targetArchitecture,
expectedOpenCodeVersion,
runCliVersion = defaultCliVersion,
}) => {
const desktopPath = path.join(root, 'openchamber.desktop');
if (!fs.existsSync(desktopPath)) throw new Error(`Missing desktop entry: ${desktopPath}`);
const desktop = fs.readFileSync(desktopPath, 'utf8');
for (const entry of ['Name=OpenChamber', 'Icon=openchamber', 'StartupWMClass=openchamber']) {
if (!desktop.split(/\r?\n/).includes(entry)) throw new Error(`Desktop identity mismatch: missing ${entry}`);
}
if (!/^Exec=AppRun(?:\s|$)/m.test(desktop)) throw new Error('Desktop identity mismatch: expected AppImage AppRun entrypoint');
assertElfArchitecture(path.join(root, 'openchamber'), targetArchitecture, 'Electron executable');
const cliPath = path.join(root, 'resources', 'opencode-cli', 'opencode');
assertElfArchitecture(cliPath, targetArchitecture, 'OpenCode CLI');
const actualVersion = runCliVersion(cliPath);
if (actualVersion !== expectedOpenCodeVersion) {
throw new Error(`OpenCode CLI version mismatch: expected ${expectedOpenCodeVersion}, got ${actualVersion || '(empty)'}`);
}
const unpackedModules = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules');
if (!fs.existsSync(unpackedModules)) throw new Error(`Missing unpacked native modules: ${unpackedModules}`);
const nativeModules = collectFiles(unpackedModules, (name, fullPath) => {
if (!name.endsWith('.node')) return false;
const normalizedPath = fullPath.split(path.sep).join('/');
if (!normalizedPath.includes('/prebuilds/')) return true;
return normalizedPath.includes(`/prebuilds/linux-${targetArchitecture}/`);
});
for (const requiredName of REQUIRED_NATIVE_MODULES) {
if (!nativeModules.some((modulePath) => path.basename(modulePath) === requiredName)) {
throw new Error(`Missing packaged native module: ${requiredName}`);
}
}
for (const modulePath of nativeModules) assertElfArchitecture(modulePath, targetArchitecture, 'Native module');
return { nativeModuleCount: nativeModules.length, openCodeVersion: actualVersion };
};
const findAppImage = (version, architecture) => {
const suffix = linuxAppImageArchSuffix(architecture);
const expected = path.join(electronRoot, 'dist', `OpenChamber-${version}-linux-${suffix}.AppImage`);
if (!fs.existsSync(expected)) throw new Error(`Linux AppImage not found: ${expected}`);
return expected;
};
const extractAppImage = (appImagePath, destination) => {
fs.chmodSync(appImagePath, fs.statSync(appImagePath).mode | 0o100);
const result = spawnSync(appImagePath, ['--appimage-extract'], {
cwd: destination,
encoding: 'utf8',
stdio: ['ignore', 'ignore', 'pipe'],
timeout: 120000,
});
if (result.status !== 0) {
throw new Error(`Failed to extract AppImage: ${appImagePath}\n${(result.stderr || '').trim()}`);
}
return path.join(destination, 'squashfs-root');
};
const main = () => {
const rootPackage = readJson(path.join(workspaceRoot, 'package.json'));
const target = normalizeTargetArchitecture(process.env.OPENCHAMBER_TARGET_ARCH || process.arch).node;
const appImagePath = process.argv[2] ? path.resolve(process.argv[2]) : findAppImage(rootPackage.version, target);
assertElfArchitecture(appImagePath, target, 'AppImage');
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-appimage-'));
try {
const result = verifyExtractedPayload({
root: extractAppImage(appImagePath, temporaryDirectory),
targetArchitecture: target,
expectedOpenCodeVersion: rootPackage.dependencies?.['@opencode-ai/sdk'],
});
console.log(`[electron] verified Linux ${target} AppImage: ${appImagePath}`);
console.log(`[electron] verified OpenCode CLI ${result.openCodeVersion} and ${result.nativeModuleCount} native modules`);
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
};
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
}