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:
committed by
GitHub
co-authored by
Serhii Dziupin
jibanez-staticduo
parent
7e248d4e9b
commit
502c96630e
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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: () => {},
|
||||
}));
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user