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
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user