Files
openchamber/packages/electron/scripts/prepare-opencode-cli.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

184 lines
6.8 KiB
JavaScript

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 { resolveTargetArchitecture } from './target-architecture.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const electronRoot = path.resolve(__dirname, '..');
const workspaceRoot = path.resolve(electronRoot, '../..');
const outputDir = path.join(electronRoot, 'resources', 'opencode-cli');
const cacheRoot = path.join(electronRoot, '.cache', 'opencode-cli');
const rootPackagePath = path.join(workspaceRoot, 'package.json');
const run = (command, args, options = {}) => {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: options.stdio || 'pipe',
windowsHide: true,
...options,
});
if (result.status !== 0) {
const stderr = result.stderr ? `\n${result.stderr.trim()}` : '';
const stdout = result.stdout ? `\n${result.stdout.trim()}` : '';
throw new Error(`Command failed: ${command} ${args.join(' ')}${stderr}${stdout}`);
}
return result;
};
const readPinnedSdkVersion = () => {
const pkg = JSON.parse(fs.readFileSync(rootPackagePath, 'utf8'));
const version = pkg.dependencies?.['@opencode-ai/sdk'];
if (typeof version !== 'string' || !version.trim()) {
throw new Error('Missing @opencode-ai/sdk dependency in root package.json');
}
const trimmed = version.trim();
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(trimmed)) {
throw new Error(`@opencode-ai/sdk must be pinned to an exact version for desktop CLI bundling, got: ${trimmed}`);
}
return trimmed;
};
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' };
}
if (platform === 'win32') {
if (arch === 'arm64') return { name: 'opencode-windows-arm64.zip', binary: 'opencode.exe' };
if (arch === 'x64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' };
}
if (platform === 'linux') {
if (arch === 'arm64') return { name: 'opencode-linux-arm64.tar.gz', binary: 'opencode' };
if (arch === 'x64') return { name: 'opencode-linux-x64-baseline.tar.gz', binary: 'opencode' };
}
throw new Error(`No OpenCode CLI artifact mapping for ${platform}/${arch}`);
};
const outputBinaryPath = (binaryName) => path.join(outputDir, binaryName);
const readBinaryVersion = (binaryPath) => {
if (!fs.existsSync(binaryPath)) return null;
const result = spawnSync(binaryPath, ['--version'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
windowsHide: true,
});
if (result.status !== 0) return null;
return (result.stdout || '').trim().split(/\s+/)[0] || null;
};
const ensureExecutable = (filePath) => {
if (process.platform !== 'win32') {
fs.chmodSync(filePath, 0o755);
}
};
const download = async (url, destination) => {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
}
const temp = `${destination}.tmp`;
fs.writeFileSync(temp, Buffer.from(await response.arrayBuffer()));
fs.renameSync(temp, destination);
};
const extractArchive = (archivePath, destination) => {
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
if (archivePath.endsWith('.zip')) {
if (process.platform === 'win32') {
run('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Command',
`Expand-Archive -LiteralPath ${JSON.stringify(archivePath)} -DestinationPath ${JSON.stringify(destination)} -Force`,
]);
return;
}
run('unzip', ['-q', archivePath, '-d', destination]);
return;
}
if (archivePath.endsWith('.tar.gz')) {
run('tar', ['-xzf', archivePath, '-C', destination]);
return;
}
throw new Error(`Unsupported OpenCode CLI archive: ${archivePath}`);
};
const findBinary = (root, binaryName) => {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
if (entry.isFile() && entry.name.toLowerCase() === binaryName.toLowerCase()) {
return fullPath;
}
if (entry.isDirectory()) {
const found = findBinary(fullPath, binaryName);
if (found) return found;
}
}
return null;
};
const main = async () => {
const version = process.env.OPENCHAMBER_OPENCODE_CLI_VERSION || readPinnedSdkVersion();
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid OpenCode CLI version: ${version}`);
}
const targetArchitecture = resolveTargetArchitecture();
const artifact = artifactForPlatform(process.platform, targetArchitecture);
const outputBinary = outputBinaryPath(artifact.binary);
const existingVersion = readBinaryVersion(outputBinary);
if (existingVersion === version) {
console.log(`[electron] bundled OpenCode CLI already prepared: ${outputBinary} (${version})`);
return;
}
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)) {
console.log(`[electron] downloading OpenCode CLI ${version}: ${artifact.name}`);
await download(url, archivePath);
} else {
console.log(`[electron] using cached OpenCode CLI archive: ${archivePath}`);
}
const extractDir = path.join(cacheDir, 'extract');
extractArchive(archivePath, extractDir);
const extractedBinary = findBinary(extractDir, artifact.binary);
if (!extractedBinary) {
throw new Error(`Archive ${archivePath} did not contain ${artifact.binary}`);
}
fs.mkdirSync(outputDir, { recursive: true });
for (const entry of fs.readdirSync(outputDir)) {
if (entry === '.gitkeep') continue;
fs.rmSync(path.join(outputDir, entry), { recursive: true, force: true });
}
fs.copyFileSync(extractedBinary, outputBinary);
ensureExecutable(outputBinary);
const preparedVersion = readBinaryVersion(outputBinary);
if (preparedVersion !== version) {
throw new Error(`Prepared OpenCode CLI version mismatch: expected ${version}, got ${preparedVersion || 'unknown'}`);
}
console.log(`[electron] prepared OpenCode CLI ${version}: ${outputBinary}`);
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});