* 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>
184 lines
6.8 KiB
JavaScript
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);
|
|
});
|