Files
openchamber/packages/electron/scripts/updater-e2e-fixture.mjs
T
502c96630e 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>
2026-07-13 08:59:31 +03:00

157 lines
6.0 KiB
JavaScript

#!/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;
});
}