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
+16 -3
View File
@@ -1,6 +1,6 @@
# OpenChamber Desktop
Electron desktop runtime for OpenChamber on macOS and Windows.
Electron desktop runtime for OpenChamber on macOS, Windows, and Linux.
This package owns the native shell: windows, menus, deep links, native notifications, auto-updates, host switching, SSH connections, tunnel helpers, and packaged desktop builds. The web UI and OpenChamber server logic still live in `packages/web` and shared React UI lives in `packages/ui`.
@@ -66,7 +66,7 @@ That runs, in order:
Build output goes to `packages/electron/dist`.
macOS builds produce `dmg` and `zip` artifacts. Windows builds produce an NSIS installer.
macOS builds produce `dmg` and `zip` artifacts. Windows builds produce an NSIS installer. Linux builds produce an AppImage for the native x64 or arm64 host.
## Platform Notes
@@ -74,7 +74,19 @@ macOS packaging needs Xcode/build tools for notarized builds and icon asset comp
Windows packaging needs NSIS support through `electron-builder`. If no Windows signing env is set, `package.mjs` disables code signing and builds an unsigned installer.
The package supports macOS and Windows desktop features. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps works on macOS and Windows.
Linux AppImages must be built natively. Set `OPENCHAMBER_TARGET_ARCH=x64` or `OPENCHAMBER_TARGET_ARCH=arm64` when packaging; the build rejects a target that does not match the Linux host. The same target selects the bundled OpenCode CLI, native Electron rebuild, and Electron Builder architecture. Linux identity is stable across architectures: executable `openchamber`, desktop file `openchamber.desktop`, icon `openchamber`, and `StartupWMClass=openchamber`.
After packaging, run `bun run --cwd packages/electron verify:linux-appimage`. The verifier extracts the final AppImage and checks its ELF architecture, desktop identity, Electron executable, pinned OpenCode CLI version and architecture, and all packaged native `.node` modules.
Running a packaged Linux AppImage requires FUSE (`libfuse.so.2`, typically `libfuse2` / `libfuse2t64` on Debian/Ubuntu). Without FUSE, start with `APPIMAGE_EXTRACT_AND_RUN=1`. Keep the AppImage on a writable path so in-app updates can replace it.
Linux updates are supported only when the packaged app is running from a writable AppImage. Update checks, downloads, and installation report an actionable error when `APPIMAGE` is missing, invalid, or read-only; a missing release feed (`latest-linux.yml` 404 before the first Linux publish) is treated as “no update available”. macOS and Windows updater behavior is unchanged. Release builds keep `latest-linux.yml` (x64) and `latest-linux-arm64.yml` separate and validate each manifest against its AppImage before upload. Linux AppImages download full updates (no `.blockmap` differential channel yet).
### Updater End-to-End Fixture
A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture.
The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls and auto-update; system tray and launch-at-login remain macOS/Windows only. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps and installed-app discovery work on macOS and Windows (Linux returns an empty list without errors).
## Bundled OpenCode CLI
@@ -98,6 +110,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
| `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` |
| `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server |
| `OPENCHAMBER_OPENCODE_CLI_VERSION` | Optional packaging override for the bundled OpenCode CLI version; defaults to the pinned root `@opencode-ai/sdk` version |
| `OPENCHAMBER_TARGET_ARCH` | Explicit desktop package architecture (`x64` or `arm64`); Linux requires it to match the native host |
| `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server |
| `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead |
| `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally |
+34 -31
View File
@@ -14,6 +14,9 @@ import { ElectronSshManager } from './ssh-manager.mjs';
import { createTrayController } from './tray.mjs';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs';
import { assertUpdaterCapability } from './updater-capability.mjs';
import { checkForDesktopUpdate } from './updater-check.mjs';
import { resolveUpdaterFeed } from './updater-feed.mjs';
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
const execFileAsync = promisify(execFile);
@@ -60,6 +63,9 @@ const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) =>
// Set the product name early so electron-log derives its log directory as
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
app.setName('OpenChamber');
if (process.platform === 'linux') {
app.setDesktopName('openchamber.desktop');
}
if (isDev) {
app.setPath('userData', path.join(app.getPath('appData'), 'OpenChamber Dev'));
}
@@ -2182,12 +2188,11 @@ const readThemeSource = () => {
};
const getWindowIconPath = () => {
if (process.platform !== 'win32' && process.platform !== 'linux') {
return undefined;
}
if (process.platform !== 'win32' && process.platform !== 'linux') return undefined;
const iconFileName = process.platform === 'linux' ? 'icon.png' : 'icon.ico';
const iconPath = isDev
? path.join(__dirname, 'resources', 'icons', 'icon.ico')
: path.join(process.resourcesPath, 'icons', 'icon.ico');
? path.join(__dirname, 'resources', 'icons', iconFileName)
: path.join(process.resourcesPath, 'icons', iconFileName);
return fs.existsSync(iconPath) ? iconPath : undefined;
};
@@ -2209,7 +2214,8 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
const desktopRequestHeaders = rendererRuntimeConfig.requestHeaders || {};
const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion());
const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32';
const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux';
const usesCustomTitleBar = process.platform === 'darwin' || usesFramelessChrome;
// macOS vibrancy, on by default; users can disable it (Appearance settings).
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
const titleBarOverlayEnabled = false;
@@ -2231,7 +2237,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
// here: setting it in the constructor leaves the material uncomposited on a
// cold launch until a window event. No `transparent: true` either — vibrancy
// alone is enough and composites reliably once applied to a live window.
frame: process.platform === 'win32' ? false : undefined,
frame: usesFramelessChrome ? false : undefined,
autoHideMenuBar: autoHidesNativeMenuBar,
// Electron's hiddenInset adds its own extra inset, which leaves the controls
// visibly lower than the app header. Use a plain hidden title bar instead.
@@ -2604,6 +2610,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
const desktopRequestHeaders = effectiveRuntimeConfig.requestHeaders || {};
const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion());
const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux';
// macOS vibrancy, on by default; users can disable it (Appearance settings).
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
const browserWindow = new BrowserWindow({
@@ -2619,9 +2626,9 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
// here: setting it in the constructor leaves the material uncomposited on a
// cold launch until a window event. No `transparent: true` either — vibrancy
// alone is enough and composites reliably once applied to a live window.
frame: process.platform === 'win32' ? false : undefined,
frame: usesFramelessChrome ? false : undefined,
autoHideMenuBar: process.platform !== 'darwin',
titleBarStyle: process.platform === 'darwin' || process.platform === 'win32' ? 'hidden' : 'default',
titleBarStyle: process.platform === 'darwin' || usesFramelessChrome ? 'hidden' : 'default',
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
webPreferences: {
additionalArguments: [
@@ -2819,10 +2826,6 @@ const compareSemver = (left, right) => {
return 0;
};
const parseGithubRepo = () => {
return { owner: 'openchamber', repo: 'openchamber' };
};
const setupAutoUpdater = () => {
if (!app.isPackaged) {
return;
@@ -2834,11 +2837,13 @@ const setupAutoUpdater = () => {
autoUpdater.disableWebInstaller = false;
autoUpdater.logger = log;
const { owner, repo } = parseGithubRepo();
autoUpdater.setFeedURL({
provider: 'github',
owner,
repo,
const testBuild = typeof __OPENCHAMBER_UPDATER_E2E_BUILD__ !== 'undefined'
&& __OPENCHAMBER_UPDATER_E2E_BUILD__ === true;
const feed = resolveUpdaterFeed({ testBuild });
autoUpdater.setFeedURL(feed);
log.info('[electron] updater feed configured', {
provider: feed.provider,
target: feed.provider === 'github' ? `${feed.owner}/${feed.repo}` : feed.url,
});
autoUpdater.on('download-progress', (progress) => {
@@ -3798,7 +3803,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
emitToAllWindows('openchamber:installed-apps-updated', apps);
};
if (process.platform !== 'darwin' && process.platform !== 'win32') {
throw new Error('desktop_get_installed_apps is only supported on macOS and Windows');
return { apps: [], hasCache: false, isCacheStale: false, supported: false };
}
if (!hasCache || isCacheStale || args.force === true) {
void refresh();
@@ -3901,22 +3906,18 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_check_for_updates': {
assertUpdaterCapability({ packaged: app.isPackaged });
const currentVersion = APP_VERSION;
let updateResult = null;
try {
updateResult = await autoUpdater.checkForUpdates();
} catch {
}
const updateInfo = updateResult?.updateInfo;
const nextVersion =
(typeof updateInfo?.version === 'string' && updateInfo.version) ||
currentVersion;
const available = compareSemver(nextVersion, currentVersion) > 0;
const { available, updateInfo, updateResult, nextVersion, pendingUpdate } = await checkForDesktopUpdate({
autoUpdater,
currentVersion,
pendingUpdate: state.pendingUpdate,
compareVersions: compareSemver,
});
const body =
(typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) ||
await parseRelevantChangelogNotes(currentVersion, nextVersion);
state.pendingUpdate = available ? { version: nextVersion, electronUpdate: updateResult } : null;
state.pendingUpdate = pendingUpdate;
return {
available,
currentVersion,
@@ -3929,6 +3930,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_download_and_install_update':
assertUpdaterCapability({ packaged: app.isPackaged });
if (!state.pendingUpdate) {
throw new Error('No pending update');
}
@@ -3974,6 +3976,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_restart': {
const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged);
if (applyUpdate) assertUpdaterCapability({ packaged: app.isPackaged });
log.info(`[electron] desktop_restart applyUpdate=${applyUpdate} packaged=${app.isPackaged}`);
if (applyUpdate && process.platform === 'darwin' && typeof app.isInApplicationsFolder === 'function') {
try {
+28 -1
View File
@@ -21,7 +21,8 @@
"Electron runtime dependencies installed via bun install",
"Bun available for sidecar compilation",
"macOS: Xcode + build tools for notarized packaging",
"Windows: NSIS installed for installer creation"
"Windows: NSIS installed for installer creation",
"Linux: native x64 or arm64 host (no cross-arch packaging); FUSE/libfuse2 to run AppImages, or APPIMAGE_EXTRACT_AND_RUN=1"
],
"scripts": {
"dev": "node ./scripts/electron-dev.mjs",
@@ -30,9 +31,14 @@
"prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs",
"verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged",
"verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged",
"verify:linux-appimage": "node ./scripts/verify-linux-appimage.mjs",
"bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"test:architecture": "node --test ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs",
"test:updater": "node --test ./updater-capability.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/updater-e2e-fixture.test.mjs",
"updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs",
"verify:update-manifest": "node ./scripts/verify-update-manifest.mjs",
"package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
"finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs",
@@ -54,6 +60,10 @@
"from": "resources/icons/icon.ico",
"to": "icons/icon.ico"
},
{
"from": "resources/icons/icon.png",
"to": "icons/icon.png"
},
{
"from": "resources/icons/tray",
"to": "icons/tray"
@@ -95,6 +105,23 @@
"verifyUpdateCodeSignature": false,
"artifactName": "${productName}-${version}-win-${arch}.${ext}"
},
"linux": {
"target": [
"AppImage"
],
"category": "Development",
"icon": "resources/icons/icon.png",
"executableName": "openchamber",
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",
"desktop": {
"entry": {
"Name": "OpenChamber",
"Comment": "Desktop runtime for OpenChamber",
"Icon": "openchamber",
"StartupWMClass": "openchamber"
}
}
},
"nsis": {
"oneClick": true,
"perMachine": false,
+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 });
}
});
+35
View File
@@ -0,0 +1,35 @@
import fs from 'node:fs';
import path from 'node:path';
export const assertUpdaterCapability = ({
platform = process.platform,
packaged,
appImagePath = process.env.APPIMAGE,
access = fs.accessSync,
stat = fs.statSync,
} = {}) => {
if (platform !== 'linux' || !packaged) return;
if (!appImagePath) {
throw new Error(
'Updates require the packaged Linux AppImage. Start OpenChamber from its .AppImage file, not an extracted or repackaged copy.',
);
}
if (!path.isAbsolute(appImagePath)) {
throw new Error(`Updates require APPIMAGE to be an absolute path, got: ${appImagePath}`);
}
try {
if (!stat(appImagePath).isFile()) throw new Error('not a file');
} catch {
throw new Error(`The running AppImage cannot be found at ${appImagePath}. Start OpenChamber from a valid .AppImage file.`);
}
try {
access(appImagePath, fs.constants.W_OK);
} catch {
throw new Error(
`The AppImage is not writable at ${appImagePath}. Move it to a writable location or grant write permission before updating.`,
);
}
};
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertUpdaterCapability } from './updater-capability.mjs';
test('preserves updater behavior outside packaged Linux', () => {
assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'darwin', packaged: true }));
assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'win32', packaged: true }));
assert.doesNotThrow(() => assertUpdaterCapability({ platform: 'linux', packaged: false }));
});
test('rejects packaged Linux execution outside an AppImage', () => {
assert.throws(
() => assertUpdaterCapability({ platform: 'linux', packaged: true, appImagePath: '' }),
/Start OpenChamber from its \.AppImage file/,
);
});
test('rejects missing and non-writable AppImages with actionable errors', () => {
assert.throws(
() => assertUpdaterCapability({
platform: 'linux',
packaged: true,
appImagePath: '/opt/OpenChamber.AppImage',
stat: () => { throw new Error('missing'); },
}),
/cannot be found.*valid \.AppImage file/,
);
assert.throws(
() => assertUpdaterCapability({
platform: 'linux',
packaged: true,
appImagePath: '/opt/OpenChamber.AppImage',
stat: () => ({ isFile: () => true }),
access: () => { throw new Error('read-only'); },
}),
/not writable.*grant write permission/,
);
});
test('accepts a writable packaged AppImage', () => {
assert.doesNotThrow(() => assertUpdaterCapability({
platform: 'linux',
packaged: true,
appImagePath: '/home/user/OpenChamber.AppImage',
stat: () => ({ isFile: () => true }),
access: () => {},
}));
});
+42
View File
@@ -0,0 +1,42 @@
const MISSING_UPDATE_FEED_RE =
/404|ENOTFOUND|Cannot find (?:channel|latest)|latest-linux(?:-arm64)?\.yml|HttpError:\s*404|status code 404/i;
export const isMissingUpdateFeedError = (error) => {
const message = error instanceof Error ? error.message : String(error ?? '');
return MISSING_UPDATE_FEED_RE.test(message);
};
export const checkForDesktopUpdate = async ({ autoUpdater, currentVersion, pendingUpdate, compareVersions }) => {
let updateResult;
try {
updateResult = await autoUpdater.checkForUpdates();
} catch (error) {
// Before the first Linux (or platform) release publishes its feed, electron-updater
// returns 404 for latest-*.yml. Treat that as authoritative "no update" instead of
// surfacing a hard failure that looks like a broken updater.
if (isMissingUpdateFeedError(error)) {
return {
available: false,
updateInfo: null,
updateResult: null,
nextVersion: currentVersion,
pendingUpdate: null,
};
}
const detail = error instanceof Error && error.message ? `: ${error.message}` : '';
throw new Error(`Unable to check for updates${detail}. Check your network connection and try again.`, { cause: error });
}
const updateInfo = updateResult?.updateInfo;
const nextVersion =
(typeof updateInfo?.version === 'string' && updateInfo.version) ||
currentVersion;
const available = compareVersions(nextVersion, currentVersion) > 0;
return {
available,
updateInfo,
updateResult,
nextVersion,
pendingUpdate: available ? { version: nextVersion, electronUpdate: updateResult } : null,
};
};
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { checkForDesktopUpdate } from './updater-check.mjs';
const compareVersions = (left, right) => left.localeCompare(right, undefined, { numeric: true });
test('signals failed checks without replacing an existing pending update', async () => {
const pendingUpdate = { version: '2.0.0', electronUpdate: { id: 'existing' } };
await assert.rejects(
checkForDesktopUpdate({
autoUpdater: { checkForUpdates: async () => { throw new Error('feed unavailable'); } },
currentVersion: '1.0.0',
pendingUpdate,
compareVersions,
}),
/Unable to check for updates: feed unavailable.*network connection/,
);
assert.deepEqual(pendingUpdate, { version: '2.0.0', electronUpdate: { id: 'existing' } });
});
test('treats missing update feed (404) as no update available', async () => {
const result = await checkForDesktopUpdate({
autoUpdater: {
checkForUpdates: async () => {
throw new Error('HttpError: 404 Not Found "https://github.com/.../latest-linux.yml"');
},
},
currentVersion: '1.15.0',
pendingUpdate: { version: '1.16.0' },
compareVersions,
});
assert.equal(result.available, false);
assert.equal(result.pendingUpdate, null);
assert.equal(result.nextVersion, '1.15.0');
});
test('authoritative no-update result clears pending update', async () => {
const result = await checkForDesktopUpdate({
autoUpdater: { checkForUpdates: async () => ({ updateInfo: { version: '1.0.0' } }) },
currentVersion: '1.0.0',
pendingUpdate: { version: '2.0.0' },
compareVersions,
});
assert.equal(result.available, false);
assert.equal(result.pendingUpdate, null);
});
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs';
export const PRODUCTION_UPDATER_FEED = Object.freeze({
provider: 'github',
owner: 'openchamber',
repo: 'openchamber',
});
const isLoopbackHostname = (hostname) => {
if (hostname === '::1' || hostname === '[::1]') return true;
const octets = hostname.split('.');
if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) return false;
const values = octets.map(Number);
return values[0] === 127 && values.every((value) => value <= 255);
};
export const parseLoopbackUpdaterUrl = (value) => {
if (!value) return null;
try {
const url = new URL(value);
if ((url.protocol !== 'http:' && url.protocol !== 'https:')
|| !isLoopbackHostname(url.hostname)
|| url.username
|| url.password
|| url.search
|| url.hash) {
return null;
}
return url.toString();
} catch {
return null;
}
};
export const resolveUpdaterFeed = ({
environment = process.env,
testBuild = false,
} = {}) => {
if (environment.OPENCHAMBER_E2E !== '1'
|| testBuild !== true) {
return PRODUCTION_UPDATER_FEED;
}
const url = parseLoopbackUpdaterUrl(environment.OPENCHAMBER_UPDATER_E2E_URL);
if (!url) return PRODUCTION_UPDATER_FEED;
return { provider: 'generic', url };
};
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PRODUCTION_UPDATER_FEED,
parseLoopbackUpdaterUrl,
resolveUpdaterFeed,
} from './updater-feed.mjs';
const overrideEnvironment = {
OPENCHAMBER_E2E: '1',
OPENCHAMBER_UPDATER_E2E_URL: 'http://127.0.0.1:49152/updates/',
};
test('production updater feed is immutable GitHub configuration', () => {
assert.equal(Object.isFrozen(PRODUCTION_UPDATER_FEED), true);
assert.deepEqual(PRODUCTION_UPDATER_FEED, {
provider: 'github',
owner: 'openchamber',
repo: 'openchamber',
});
});
test('requires the complete E2E environment and embedded build-marker conjunction', () => {
const cases = [
{},
{ environment: overrideEnvironment },
{ environment: { OPENCHAMBER_E2E: '1' }, testBuild: true },
{
environment: { OPENCHAMBER_UPDATER_E2E_URL: overrideEnvironment.OPENCHAMBER_UPDATER_E2E_URL },
testBuild: true,
},
{ environment: overrideEnvironment, testBuild: false },
];
for (const input of cases) assert.equal(resolveUpdaterFeed(input), PRODUCTION_UPDATER_FEED);
});
test('accepts only credential-free loopback HTTP(S) URLs', () => {
assert.equal(parseLoopbackUpdaterUrl('http://127.0.0.1:8080/feed'), 'http://127.0.0.1:8080/feed');
assert.equal(parseLoopbackUpdaterUrl('https://127.255.0.1/feed/'), 'https://127.255.0.1/feed/');
assert.equal(parseLoopbackUpdaterUrl('http://[::1]:8080/feed'), 'http://[::1]:8080/feed');
for (const value of [
'http://localhost:8080/feed',
'http://0.0.0.0:8080/feed',
'http://192.168.1.5:8080/feed',
'https://example.com/feed',
'file:///tmp/feed',
'ftp://127.0.0.1/feed',
'http://user:secret@127.0.0.1/feed',
'http://127.0.0.1/feed?token=secret',
'http://127.0.0.1/feed#fragment',
'not-a-url',
]) assert.equal(parseLoopbackUpdaterUrl(value), null, value);
});
test('uses a generic feed only when every test-only gate is valid', () => {
assert.deepEqual(resolveUpdaterFeed({
environment: overrideEnvironment,
testBuild: true,
}), {
provider: 'generic',
url: 'http://127.0.0.1:49152/updates/',
});
});
test('invalid URLs fall back to the production feed even with both test gates', () => {
for (const url of ['https://example.com/feed', 'http://localhost/feed', '']) {
assert.equal(resolveUpdaterFeed({
environment: { ...overrideEnvironment, OPENCHAMBER_UPDATER_E2E_URL: url },
testBuild: true,
}), PRODUCTION_UPDATER_FEED);
}
});