feat(desktop): Linux AppImage polish — window controls, updater UX, docs (#2144)

* feat(electron): add Linux AppImage releases

* ci: cache Linux OpenCode CLI artifacts

* fix(ci): await Linux release inventory check

* fix(electron): add frameless window controls on Linux desktop

Linux AppImages were created without native WM decorations and without
in-app controls, leaving users unable to close the window with a mouse.

Treat Linux like Windows: frameless BrowserWindow plus the existing
WindowsWindowControls header buttons and app-menu entry. macOS keeps
hidden title bar with traffic lights unchanged.

Shared usesFramelessElectronChrome() helper drives main window, mini
chat, header insets, and titlebar controls.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* feat(desktop): add configurable window controls position by OS

Add desktopWindowControlsPosition setting (auto/left/right) with OS-aware
defaults: Linux left, Windows right. Wire frameless chrome controls in
Header, TitlebarLeftControls, and MiniChatLayout, plus a Sessions settings
control for Windows and Linux desktop shells.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(desktop): address Linux AppImage release review findings

Propagate updater capability errors to the UI, treat missing
latest-linux.yml feeds as no-update, stop installed-apps IPC spam on
Linux, document FUSE/AppImage limits, add CHANGELOG entry, migrate
remaining btriapitsyn URLs, and run Electron Linux unit tests on PRs.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: jibanez-staticduo <staticduo@gmail.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-07-13 08:59:31 +03:00
committed by GitHub
co-authored by Serhii Dziupin jibanez-staticduo
parent 7e248d4e9b
commit 502c96630e
55 changed files with 1679 additions and 112 deletions
+5 -1
View File
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const updaterE2eBuild = process.env.OPENCHAMBER_UPDATER_E2E_BUILD === '1';
const result = await Bun.build({
entrypoints: [path.join(root, 'main.mjs')],
@@ -34,6 +35,9 @@ const result = await Bun.build({
minify: false,
sourcemap: 'none',
naming: '[name].mjs',
define: {
__OPENCHAMBER_UPDATER_E2E_BUILD__: updaterE2eBuild ? 'true' : 'false',
},
});
if (!result.success) {
@@ -41,4 +45,4 @@ if (!result.success) {
process.exit(1);
}
console.log('[electron] main.mjs bundled -> dist-bundle/main.mjs');
console.log(`[electron] main.mjs bundled -> dist-bundle/main.mjs (updater E2E=${updaterE2eBuild})`);
@@ -85,12 +85,6 @@ if (winX64 || winArm64) {
});
}
const linuxX64 = await read('latest-yml-x86_64-unknown-linux-gnu', 'latest-linux.yml');
if (linuxX64) output['latest-linux.yml'] = serialize(linuxX64);
const linuxArm64 = await read('latest-yml-aarch64-unknown-linux-gnu', 'latest-linux-arm64.yml');
if (linuxArm64) output['latest-linux-arm64.yml'] = serialize(linuxArm64);
const macX64 = await read('latest-yml-x86_64-apple-darwin', 'latest-mac.yml');
const macArm64 = await read('latest-yml-aarch64-apple-darwin', 'latest-mac.yml');
if (macX64 || macArm64) {
+10 -1
View File
@@ -1,8 +1,11 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { resolveTargetArchitecture } from './target-architecture.mjs';
const env = { ...process.env };
const builderArgs = process.argv.slice(2);
const targetArchitecture = resolveTargetArchitecture({ environment: env, builderArgs });
if (process.platform === 'win32' && !env.CSC_LINK && !env.WINDOWS_CSC_LINK) {
env.CSC_IDENTITY_AUTO_DISCOVERY = 'false';
@@ -22,7 +25,13 @@ const bunBinary = bunBinaryCandidates.find((candidate) => {
return false;
}) || (process.platform === 'win32' ? 'bun.exe' : 'bun');
const child = spawn(bunBinary, ['x', 'electron-builder', ...process.argv.slice(2)], {
if (process.platform === 'linux' && !builderArgs.some((argument) => (
argument === '--x64' || argument === '--arm64' || argument === '--arch' || argument.startsWith('--arch=')
))) {
builderArgs.push(`--${targetArchitecture.electronBuilder}`);
}
const child = spawn(bunBinary, ['x', 'electron-builder', ...builderArgs], {
env,
stdio: 'inherit',
});
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveTargetArchitecture } from './target-architecture.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const electronRoot = path.resolve(__dirname, '..');
@@ -39,8 +40,8 @@ const readPinnedSdkVersion = () => {
return trimmed;
};
const artifactForCurrentPlatform = () => {
const { platform, arch } = process;
const artifactForPlatform = (platform, targetArchitecture) => {
const arch = targetArchitecture.opencode;
if (platform === 'darwin') {
if (arch === 'arm64') return { name: 'opencode-darwin-arm64.zip', binary: 'opencode' };
if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' };
@@ -134,7 +135,8 @@ const main = async () => {
throw new Error(`Invalid OpenCode CLI version: ${version}`);
}
const artifact = artifactForCurrentPlatform();
const targetArchitecture = resolveTargetArchitecture();
const artifact = artifactForPlatform(process.platform, targetArchitecture);
const outputBinary = outputBinaryPath(artifact.binary);
const existingVersion = readBinaryVersion(outputBinary);
if (existingVersion === version) {
@@ -142,7 +144,7 @@ const main = async () => {
return;
}
const cacheDir = path.join(cacheRoot, version, `${process.platform}-${process.arch}`);
const cacheDir = path.join(cacheRoot, version, `${process.platform}-${targetArchitecture.opencode}`);
const archivePath = path.join(cacheDir, artifact.name);
const url = `https://github.com/anomalyco/opencode/releases/download/v${version}/${artifact.name}`;
if (!fs.existsSync(archivePath)) {
+3 -1
View File
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { rebuild } from '@electron/rebuild';
import { resolveTargetArchitecture } from './target-architecture.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -16,6 +17,7 @@ const require = createRequire(import.meta.url);
const electronPkg = require('electron/package.json');
const electronVersion = electronPkg.version;
const targetArchitecture = resolveTargetArchitecture();
const copyDirectory = async (src, dst) => {
await fsp.mkdir(dst, { recursive: true });
@@ -142,7 +144,7 @@ try {
buildPath: rebuildPath.buildPath,
electronVersion,
force: true,
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch,
arch: targetArchitecture.electronBuilder,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
});
} finally {
@@ -0,0 +1,77 @@
const ARCHITECTURES = {
x64: {
node: 'x64',
electronBuilder: 'x64',
opencode: 'x64',
},
arm64: {
node: 'arm64',
electronBuilder: 'arm64',
opencode: 'arm64',
},
};
const ARCHITECTURE_ALIASES = new Map([
['x64', 'x64'],
['amd64', 'x64'],
['x86_64', 'x64'],
['arm64', 'arm64'],
['aarch64', 'arm64'],
]);
export const normalizeTargetArchitecture = (value, source = 'target architecture') => {
const normalized = ARCHITECTURE_ALIASES.get(String(value || '').trim().toLowerCase());
if (!normalized) {
throw new Error(
`Unsupported ${source} ${JSON.stringify(value)}. Supported architectures: x64, arm64.`,
);
}
return ARCHITECTURES[normalized];
};
export const readElectronBuilderArchitecture = (args = []) => {
const requested = [];
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === '--x64' || argument === '--arm64') requested.push(argument.slice(2));
if (argument === '--arch' && args[index + 1]) requested.push(args[index + 1]);
if (argument.startsWith('--arch=')) requested.push(argument.slice('--arch='.length));
}
if (requested.length === 0) return null;
const architectures = new Set(requested.map((value) => normalizeTargetArchitecture(value, 'electron-builder architecture').node));
if (architectures.size !== 1) {
throw new Error(`Exactly one Electron target architecture is required, got: ${requested.join(', ')}.`);
}
return [...architectures][0];
};
export const resolveTargetArchitecture = ({
platform = process.platform,
hostArchitecture = process.arch,
environment = process.env,
builderArgs = [],
} = {}) => {
const host = normalizeTargetArchitecture(hostArchitecture, 'host architecture');
const builderArchitecture = readElectronBuilderArchitecture(builderArgs);
const requestedValues = [
environment.OPENCHAMBER_TARGET_ARCH,
environment.ELECTRON_BUILDER_ARCH,
builderArchitecture,
].filter(Boolean);
const requestedArchitectures = new Set(
requestedValues.map((value) => normalizeTargetArchitecture(value, 'target architecture').node),
);
if (requestedArchitectures.size > 1) {
throw new Error(`Conflicting target architectures: ${requestedValues.join(', ')}.`);
}
const target = normalizeTargetArchitecture(requestedValues[0] || host.node);
if (platform === 'linux' && target.node !== host.node) {
throw new Error(
`Linux AppImages must be built natively: host is ${host.node}, target is ${target.node}. `
+ `Run this build on a ${target.node} Linux host.`,
);
}
return target;
};
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
normalizeTargetArchitecture,
readElectronBuilderArchitecture,
resolveTargetArchitecture,
} from './target-architecture.mjs';
test('normalizes host and release architecture aliases', () => {
assert.equal(normalizeTargetArchitecture('amd64').node, 'x64');
assert.equal(normalizeTargetArchitecture('x86_64').electronBuilder, 'x64');
assert.equal(normalizeTargetArchitecture('aarch64').opencode, 'arm64');
});
test('reads a single electron-builder target architecture', () => {
assert.equal(readElectronBuilderArchitecture(['--linux', '--arch=aarch64']), 'arm64');
assert.equal(readElectronBuilderArchitecture(['--linux', '--x64']), 'x64');
});
test('rejects unsupported architectures', () => {
assert.throws(() => normalizeTargetArchitecture('ia32'), /Supported architectures: x64, arm64/);
});
test('rejects conflicting architecture inputs', () => {
assert.throws(
() => resolveTargetArchitecture({
platform: 'linux',
hostArchitecture: 'x64',
environment: { OPENCHAMBER_TARGET_ARCH: 'x64', ELECTRON_BUILDER_ARCH: 'arm64' },
}),
/Conflicting target architectures/,
);
});
test('rejects cross-architecture Linux packaging', () => {
assert.throws(
() => resolveTargetArchitecture({
platform: 'linux',
hostArchitecture: 'x86_64',
environment: { OPENCHAMBER_TARGET_ARCH: 'aarch64' },
}),
/must be built natively.*host is x64, target is arm64/,
);
});
test('accepts matching native Linux architecture aliases', () => {
assert.equal(resolveTargetArchitecture({
platform: 'linux',
hostArchitecture: 'x64',
environment: { OPENCHAMBER_TARGET_ARCH: 'amd64' },
}).node, 'x64');
});
@@ -0,0 +1,36 @@
# Linux Updater E2E Fixture
This local-only harness verifies AppImage N-to-N+1 replacement without changing the
production GitHub updater provider. It supports native x64 and arm64 hosts.
1. Build both versions on the native target architecture. For N and N+1, set the
test-build marker only while bundling main, then complete normal packaging:
```bash
OPENCHAMBER_TARGET_ARCH=x64 OPENCHAMBER_UPDATER_E2E_BUILD=1 bun run bundle:main
OPENCHAMBER_TARGET_ARCH=x64 node ./scripts/package.mjs --linux --x64 --publish=never
```
Use `OPENCHAMBER_TARGET_ARCH=arm64` and `--arm64` on an arm64 host. Keep the N and
N+1 AppImages in separate output directories before rebuilding.
2. Launch N against a loopback fixture containing N+1:
```bash
bun run updater:e2e:fixture -- run \
--arch x64 \
--current /absolute/path/OpenChamber-N-linux-x86_64.AppImage \
--next /absolute/path/OpenChamber-N+1-linux-x86_64.AppImage \
--version N+1 \
--dir /tmp/openchamber-updater-e2e
```
3. In N, check for updates, download/install, and restart. Verify the restarted app
reports N+1 and that the file at `APPIMAGE` was replaced. Repeat with `--arch arm64`
and the arm64 AppImages on the arm64 host.
The harness binds only `127.0.0.1`. Runtime override activation additionally requires
`OPENCHAMBER_E2E=1`, the loopback URL set by the harness, and the build-time marker.
Normal packages omit the build-time marker and always use `openchamber/openchamber`.
The renderer, IPC bridge, command-line arguments, and persistent configuration do not
have access to the feed URL.
@@ -0,0 +1,156 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const ARCHITECTURES = new Map([
['x64', 'latest-linux.yml'],
['arm64', 'latest-linux-arm64.yml'],
]);
const usage = `Usage:
updater-e2e-fixture.mjs stage --arch <x64|arm64> --next <N+1.AppImage> --version <N+1> --dir <feed-dir>
updater-e2e-fixture.mjs serve --dir <feed-dir> [--port <port>]
updater-e2e-fixture.mjs run --arch <x64|arm64> --current <N.AppImage> --next <N+1.AppImage> --version <N+1> --dir <feed-dir> [--port <port>]
Both AppImages must be packaged with OPENCHAMBER_UPDATER_E2E_BUILD=1 during bundle:main.
The run command stages N+1, serves it on 127.0.0.1, and launches N with only the two
runtime E2E gates. Use the Desktop update UI to check, download, apply, and restart.
Keep this process running until the restarted N+1 is verified, then press Ctrl-C.`;
const parseArguments = (argv) => {
const [command, ...rest] = argv;
const options = {};
for (let index = 0; index < rest.length; index += 2) {
const key = rest[index];
const value = rest[index + 1];
if (!key?.startsWith('--') || value === undefined) throw new Error(usage);
options[key.slice(2)] = value;
}
return { command, options };
};
const requireOption = (options, name) => {
const value = options[name];
if (!value) throw new Error(`Missing --${name}\n\n${usage}`);
return value;
};
const resolveArchitecture = (value) => {
if (!ARCHITECTURES.has(value)) throw new Error(`Unsupported architecture: ${value || '(missing)'}`);
return value;
};
const resolveExistingFile = (value, name) => {
const filePath = path.resolve(value);
if (!fs.statSync(filePath).isFile()) throw new Error(`--${name} must be a file: ${filePath}`);
return filePath;
};
const sha512 = (filePath) => crypto.createHash('sha512').update(fs.readFileSync(filePath)).digest('base64');
export const stageUpdaterFixture = ({ architecture, nextAppImage, version, directory }) => {
const manifestName = ARCHITECTURES.get(resolveArchitecture(architecture));
const sourcePath = resolveExistingFile(nextAppImage, 'next');
const feedDirectory = path.resolve(directory);
fs.mkdirSync(feedDirectory, { recursive: true });
const artifactName = path.basename(sourcePath);
const artifactPath = path.join(feedDirectory, artifactName);
if (sourcePath !== artifactPath) fs.copyFileSync(sourcePath, artifactPath);
const size = fs.statSync(artifactPath).size;
const checksum = sha512(artifactPath);
const manifest = [
`version: ${version}`,
'files:',
` - url: ${encodeURIComponent(artifactName)}`,
` sha512: ${checksum}`,
` size: ${size}`,
`path: ${encodeURIComponent(artifactName)}`,
`sha512: ${checksum}`,
`releaseDate: '${new Date().toISOString()}'`,
'',
].join('\n');
fs.writeFileSync(path.join(feedDirectory, manifestName), manifest, { mode: 0o644 });
return { artifactPath, manifestName, size };
};
export const createFixtureServer = ({ directory, port = 0 }) => {
const feedDirectory = path.resolve(directory);
const files = new Map(fs.readdirSync(feedDirectory, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => [`/${encodeURIComponent(entry.name)}`, path.join(feedDirectory, entry.name)]));
const server = http.createServer((request, response) => {
const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
const filePath = files.get(requestUrl.pathname);
if ((request.method !== 'GET' && request.method !== 'HEAD') || !filePath) {
response.writeHead(404).end();
return;
}
const stat = fs.statSync(filePath);
response.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': filePath.endsWith('.yml') ? 'text/yaml' : 'application/octet-stream',
});
if (request.method === 'HEAD') response.end();
else fs.createReadStream(filePath).pipe(response);
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(Number(port), '127.0.0.1', () => {
const address = server.address();
resolve({ server, url: `http://127.0.0.1:${address.port}/` });
});
});
};
const waitForSignal = () => new Promise((resolve) => {
process.once('SIGINT', resolve);
process.once('SIGTERM', resolve);
});
const main = async () => {
const { command, options } = parseArguments(process.argv.slice(2));
if (command === '--help' || command === 'help' || !command) {
console.log(usage);
return;
}
const directory = requireOption(options, 'dir');
if (command === 'stage' || command === 'run') {
const result = stageUpdaterFixture({
architecture: requireOption(options, 'arch'),
nextAppImage: requireOption(options, 'next'),
version: requireOption(options, 'version'),
directory,
});
console.log(`[electron] staged ${result.manifestName} and ${path.basename(result.artifactPath)}`);
if (command === 'stage') return;
}
if (command !== 'serve' && command !== 'run') throw new Error(usage);
const { server, url } = await createFixtureServer({ directory, port: options.port || 0 });
console.log(`[electron] updater E2E fixture listening at ${url}`);
if (command === 'run') {
const currentAppImage = resolveExistingFile(requireOption(options, 'current'), 'current');
const child = spawn(currentAppImage, [], {
env: {
...process.env,
APPIMAGE: currentAppImage,
OPENCHAMBER_E2E: '1',
OPENCHAMBER_UPDATER_E2E_URL: url,
},
stdio: 'inherit',
});
child.once('error', (error) => console.error(`[electron] failed to launch N AppImage: ${error.message}`));
}
await waitForSignal();
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
};
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { createFixtureServer, stageUpdaterFixture } from './updater-e2e-fixture.mjs';
import { parseUpdateManifest, verifyUpdateManifest } from './verify-update-manifest.mjs';
test('stages architecture-specific generic updater fixtures with valid metadata', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-updater-fixture-'));
try {
const source = path.join(root, 'OpenChamber-1.15.1-linux-arm64.AppImage');
const directory = path.join(root, 'feed');
fs.writeFileSync(source, 'fixture-appimage');
const result = stageUpdaterFixture({
architecture: 'arm64',
nextAppImage: source,
version: '1.15.1',
directory,
});
assert.equal(result.manifestName, 'latest-linux-arm64.yml');
const manifestPath = path.join(directory, result.manifestName);
assert.deepEqual(parseUpdateManifest(fs.readFileSync(manifestPath, 'utf8')).files.length, 1);
assert.deepEqual(verifyUpdateManifest({
manifestPath,
artifactPath: result.artifactPath,
expectedVersion: '1.15.1',
}), {
name: 'OpenChamber-1.15.1-linux-arm64.AppImage',
size: 16,
version: '1.15.1',
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('serves only staged fixture files over loopback', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-updater-server-'));
const artifact = path.join(root, 'OpenChamber.AppImage');
fs.writeFileSync(artifact, 'fixture');
const { server, url } = await createFixtureServer({ directory: root });
try {
assert.equal(new URL(url).hostname, '127.0.0.1');
const response = await fetch(`${url}OpenChamber.AppImage`);
assert.equal(response.status, 200);
assert.equal(await response.text(), 'fixture');
assert.equal((await fetch(`${url}../package.json`)).status, 404);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,164 @@
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 = ['better_sqlite3.node', '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);
}
}
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { linuxAppImageArchSuffix, readElfArchitecture, verifyExtractedPayload } from './verify-linux-appimage.mjs';
const writeElf = (filePath, architecture) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const header = Buffer.alloc(20);
header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]);
header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18);
fs.writeFileSync(filePath, header, { mode: 0o755 });
};
const createPayload = () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-payload-test-'));
fs.writeFileSync(path.join(root, 'openchamber.desktop'), [
'[Desktop Entry]', 'Name=OpenChamber', 'Exec=AppRun --no-sandbox %U', 'Icon=openchamber', 'StartupWMClass=openchamber', '',
].join('\n'));
writeElf(path.join(root, 'openchamber'), 'x64');
writeElf(path.join(root, 'resources/opencode-cli/opencode'), 'x64');
for (const name of ['better_sqlite3.node', 'pty.node', 'sherpa-onnx.node']) {
writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules', name), 'x64');
}
return root;
};
test('reads supported ELF architectures', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-elf-test-'));
try {
writeElf(path.join(root, 'x64'), 'x64');
writeElf(path.join(root, 'arm64'), 'arm64');
assert.equal(readElfArchitecture(path.join(root, 'x64')), 'x64');
assert.equal(readElfArchitecture(path.join(root, 'arm64')), 'arm64');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('AppImage artifact names use electron-builder arch suffixes', () => {
assert.equal(linuxAppImageArchSuffix('x64'), 'x86_64');
assert.equal(linuxAppImageArchSuffix('arm64'), 'arm64');
});
test('verifies identity, version, and native payload architecture', () => {
const root = createPayload();
try {
const result = verifyExtractedPayload({
root,
targetArchitecture: 'x64',
expectedOpenCodeVersion: '1.17.18',
runCliVersion: () => '1.17.18',
});
assert.equal(result.nativeModuleCount, 3);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('fails on a missing native module', () => {
const root = createPayload();
try {
fs.rmSync(path.join(root, 'resources/app.asar.unpacked/node_modules/pty.node'));
assert.throws(() => verifyExtractedPayload({
root,
targetArchitecture: 'x64',
expectedOpenCodeVersion: '1.17.18',
runCliVersion: () => '1.17.18',
}), /Missing packaged native module: pty\.node/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('fails on wrong CLI version or native architecture', () => {
const root = createPayload();
try {
assert.throws(() => verifyExtractedPayload({
root,
targetArchitecture: 'x64',
expectedOpenCodeVersion: '1.17.18',
runCliVersion: () => '1.17.17',
}), /OpenCode CLI version mismatch/);
writeElf(path.join(root, 'resources/app.asar.unpacked/node_modules/pty.node'), 'arm64');
assert.throws(() => verifyExtractedPayload({
root,
targetArchitecture: 'x64',
expectedOpenCodeVersion: '1.17.18',
runCliVersion: () => '1.17.18',
}), /Native module architecture mismatch/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const parseUpdateManifest = (content) => {
const version = content.match(/^version:\s*(\S+)\s*$/m)?.[1] || '';
const lines = content.split(/\r?\n/);
const files = [];
let entry = null;
for (const line of lines) {
const start = line.match(/^\s{2}-\s+(url|sha512|size|blockMapSize):\s*(\S+)\s*$/);
const field = start || line.match(/^\s{4}(url|sha512|size|blockMapSize):\s*(\S+)\s*$/);
if (start) {
if (entry) files.push(entry);
entry = {};
}
if (!field || !entry) continue;
const [, key, value] = field;
entry[key] = key === 'size' || key === 'blockMapSize' ? Number(value) : value;
}
if (entry) files.push(entry);
return {
version,
files: files.filter((file) => file.url && file.sha512 && Number.isSafeInteger(file.size)),
};
};
export const verifyUpdateManifest = ({ manifestPath, artifactPath, expectedVersion }) => {
const manifest = parseUpdateManifest(fs.readFileSync(manifestPath, 'utf8'));
const expectedName = path.basename(artifactPath);
if (manifest.version !== expectedVersion) {
throw new Error(`Update manifest version mismatch: expected ${expectedVersion}, got ${manifest.version || '(missing)'}`);
}
if (manifest.files.length !== 1) {
throw new Error(`Linux update manifest must contain exactly one artifact, got ${manifest.files.length}`);
}
const [entry] = manifest.files;
if (decodeURIComponent(path.basename(entry.url)) !== expectedName) {
throw new Error(`Update manifest artifact mismatch: expected ${expectedName}, got ${entry.url}`);
}
const bytes = fs.readFileSync(artifactPath);
if (entry.size !== bytes.length) {
throw new Error(`Update manifest size mismatch: expected ${bytes.length}, got ${entry.size}`);
}
const checksum = crypto.createHash('sha512').update(bytes).digest('base64');
if (entry.sha512 !== checksum) throw new Error('Update manifest sha512 mismatch');
return { name: expectedName, size: bytes.length, version: manifest.version };
};
const main = () => {
const [manifestPath, artifactPath, expectedVersion] = process.argv.slice(2);
if (!manifestPath || !artifactPath || !expectedVersion) {
throw new Error('Usage: verify-update-manifest.mjs <manifest> <artifact> <version>');
}
const result = verifyUpdateManifest({
manifestPath: path.resolve(manifestPath),
artifactPath: path.resolve(artifactPath),
expectedVersion,
});
console.log(`[electron] verified ${path.basename(manifestPath)} for ${result.name} (${result.size} bytes)`);
};
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);
}
}
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { verifyUpdateManifest } from './verify-update-manifest.mjs';
const fixture = (manifestName, artifactName, fields) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-manifest-test-'));
const artifactPath = path.join(root, artifactName);
const manifestPath = path.join(root, manifestName);
const bytes = Buffer.from(`artifact:${artifactName}`);
fs.writeFileSync(artifactPath, bytes);
fs.writeFileSync(manifestPath, [
'version: 1.15.0',
'files:',
...(fields || [
` - url: ${artifactName}`,
` sha512: ${crypto.createHash('sha512').update(bytes).digest('base64')}`,
` size: ${bytes.length}`,
]),
`path: ${artifactName}`,
'releaseDate: 2026-07-10T00:00:00.000Z',
'',
].join('\n'));
return { root, artifactPath, manifestPath };
};
for (const [manifestName, artifactName] of [
['latest-linux.yml', 'OpenChamber-1.15.0-linux-x86_64.AppImage'],
['latest-linux-arm64.yml', 'OpenChamber-1.15.0-linux-arm64.AppImage'],
]) {
test(`validates architecture-specific ${manifestName}`, () => {
const value = fixture(manifestName, artifactName);
try {
assert.equal(verifyUpdateManifest({ ...value, expectedVersion: '1.15.0' }).name, artifactName);
} finally {
fs.rmSync(value.root, { recursive: true, force: true });
}
});
}
test('accepts electron-builder field ordering and optional blockMapSize', () => {
const artifactName = 'OpenChamber-1.15.0-linux-x86_64.AppImage';
const bytes = Buffer.from(`artifact:${artifactName}`);
const value = fixture('latest-linux.yml', artifactName, [
` - sha512: ${crypto.createHash('sha512').update(bytes).digest('base64')}`,
` size: ${bytes.length}`,
' blockMapSize: 1234',
` url: ${artifactName}`,
]);
try {
assert.equal(verifyUpdateManifest({ ...value, expectedVersion: '1.15.0' }).name, artifactName);
} finally {
fs.rmSync(value.root, { recursive: true, force: true });
}
});
test('rejects a manifest that points at the other architecture artifact', () => {
const value = fixture('latest-linux-arm64.yml', 'OpenChamber-1.15.0-linux-arm64.AppImage');
try {
const x64Artifact = path.join(value.root, 'OpenChamber-1.15.0-linux-x86_64.AppImage');
fs.copyFileSync(value.artifactPath, x64Artifact);
assert.throws(() => verifyUpdateManifest({
manifestPath: value.manifestPath,
artifactPath: x64Artifact,
expectedVersion: '1.15.0',
}), /artifact mismatch/);
} finally {
fs.rmSync(value.root, { recursive: true, force: true });
}
});