feat(desktop): Linux AppImage releases and desktop feature parity (#2398)

* 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>

* Linux desktop feature parity: Open in, background start, tray, multi-window (#2392)

* feat(electron): Linux parity for Open in, background start, and tray

Enable Linux desktop feature parity with macOS/Windows: open projects in
the default file manager and discovered apps, XDG autostart with
--background launches, system tray (including minimize-to-tray), and
tray sync from the renderer.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(electron): allow packaged UI protocol navigations on Linux

Prevent openchamber-ui:// navigations from being handed to
shell.openExternal, which fails on Linux and blocked desktop UI flows.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(ui): surface Linux tray settings in settings search

Include isLinux in settings search runtime context so minimize-to-tray
is discoverable on Linux desktop, matching Windows search behavior.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(electron): stop Linux AppImage Waiting-for-OpenCode flicker

Sync updated boot-outcome init scripts to all BrowserWindows after
desktop_hosts_set, and prefer state.initScript on dom-ready so chooser
reloads inject local/ok instead of a stale not-configured outcome.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(desktop): restore Linux AppImage updater feed and error UX (#2396)

Treat missing latest-linux*.yml (404) as no update available instead of a
hard failure, and stop swallowing updater capability/download errors in the
desktop bridge so About/sidebar can show actionable messages.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* docs: credit Linux AppImage contributors in changelog

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:
Serhii Dziupin
2026-07-24 09:59:32 +03:00
committed by GitHub
co-authored by Cursor Agent Serhii Dziupin jibanez-staticduo
parent ddbb3c1db0
commit 18b58bdb6b
27 changed files with 962 additions and 57 deletions
+196 -33
View File
@@ -17,6 +17,18 @@ 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 {
buildLinuxInstalledApps,
buildLinuxOpenSpecs,
fetchLinuxAppIcons,
filterLinuxInstalledApps,
readLinuxDesktopEntries,
} from './linux-app-discovery.mjs';
import {
readLinuxAutostartEnabled,
setLinuxAutostartEnabled,
} from './linux-autostart.mjs';
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
const execFileAsync = promisify(execFile);
@@ -44,6 +56,9 @@ const getLoginItemOptions = () => {
};
const readLoginItemSettings = () => {
if (process.platform === 'linux') {
return null;
}
if (process.platform !== 'darwin' && process.platform !== 'win32') return null;
try {
return app.getLoginItemSettings(getLoginItemOptions());
@@ -182,6 +197,7 @@ const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/i
const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA';
const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24;
const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json';
const LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS = 30_000;
const OPENCODE_SHUTDOWN_GRACE_MS = 100;
const { autoUpdater } = updaterPkg;
@@ -244,7 +260,7 @@ const readDesktopKeepAwakeStatus = () => {
};
const readDesktopMinimizeToTrayStatus = () => {
const supported = process.platform === 'win32';
const supported = process.platform === 'win32' || process.platform === 'linux';
return {
supported,
enabled: supported && readSettingsRoot().desktopMinimizeToTrayEnabled === true,
@@ -252,7 +268,7 @@ const readDesktopMinimizeToTrayStatus = () => {
};
const shouldHideMainWindowToTray = (browserWindow) => {
if (process.platform !== 'win32') return false;
if (process.platform !== 'win32' && process.platform !== 'linux') return false;
if (!state.trayController) return false;
if (!browserWindow || browserWindow.isDestroyed()) return false;
if (browserWindow.__ocMiniChat === true) return false;
@@ -1540,6 +1556,18 @@ const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken
].join('');
};
// Keep per-window init scripts aligned with state. Chooser/onboarding reloads after
// desktop_hosts_set; if only state.initScript is updated, dom-ready reinjects a stale
// not-configured outcome and the UI flickers on "Waiting for OpenCode".
const syncInitScriptToWindows = (initScript = state.initScript) => {
if (!initScript) return;
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.__ocInitScript = initScript;
}
}
};
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
const availability = { localAvailable };
if (envTargetUrl) {
@@ -2400,6 +2428,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
try {
const url = new URL(raw);
if (url.protocol === 'devtools:') return true;
if (url.protocol === `${UI_PROTOCOL}:`) return true;
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (state.localOrigin) {
try {
@@ -2447,8 +2476,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
});
browserWindow.webContents.on('dom-ready', () => {
const initScript = browserWindow.__ocInitScript || state.initScript;
// Prefer authoritative state script so hosts_set updates survive reloads even if a
// window still holds a pre-activation / not-configured __ocInitScript.
const initScript = state.initScript || browserWindow.__ocInitScript;
if (initScript) {
browserWindow.__ocInitScript = initScript;
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
}
});
@@ -2499,6 +2531,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig =
rendererRuntimeConfig.clientToken,
rendererRuntimeConfig.requestHeaders,
);
syncInitScriptToWindows(state.initScript);
const mainWindow = state.mainWindow;
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -2722,8 +2755,9 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
void shell.openExternal(url).catch(() => {});
});
browserWindow.webContents.on('dom-ready', () => {
const initScript = browserWindow.__ocInitScript || state.initScript;
const initScript = state.initScript || browserWindow.__ocInitScript;
if (initScript) {
browserWindow.__ocInitScript = initScript;
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
}
});
@@ -3007,6 +3041,74 @@ const buildInstalledApps = async (apps) => {
return results;
};
let linuxDesktopEntriesCache = { expiresAt: 0, entries: null };
const getLinuxDesktopEntries = async () => {
const now = Date.now();
if (linuxDesktopEntriesCache.entries && linuxDesktopEntriesCache.expiresAt > now) {
return linuxDesktopEntriesCache.entries;
}
const entries = await readLinuxDesktopEntries();
linuxDesktopEntriesCache = { entries, expiresAt: now + LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS };
return entries;
};
const buildPlatformInstalledApps = async (apps) => {
if (process.platform === 'linux') {
return buildLinuxInstalledApps(apps);
}
if (process.platform === 'win32') {
return buildWindowsInstalledApps(apps);
}
return buildInstalledApps(apps);
};
const spawnDetachedLinux = (program, args) => new Promise((resolve, reject) => {
const child = spawn(program, args, {
detached: true,
stdio: 'ignore',
});
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
callback(value);
};
child.once('error', (error) => finish(reject, error));
child.once('spawn', () => {
child.unref();
finish(resolve);
});
});
const runLinuxSpecChain = async (specs, appName) => {
if (!Array.isArray(specs) || specs.length === 0) {
throw new Error(`Failed to open in ${appName}: no launch candidates`);
}
const failures = [];
for (const spec of specs) {
if (spec.kind === 'default') {
if (spec.targetKind === 'file') {
shell.showItemInFolder(spec.targetPath);
return;
}
const errorMessage = await shell.openPath(spec.targetPath);
if (!errorMessage) return;
failures.push(`default opener: ${errorMessage}`);
continue;
}
try {
await spawnDetachedLinux(spec.program, spec.args);
return;
} catch (error) {
failures.push(`${spec.program}: ${error instanceof Error ? error.message : String(error)}`);
}
}
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
};
const parseSshConfigImports = () => {
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
if (!fs.existsSync(sshConfigPath)) return [];
@@ -3488,12 +3590,23 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
return APP_VERSION;
case 'desktop_get_launch_at_login': {
if (process.platform === 'linux') {
return { supported: true, enabled: await readLinuxAutostartEnabled() };
}
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
const settings = app.getLoginItemSettings(getLoginItemOptions());
return { supported: true, enabled: settings.openAtLogin === true };
}
case 'desktop_set_launch_at_login': {
if (process.platform === 'linux') {
const enabled = args.enabled === true;
return setLinuxAutostartEnabled({
enabled,
appName: app.getName(),
backgroundArg: BACKGROUND_START_ARG,
});
}
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
const enabled = args.enabled === true;
const settingsArgs = {
@@ -3512,7 +3625,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_set_minimize_to_tray': {
if (process.platform !== 'win32') return { supported: false, enabled: false };
if (process.platform !== 'win32' && process.platform !== 'linux') return { supported: false, enabled: false };
const enabled = args.enabled === true;
await mutateSettingsRoot((root) => {
root.desktopMinimizeToTrayEnabled = enabled;
@@ -3690,13 +3803,19 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
case 'desktop_open_path': {
const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
const appName = typeof args.app === 'string' ? args.app.trim() : '';
if (!targetPath) throw new Error('Path is required');
const validated = await validateLocalPath(targetPath);
if (process.platform === 'darwin') {
const openArgs = appName ? ['-a', appName, targetPath] : [targetPath];
const openArgs = appName ? ['-a', appName, validated.path] : [validated.path];
spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
return null;
}
await shell.openPath(targetPath);
if (appName && process.platform !== 'linux' && process.platform !== 'win32') {
throw new Error(unsupportedAppSpecificOpenError('paths'));
}
const errorMessage = await shell.openPath(validated.path);
if (errorMessage) {
throw new Error(`Failed to open path: ${errorMessage}`);
}
return null;
}
@@ -3714,18 +3833,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_reveal_path': {
const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
if (!targetPath) {
throw new Error('Path is required');
}
const stats = await fsp.stat(targetPath).catch(() => null);
if (stats?.isDirectory()) {
await shell.openPath(targetPath);
const validated = await validateLocalPath(typeof args.path === 'string' ? args.path.trim() : '');
if (validated.stats.isDirectory()) {
const errorMessage = await shell.openPath(validated.path);
if (errorMessage) {
throw new Error(`Failed to reveal path: ${errorMessage}`);
}
return null;
}
shell.showItemInFolder(targetPath);
shell.showItemInFolder(validated.path);
return null;
}
@@ -3736,19 +3853,31 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (!projectPath || !appId || !appName) {
throw new Error('Project path, app id, and app name are required');
}
const validated = await validateLocalPath(projectPath, 'Project path');
if (process.platform === 'win32') {
if (appId === 'finder') {
const error = await shell.openPath(projectPath);
const error = await shell.openPath(validated.path);
if (error) throw new Error(error);
return null;
}
runSpecChain(buildWindowsOpenProjectSpecs({ projectPath, appId, appName }), appName);
runSpecChain(buildWindowsOpenProjectSpecs({ projectPath: validated.path, appId, appName }), appName);
return null;
}
if (process.platform === 'linux') {
const entries = await getLinuxDesktopEntries();
await runLinuxSpecChain(buildLinuxOpenSpecs({
targetPath: validated.path,
appId,
appName,
targetKind: 'project',
entries,
}), appName);
return null;
}
if (process.platform !== 'darwin') {
throw new Error('desktop_open_in_app is only supported on macOS and Windows');
throw new Error(unsupportedAppSpecificOpenError('projects'));
}
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName);
runSpecChain(buildOpenProjectSpecs({ projectPath: validated.path, appId, appName }), appName);
return null;
}
@@ -3759,14 +3888,26 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (!filePath || !appId || !appName) {
throw new Error('File path, app id, and app name are required');
}
const validated = await validateLocalPath(filePath, 'File path');
if (process.platform === 'win32') {
runSpecChain(buildWindowsOpenFileSpecs({ filePath, appId, appName }), appName);
runSpecChain(buildWindowsOpenFileSpecs({ filePath: validated.path, appId, appName }), appName);
return null;
}
if (process.platform === 'linux') {
const entries = await getLinuxDesktopEntries();
await runLinuxSpecChain(buildLinuxOpenSpecs({
targetPath: validated.path,
appId,
appName,
targetKind: 'file',
entries,
}), appName);
return null;
}
if (process.platform !== 'darwin') {
throw new Error('desktop_open_file_in_app is only supported on macOS and Windows');
throw new Error(unsupportedAppSpecificOpenError('files'));
}
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName);
runSpecChain(buildOpenFileSpecs({ filePath: validated.path, appId, appName }), appName);
return null;
}
@@ -3774,8 +3915,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (process.platform === 'win32') {
return (await buildWindowsInstalledApps(args.apps)).map((app) => app.name);
}
if (process.platform === 'linux') {
return filterLinuxInstalledApps(args.apps);
}
if (process.platform !== 'darwin') {
throw new Error('desktop_filter_installed_apps is only supported on macOS');
throw new Error('desktop_filter_installed_apps is only supported on macOS, Windows, and Linux');
}
if (!Array.isArray(args.apps)) return [];
const results = await Promise.all(
@@ -3799,8 +3943,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
return results;
}
if (process.platform === 'linux') {
return fetchLinuxAppIcons(Array.isArray(args.apps) ? args.apps : []);
}
if (process.platform !== 'darwin') {
throw new Error('desktop_fetch_app_icons is only supported on macOS');
throw new Error('desktop_fetch_app_icons is only supported on macOS, Windows, and Linux');
}
const names = Array.isArray(args.apps) ? args.apps : [];
const results = [];
@@ -3825,14 +3972,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
const hasCache = Boolean(cache);
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
const refresh = async () => {
const apps = process.platform === 'win32'
? await buildWindowsInstalledApps(args.apps)
: await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
const apps = await buildPlatformInstalledApps(Array.isArray(args.apps) ? args.apps : []);
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2));
emitToAllWindows('openchamber:installed-apps-updated', apps);
};
if (process.platform !== 'darwin' && process.platform !== 'win32') {
if (process.platform !== 'darwin' && process.platform !== 'win32' && process.platform !== 'linux') {
return { apps: [], hasCache: false, isCacheStale: false, supported: false };
}
if (!hasCache || isCacheStale || args.force === true) {
@@ -3862,6 +4007,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
localAvailable: Boolean(state.sidecarUrl || state.localOrigin),
});
state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {});
syncInitScriptToWindows(state.initScript);
log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome);
return null;
}
@@ -4662,8 +4808,10 @@ const resolveTraySurface = () => {
const trayIconAssets = () => {
const dir = path.join(resourceRoot(), 'icons', 'tray');
const statusDir = path.join(dir, 'status');
if (process.platform === 'win32') {
const iconPath = getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico');
if (process.platform === 'win32' || process.platform === 'linux') {
const iconPath = process.platform === 'linux'
? (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.png'))
: (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico'));
return {
idleIconPath: iconPath,
unseenIconPath: iconPath,
@@ -4695,7 +4843,7 @@ const trayIconAssets = () => {
};
const setupTray = () => {
if (!['darwin', 'win32'].includes(process.platform) || state.trayController) return;
if (!['darwin', 'win32', 'linux'].includes(process.platform) || state.trayController) return;
if (process.platform === 'darwin' && readSettingsRoot().desktopMacMenuBarEnabled === false) return;
const assets = trayIconAssets();
if (!fs.existsSync(assets.idleIconPath)) {
@@ -4920,6 +5068,21 @@ app.whenReady().then(async () => {
});
}
if (process.platform === 'linux' && app.isPackaged) {
try {
const enabled = await readLinuxAutostartEnabled();
if (enabled) {
await setLinuxAutostartEnabled({
enabled: true,
appName: app.getName(),
backgroundArg: BACKGROUND_START_ARG,
});
}
} catch (error) {
log.warn('[electron] failed to reconcile Linux autostart entry', error);
}
}
if (isBackgroundStart) {
const { localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
state.localOrigin = localOrigin;