import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, session, shell } from 'electron';
import contextMenu from 'electron-context-menu';
import log from 'electron-log/main.js';
import dgram from 'node:dgram';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execFile, spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import updaterPkg from 'electron-updater';
import { ElectronSshManager } from './ssh-manager.mjs';
const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged;
const DEEP_LINK_PROTOCOL = 'openchamber';
const APP_USER_MODEL_ID = 'dev.openchamber.desktop';
if (!app.requestSingleInstanceLock()) {
app.exit(0);
process.exit(0);
}
// Set the product name early so electron-log derives its log directory as
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
app.setName('OpenChamber');
app.setAppUserModelId(APP_USER_MODEL_ID);
app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>');
try {
process.chdir(os.homedir());
} catch {
}
log.initialize();
log.transports.file.maxSize = 5 * 1024 * 1024;
log.transports.file.level = 'info';
log.transports.console.level = isDev ? 'debug' : 'warn';
// The in-process web server runs in this same Node process and uses plain
// `console.log/warn/error`. Without piping console through electron-log,
// that output never lands in ~/Library/Logs/OpenChamber/main.log and we
// can't diagnose issues (e.g. OpenCode lifecycle, SSE disconnects) after
// the fact. Route all console calls through electron-log so server-side
// diagnostics are persisted.
Object.assign(console, log.functions);
const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
try {
const logPath = log.transports.file.getFile().path;
const logDir = path.dirname(logPath);
const cutoff = Date.now() - LOG_MAX_AGE_MS;
for (const entry of fs.readdirSync(logDir)) {
const candidate = path.join(logDir, entry);
try {
const info = fs.statSync(candidate);
if (info.isFile() && info.mtimeMs < cutoff) {
fs.unlinkSync(candidate);
}
} catch {
}
}
} catch {
}
try {
if (!app.isDefaultProtocolClient(DEEP_LINK_PROTOCOL)) {
app.setAsDefaultProtocolClient(DEEP_LINK_PROTOCOL);
}
} catch (error) {
// log.* not yet initialized at this point; fall back to console.
console.warn('[electron] failed to register deep-link protocol:', error);
}
const readAppMetadata = () => {
const candidates = [
path.join(__dirname, 'package.json'),
path.join(__dirname, '..', 'package.json'),
path.join(app.getAppPath?.() || '', 'package.json'),
].filter(Boolean);
for (const candidate of candidates) {
try {
const raw = fs.readFileSync(candidate, 'utf8');
const parsed = JSON.parse(raw);
if (parsed?.name === '@openchamber/electron' && typeof parsed.version === 'string') {
return { name: parsed.name, version: parsed.version };
}
} catch {
}
}
return { name: '@openchamber/electron', version: app.getVersion() };
};
const APP_METADATA = readAppMetadata();
const APP_VERSION = APP_METADATA.version;
const DEFAULT_DESKTOP_PORT = 57123;
const MIN_WINDOW_WIDTH = 800;
const MIN_WINDOW_HEIGHT = 520;
const MIN_RESTORE_WINDOW_WIDTH = 900;
const MIN_RESTORE_WINDOW_HEIGHT = 560;
const LOCAL_HOST_ID = 'local';
const ENV_OVERRIDE_HOST_ID = '__env';
const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md';
const UPDATE_METADATA_URL = 'https://github.com/btriapitsyn/openchamber/releases/latest/download/latest.json';
const GITHUB_BUG_REPORT_URL = 'https://github.com/btriapitsyn/openchamber/issues/new?template=bug_report.yml';
const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/btriapitsyn/openchamber/issues/new?template=feature_request.yml';
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 { autoUpdater } = updaterPkg;
const state = {
serverHandle: null,
sidecarUrl: null,
localOrigin: null,
bootOutcome: null,
initScript: null,
mainWindow: null,
quitRequested: false,
quitConfirmed: false,
quitConfirmationPending: false,
installingUpdate: false,
quitRiskPollerStarted: false,
pendingUpdate: null,
unreachableHosts: new Set(),
windowCounter: 1,
focusedWindowIds: new Set(),
windowGeometryRevisions: new Map(),
sshStatuses: new Map(),
sshLogs: new Map(),
};
const QUIT_RISK_POLL_INTERVAL_MS = 5_000;
const quitRisk = {
hasActiveTunnel: false,
hasRunningScheduledTasks: false,
hasEnabledScheduledTasks: false,
runningScheduledTasksCount: 0,
enabledScheduledTasksCount: 0,
};
const shouldRequireQuitConfirmation = () =>
quitRisk.hasActiveTunnel
|| quitRisk.hasRunningScheduledTasks
|| quitRisk.hasEnabledScheduledTasks;
const quitConfirmationMessage = () => {
const reasons = [];
if (quitRisk.hasActiveTunnel) {
reasons.push('an active tunnel');
}
if (quitRisk.runningScheduledTasksCount > 0) {
reasons.push(`${quitRisk.runningScheduledTasksCount} running scheduled task${quitRisk.runningScheduledTasksCount === 1 ? '' : 's'}`);
}
if (quitRisk.enabledScheduledTasksCount > 0) {
reasons.push(`${quitRisk.enabledScheduledTasksCount} enabled scheduled task${quitRisk.enabledScheduledTasksCount === 1 ? '' : 's'}`);
}
if (reasons.length === 0) {
return 'Background processes (sidecar, SSH sessions) will be stopped.';
}
return `OpenChamber detected ${reasons.join(', ')}. Quitting now will stop sidecar/background processes and may interrupt pending work.`;
};
const prepareForQuit = ({ installingUpdate = false } = {}) => {
state.quitRequested = true;
state.quitConfirmed = true;
state.installingUpdate = installingUpdate;
state.quitConfirmationPending = false;
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
try {
debounceWindowStatePersist(state.mainWindow, true);
} catch {
}
}
if (!installingUpdate) {
try {
killSidecar();
} catch {
}
void sshManager.shutdownAll().catch(() => {});
}
};
const performConfirmedQuit = () => {
if (state.quitConfirmed) return;
prepareForQuit();
// Safety net: force-exit if normal quit sequence stalls (e.g. background
// handles in electron-updater / fetch refs) after a short grace period.
const safety = setTimeout(() => {
app.exit(0);
}, 1500);
if (typeof safety?.unref === 'function') safety.unref();
app.quit();
};
const requestQuitWithConfirmation = async () => {
if (!shouldRequireQuitConfirmation()) {
performConfirmedQuit();
return;
}
if (state.quitConfirmationPending) {
return;
}
state.quitConfirmationPending = true;
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
const visible = windows.find((window) => window.isVisible());
if (!visible) {
const hidden = windows.find((window) => !window.isVisible());
if (hidden) {
hidden.show();
hidden.focus();
}
}
try {
const result = await dialog.showMessageBox({
type: 'warning',
title: 'Quit OpenChamber?',
message: 'Quit OpenChamber?',
detail: quitConfirmationMessage(),
buttons: ['Quit', 'Cancel'],
defaultId: 1,
cancelId: 1,
});
state.quitConfirmationPending = false;
if (result.response === 0) {
performConfirmedQuit();
}
} catch (error) {
state.quitConfirmationPending = false;
log.warn('[electron] quit confirmation dialog failed:', error);
}
};
const refreshQuitRiskFlags = async () => {
const base = typeof state.sidecarUrl === 'string' ? state.sidecarUrl.trim().replace(/\/$/, '') : '';
if (!base) return;
const scheduledUrl = `${base}/api/openchamber/scheduled-tasks/status`;
const tunnelUrl = `${base}/api/openchamber/tunnel/status`;
const fetchJson = async (url) => {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
if (!response.ok) return null;
return await response.json();
} catch {
return null;
}
};
const [scheduled, tunnel] = await Promise.all([fetchJson(scheduledUrl), fetchJson(tunnelUrl)]);
if (scheduled && typeof scheduled === 'object') {
const enabledCount = Number(scheduled.enabledScheduledTasksCount ?? 0);
const runningCount = Number(scheduled.runningScheduledTasksCount ?? 0);
quitRisk.enabledScheduledTasksCount = Number.isFinite(enabledCount) ? enabledCount : 0;
quitRisk.runningScheduledTasksCount = Number.isFinite(runningCount) ? runningCount : 0;
quitRisk.hasEnabledScheduledTasks = Boolean(scheduled.hasEnabledScheduledTasks) || quitRisk.enabledScheduledTasksCount > 0;
quitRisk.hasRunningScheduledTasks = Boolean(scheduled.hasRunningScheduledTasks) || quitRisk.runningScheduledTasksCount > 0;
}
if (tunnel && typeof tunnel === 'object') {
quitRisk.hasActiveTunnel = Boolean(tunnel.active);
}
};
const startQuitRiskPoller = () => {
if (process.platform !== 'darwin') return;
if (state.quitRiskPollerStarted) return;
state.quitRiskPollerStarted = true;
const loop = async () => {
while (!state.quitConfirmed && !state.quitRequested) {
await refreshQuitRiskFlags();
if (state.quitConfirmed || state.quitRequested) break;
await new Promise((resolve) => {
const timer = setTimeout(resolve, QUIT_RISK_POLL_INTERVAL_MS);
if (typeof timer?.unref === 'function') timer.unref();
});
}
};
void loop();
};
const settingsFilePath = () => {
if (typeof process.env.OPENCHAMBER_DATA_DIR === 'string' && process.env.OPENCHAMBER_DATA_DIR.trim()) {
return path.join(process.env.OPENCHAMBER_DATA_DIR.trim(), 'settings.json');
}
return path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
};
const sshManager = new ElectronSshManager({
settingsFilePath: settingsFilePath(),
appVersion: APP_VERSION,
emit: (event, detail) => emitToAllWindows(event, detail),
});
const readJsonFile = (filePath) => {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
if (error && error.code === 'ENOENT') return {};
// Parse errors can happen if a concurrent writer just truncated the file
// and hasn't finished writing yet. Log loudly so we notice, then return
// {} as before. Writes are atomic (tmp + rename) so this race is rare.
log.warn?.('[electron] failed to read JSON file', filePath, error);
return {};
}
};
const writeJsonFile = async (filePath, data) => {
await fsp.mkdir(path.dirname(filePath), { recursive: true });
// Atomic: write to a temp file then rename. Readers never see a partial
// JSON file that could parse-error and get coerced to {}.
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsp.writeFile(tmp, JSON.stringify(data, null, 2));
await fsp.rename(tmp, filePath);
};
const readSettingsRoot = () => {
const root = readJsonFile(settingsFilePath());
return root && typeof root === 'object' && !Array.isArray(root) ? root : {};
};
// Serializes read-modify-write of the settings file within this process.
// Multiple call sites (spawnLocalServer, writeDesktopHostsConfig, theme
// preference saves, ssh manager imports, etc.) would otherwise have their
// RMW pairs interleave across awaits, letting one writer's stale copy
// overwrite another writer's just-persisted changes.
let settingsMutationChain = Promise.resolve();
const mutateSettingsRoot = (mutator) => {
const next = settingsMutationChain.then(async () => {
const current = readSettingsRoot();
const result = await mutator(current);
const nextRoot = result ?? current;
await writeJsonFile(settingsFilePath(), nextRoot);
});
// Keep the chain alive even if one mutator throws.
settingsMutationChain = next.catch(() => {});
return next;
};
const writeSettingsRoot = async (root) => writeJsonFile(settingsFilePath(), root);
const normalizeHostUrl = (raw) => {
const trimmed = typeof raw === 'string' ? raw.trim() : '';
if (!trimmed) return null;
try {
const parsed = new URL(trimmed);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
parsed.hash = '';
return parsed.toString();
} catch {
return null;
}
};
const sanitizeHostUrlForStorage = (raw) => normalizeHostUrl(raw);
const readDesktopHostsConfig = () => {
const root = readSettingsRoot();
const hostsRaw = Array.isArray(root.desktopHosts) ? root.desktopHosts : [];
const hosts = hostsRaw
.map((entry) => {
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
const url = sanitizeHostUrlForStorage(entry?.url);
if (!id || id === LOCAL_HOST_ID || !url) return null;
const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url;
return { id, label, url };
})
.filter(Boolean);
return {
hosts,
defaultHostId: typeof root.desktopDefaultHostId === 'string' && root.desktopDefaultHostId.trim()
? root.desktopDefaultHostId.trim()
: null,
initialHostChoiceCompleted: root.desktopInitialHostChoiceCompleted === true,
};
};
const writeDesktopHostsConfig = async (config) => {
await mutateSettingsRoot((root) => {
root.desktopHosts = Array.isArray(config?.hosts)
? config.hosts
.map((entry) => {
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
const url = sanitizeHostUrlForStorage(entry?.url);
if (!id || id === LOCAL_HOST_ID || !url) return null;
return {
id,
label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url,
url,
};
})
.filter(Boolean)
: [];
root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim()
? config.defaultHostId.trim()
: null;
if (typeof config?.initialHostChoiceCompleted === 'boolean') {
root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted;
}
});
};
const readWindowState = () => {
const stateValue = readSettingsRoot().desktopWindowState;
return stateValue && typeof stateValue === 'object' ? stateValue : null;
};
const writeWindowState = async (browserWindow) => {
if (!browserWindow || browserWindow.isDestroyed()) return;
if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return;
const bounds = browserWindow.getBounds();
await mutateSettingsRoot((root) => {
root.desktopWindowState = {
x: bounds.x,
y: bounds.y,
width: Math.max(bounds.width, MIN_WINDOW_WIDTH),
height: Math.max(bounds.height, MIN_WINDOW_HEIGHT),
maximized: browserWindow.isMaximized(),
fullscreen: browserWindow.isFullScreen(),
};
});
};
const debounceWindowStatePersist = (browserWindow, immediate = false) => {
if (!browserWindow || browserWindow.isDestroyed()) return;
const key = String(browserWindow.id);
const revision = (state.windowGeometryRevisions.get(key) || 0) + 1;
state.windowGeometryRevisions.set(key, revision);
const persist = async () => {
if (state.windowGeometryRevisions.get(key) !== revision) return;
await writeWindowState(browserWindow);
};
if (immediate) {
void persist();
return;
}
setTimeout(() => {
void persist();
}, 300);
};
const buildHealthUrl = (url) => {
try {
const parsed = new URL(url);
parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/health`;
return parsed.toString();
} catch {
return null;
}
};
const probeHostWithTimeout = async (url, timeoutMs) => {
const healthUrl = buildHealthUrl(url);
if (!healthUrl) {
throw new Error('Invalid URL');
}
const started = Date.now();
try {
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs) });
const status = response.status;
return {
status: status >= 200 && status < 300 ? 'ok' : (status === 401 || status === 403 ? 'auth' : 'unreachable'),
latencyMs: Date.now() - started,
};
} catch {
return { status: 'unreachable', latencyMs: Date.now() - started };
}
};
const waitForHealth = async (url, timeoutMs = 20_000, initialPollMs = 250, maxPollMs = 2000) => {
const deadline = Date.now() + timeoutMs;
let pollMs = initialPollMs;
while (Date.now() < deadline) {
try {
const response = await fetch(buildHealthUrl(url), { signal: AbortSignal.timeout(Math.min(pollMs * 4, 1500)) });
if (response.ok) {
return true;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
pollMs = Math.min(pollMs * 2, maxPollMs);
}
return false;
};
const pickUnusedPort = async () => {
const net = await import('node:net');
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
server.on('error', reject);
});
};
const isPortFree = async (port) => {
if (!Number.isFinite(port) || port <= 0) return false;
const net = await import('node:net');
return await new Promise((resolve) => {
const test = net.createServer();
const done = (value) => {
try { test.close(); } catch {}
resolve(value);
};
test.once('error', () => done(false));
test.listen(port, '127.0.0.1', () => done(true));
});
};
// Return the LAN IPv4 of the interface that routes to the public internet.
// UDP "connect" is a kernel-side route lookup — no packet actually goes out —
// and it picks the same interface as a real outbound connection, which is what
// a phone on the same Wi-Fi needs to reach us. Falls back to scanning
// os.networkInterfaces() if the socket trick fails (e.g. no default route).
const detectLanIPv4Address = async () => {
const ip = await new Promise((resolve) => {
const socket = dgram.createSocket('udp4');
const finish = (value) => {
try { socket.close(); } catch {}
resolve(value);
};
socket.once('error', () => finish(null));
try {
socket.connect(80, '8.8.8.8', (error) => {
if (error) return finish(null);
try {
const addr = socket.address();
finish(addr && typeof addr.address === 'string' ? addr.address : null);
} catch {
finish(null);
}
});
} catch {
finish(null);
}
});
if (ip && ip !== '0.0.0.0' && !ip.startsWith('127.')) return ip;
for (const entries of Object.values(os.networkInterfaces() || {})) {
for (const entry of entries || []) {
if (entry.family === 'IPv4' && !entry.internal && entry.address) {
return entry.address;
}
}
}
return null;
};
const buildLocalUrl = (port) => `http://127.0.0.1:${port}`;
const resourceRoot = () => isDev ? path.join(__dirname, 'resources') : process.resourcesPath;
const resolveWebDistDir = () => path.join(resourceRoot(), 'web-dist');
const normalizeNotificationInput = (raw) => {
if (!raw || typeof raw !== 'object') return {};
// UI IPC path wraps in { payload: {...} }; sidecar stdout path is flat.
if (raw.payload && typeof raw.payload === 'object') {
return { ...raw, ...raw.payload };
}
return raw;
};
const isAnyWindowFocused = () =>
BrowserWindow.getAllWindows().some(
(window) => !window.isDestroyed() && window.isFocused(),
);
const focusForegroundWindow = () => {
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
if (windows.length === 0) return;
const target = state.mainWindow && !state.mainWindow.isDestroyed()
? state.mainWindow
: windows.find((window) => window.isVisible()) || windows[0];
// macOS: bring the app to foreground FIRST. When the window is minimized
// to the Dock or hidden via Cmd+H, the app is in the background, and
// subsequent window.show/restore/focus calls won't pull it forward
// unless app.focus runs first.
if (process.platform === 'darwin') app.focus({ steal: true });
if (target.isMinimized()) target.restore();
target.show();
target.focus();
if (typeof target.moveTop === 'function') target.moveTop();
};
// Keep references to live notifications so they aren't garbage-collected
// before the OS fires click/close. On macOS, losing the JS reference causes
// click events to silently stop firing after ~1 min.
// See https://blog.bloomca.me/2025/02/22/electron-mac-notifications
const activeNotifications = new Set();
const maybeShowNativeNotification = (rawInput) => {
const payload = normalizeNotificationInput(rawInput);
const requireHidden = Boolean(payload.requireHidden ?? payload.require_hidden);
if (requireHidden && isAnyWindowFocused()) {
return;
}
if (!Notification.isSupported()) {
return;
}
const title = typeof payload.title === 'string' && payload.title.trim()
? payload.title.trim()
: 'OpenChamber';
const body = typeof payload.body === 'string' ? payload.body : '';
const sessionId = typeof payload.sessionId === 'string' && payload.sessionId.trim()
? payload.sessionId.trim()
: null;
const notification = new Notification({
title,
body,
silent: false,
...(process.platform === 'darwin' ? { sound: 'Glass' } : {}),
});
activeNotifications.add(notification);
const release = () => { activeNotifications.delete(notification); };
notification.on('click', () => {
focusForegroundWindow();
if (sessionId) {
emitToAllWindows('openchamber:open-session', { sessionId });
}
release();
});
notification.on('close', release);
notification.on('failed', release);
notification.show();
};
const mapUpdaterProgressEvent = (payload) => ({
event: payload.event,
data: payload.data,
});
const SHELL_ENV_TIMEOUT_MS = 5_000;
let cachedShellEnv = null;
let shellEnvProbed = false;
const isNushell = (shell) => {
const name = path.basename(shell).toLowerCase();
return name === 'nu' || name === 'nu.exe';
};
const parseShellEnv = (buf) => {
const result = {};
for (const line of buf.toString('utf8').split('\0')) {
if (!line) continue;
const idx = line.indexOf('=');
if (idx <= 0) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
};
const probeShellEnv = (shell, mode) => {
const result = spawnSync(shell, [mode, '-c', 'env -0'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: SHELL_ENV_TIMEOUT_MS,
windowsHide: true,
});
if (result.error || result.status !== 0) return null;
const env = parseShellEnv(result.stdout);
return Object.keys(env).length > 0 ? env : null;
};
// Finder-launched apps on macOS inherit a minimal PATH (no /opt/homebrew, mise, asdf, etc.).
// Probe the user's login shell once so the sidecar sees the same PATH / tool env as `$SHELL -il`.
const loadShellEnv = () => {
if (shellEnvProbed) return cachedShellEnv;
shellEnvProbed = true;
if (process.platform === 'win32') return null;
const shell = process.env.SHELL || '/bin/sh';
if (isNushell(shell)) return null;
cachedShellEnv = probeShellEnv(shell, '-il') || probeShellEnv(shell, '-l');
return cachedShellEnv;
};
// Merge the user's login-shell env (PATH, etc.) into this process before we
import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js';
// import/start the server in-process. The server and its children (opencode
// CLI, git, etc.) inherit process.env directly now — there is no sidecar
// subprocess to hand a custom env to.
const inheritUserShellEnv = () => {
const shellEnv = loadShellEnv();
if (!shellEnv) return;
const homeDir = os.homedir();
const currentPath = process.env.PATH || '';
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, ':');
for (const [key, value] of Object.entries(shellEnv)) {
if (key === 'PATH') continue;
if (typeof process.env[key] === 'undefined') {
process.env[key] = value;
}
}
const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : '';
if (!currentPathLooksUserConfigured && shellPath) {
process.env.PATH = mergePathValues(shellPath, currentPath, ':');
}
};
const spawnLocalServer = async () => {
inheritUserShellEnv();
const settings = readSettingsRoot();
const storedPort = Number.isFinite(settings.desktopLocalPort) ? settings.desktopLocalPort : null;
// When the user enables "Desktop Network Access" we bind on all interfaces
// so phones/tablets on the same Wi-Fi can reach the app. UI shows a clear
// warning and persists the flag via /api/config/settings.
const lanAccessEnabled = settings.desktopLanAccessEnabled === true;
const bindHost = lanAccessEnabled ? '0.0.0.0' : '127.0.0.1';
// Probe before starting the server — main() in the server module sets up a
// lot of global state before binding, and calling it twice after a listen
// failure would double-wire runtimes. Pick a known-free port in one shot.
const candidates = [storedPort, DEFAULT_DESKTOP_PORT].filter((v) => Number.isFinite(v) && v > 0);
let chosenPort = 0;
for (const candidate of candidates) {
if (await isPortFree(candidate)) {
chosenPort = candidate;
break;
}
}
if (chosenPort === 0) {
chosenPort = await pickUnusedPort();
}
// The server module reads ENV_DESKTOP_NOTIFY / OPENCHAMBER_DIST_DIR /
// OPENCHAMBER_RUNTIME at import time (top-level const), so these must be
// set before the first import. After this point, the same env is used by
// both the Electron main and the server running inside it.
process.env.OPENCHAMBER_HOST = bindHost;
process.env.OPENCHAMBER_DIST_DIR = resolveWebDistDir();
process.env.OPENCHAMBER_RUNTIME = 'desktop';
process.env.OPENCHAMBER_DESKTOP_NOTIFY = 'true';
process.env.NO_PROXY = process.env.NO_PROXY || 'localhost,127.0.0.1';
process.env.no_proxy = process.env.no_proxy || 'localhost,127.0.0.1';
const { startWebUiServer } = await import('@openchamber/web/server/index.js');
const handle = await startWebUiServer({
port: chosenPort,
host: bindHost,
attachSignals: false,
exitOnShutdown: false,
onDesktopNotification: (payload) => maybeShowNativeNotification(payload),
});
const port = handle.getPort();
const url = buildLocalUrl(port);
state.serverHandle = handle;
state.sidecarUrl = url;
await mutateSettingsRoot((root) => {
root.desktopLocalPort = port;
});
return url;
};
const killSidecar = () => {
if (state.serverHandle) {
try {
const result = state.serverHandle.stop({ exitProcess: false });
if (result && typeof result.then === 'function') {
result.catch(() => {});
}
} catch {
}
state.serverHandle = null;
}
state.sidecarUrl = null;
};
const macosMajorVersion = () => {
if (process.platform !== 'darwin') return 0;
const result = spawnSync('/usr/bin/sw_vers', ['-productVersion'], { encoding: 'utf8' });
const raw = (result.stdout || '').trim();
const [majorRaw, minorRaw] = raw.split('.');
const major = Number.parseInt(majorRaw || '0', 10);
const minor = Number.parseInt(minorRaw || '0', 10);
return major === 10 ? minor : major;
};
const buildInitScript = (localOrigin, bootOutcome) => {
const home = JSON.stringify(os.homedir() || '');
const local = JSON.stringify(localOrigin || '');
const macVersion = macosMajorVersion();
const outcome = JSON.stringify(bootOutcome ?? null);
return [
'(function(){',
`try{window.__OPENCHAMBER_HOME__=${home};window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=${local};var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`,
'}())',
].join('');
};
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
if (envTargetUrl) {
const status = probe && probe.status === 'unreachable' ? 'unreachable' : 'ok';
return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl };
}
const defaultId = config.defaultHostId || '';
if (!defaultId) {
return { target: null, status: 'not-configured' };
}
if (defaultId === LOCAL_HOST_ID) {
return localAvailable
? { target: 'local', status: 'ok' }
: { target: 'local', status: 'unreachable' };
}
const host = config.hosts.find((entry) => entry.id === defaultId);
if (!host) {
return { target: 'remote', status: 'missing', hostId: defaultId };
}
const status = probe && probe.status === 'unreachable' ? 'unreachable' : 'ok';
return { target: 'remote', status, hostId: host.id, url: host.url };
};
const buildStartupSplashHtml = () => {
const settings = readSettingsRoot();
const splashBgLight = typeof settings.splashBgLight === 'string' ? settings.splashBgLight.trim() : '#f5f5f4';
const splashFgLight = typeof settings.splashFgLight === 'string' ? settings.splashFgLight.trim() : '#1c1917';
const splashBgDark = typeof settings.splashBgDark === 'string' ? settings.splashBgDark.trim() : '#0c0a09';
const splashFgDark = typeof settings.splashFgDark === 'string' ? settings.splashFgDark.trim() : '#fafaf9';
return `
OpenChamber
`;
};
const isBenignNavigationAbort = (error) => {
if (!error || typeof error !== 'object') {
return false;
}
if (error.errno === -3) {
return true;
}
const message = typeof error.message === 'string' ? error.message : '';
return message.includes('ERR_ABORTED') || message.includes(' (-3) loading ');
};
const navigateWindow = async (browserWindow, url, { allowAbort = false } = {}) => {
try {
await browserWindow.loadURL(url);
} catch (error) {
if (allowAbort && isBenignNavigationAbort(error)) {
return;
}
throw error;
}
};
const emitToWindow = (browserWindow, event, detail) => {
if (!browserWindow || browserWindow.isDestroyed()) return;
browserWindow.webContents.send('openchamber:emit', { event, detail });
};
const emitToAllWindows = (event, detail) => {
for (const browserWindow of BrowserWindow.getAllWindows()) {
emitToWindow(browserWindow, event, detail);
}
};
const pendingDeepLinks = [];
const parseDeepLink = (raw) => {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.protocol !== `${DEEP_LINK_PROTOCOL}:`) return null;
const type = url.hostname;
if (!type) return null;
const segments = url.pathname.split('/').filter(Boolean);
const value = segments.length > 0
? decodeURIComponent(segments.join('/'))
: '';
return { type, value };
} catch {
return null;
}
};
const switchToHostById = async (rawId) => {
const id = typeof rawId === 'string' ? rawId.trim() : '';
if (!id) return;
const config = readDesktopHostsConfig();
let targetUrl = null;
if (id === LOCAL_HOST_ID) {
targetUrl = state.sidecarUrl || state.localOrigin;
} else {
const host = config.hosts.find((entry) => entry.id === id);
if (!host) {
log.warn('[electron] deep-link host not found:', id);
return;
}
targetUrl = host.url;
}
if (!targetUrl) {
log.warn('[electron] deep-link host has no target URL:', id);
return;
}
const bootOutcome = id === LOCAL_HOST_ID
? { target: 'local', status: 'ok' }
: { target: 'remote', status: 'ok', hostId: id, url: targetUrl };
log.info('[electron] switching to host', { id, bootOutcome });
await activateMainWindow(targetUrl, state.localOrigin, bootOutcome);
};
const dispatchDeepLink = (link) => {
if (!link) return;
log.info('[electron] dispatching deep-link', { type: link.type, valueLen: link.value?.length || 0 });
if (link.type === 'session' && link.value) {
emitToAllWindows('openchamber:open-session', { sessionId: link.value });
return;
}
if (link.type === 'project' && link.value) {
emitToAllWindows('openchamber:open-project', { projectPath: link.value });
return;
}
if (link.type === 'host' && link.value) {
void switchToHostById(link.value);
return;
}
log.warn('[electron] unknown deep-link action:', link.type);
};
const flushPendingDeepLinks = () => {
while (pendingDeepLinks.length > 0) {
dispatchDeepLink(pendingDeepLinks.shift());
}
};
const isMainWindowReadyForDeepLink = () =>
Boolean(state.mainWindow)
&& !state.mainWindow.isDestroyed()
&& !state.mainWindow.webContents.isLoading();
const handleDeepLinks = (urls) => {
for (const raw of urls) {
const parsed = parseDeepLink(raw);
if (!parsed) continue;
if (isMainWindowReadyForDeepLink()) {
dispatchDeepLink(parsed);
} else {
pendingDeepLinks.push(parsed);
}
}
};
const extractInitialDeepLinks = () =>
process.argv.filter((arg) => typeof arg === 'string' && arg.startsWith(`${DEEP_LINK_PROTOCOL}://`));
const dispatchDomEventToWindow = (browserWindow, event, detail) => {
if (!browserWindow || browserWindow.isDestroyed()) return;
const eventLiteral = JSON.stringify(event);
const script = detail === undefined
? `window.dispatchEvent(new Event(${eventLiteral}));`
: `window.dispatchEvent(new CustomEvent(${eventLiteral}, { detail: ${JSON.stringify(detail)} }));`;
void browserWindow.webContents.executeJavaScript(script, true).catch(() => {});
};
const getMenuTargetWindow = () => {
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused;
if (state.mainWindow && !state.mainWindow.isDestroyed()) return state.mainWindow;
const [firstWindow] = BrowserWindow.getAllWindows();
return firstWindow && !firstWindow.isDestroyed() ? firstWindow : null;
};
const dispatchMenuAction = (action) => {
const target = getMenuTargetWindow();
emitToWindow(target, 'openchamber:menu-action', action);
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
};
const dispatchCheckForUpdates = () => {
emitToAllWindows('openchamber:check-for-updates');
for (const browserWindow of BrowserWindow.getAllWindows()) {
dispatchDomEventToWindow(browserWindow, 'openchamber:check-for-updates');
}
};
const nextWindowLabel = () => {
const value = state.windowCounter++;
return value === 1 ? 'main' : `main-${value}`;
};
const readThemeSource = () => {
const settings = readSettingsRoot();
// themeMode is the user's intent; themeVariant is only the resolved
// concrete appearance at persist time. When mode === 'system', we must
// follow the OS even if variant was saved as a specific value.
if (settings.themeMode === 'system' || settings.useSystemTheme === true) return 'system';
if (settings.themeMode === 'light') return 'light';
if (settings.themeMode === 'dark') return 'dark';
if (settings.themeVariant === 'light') return 'light';
if (settings.themeVariant === 'dark') return 'dark';
return 'system';
};
const createBrowserWindow = ({ label, restoreGeometry, url }) => {
const saved = restoreGeometry ? readWindowState() : null;
const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number';
const desktopLocalOrigin = state.localOrigin || '';
const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion());
const options = {
title: 'OpenChamber',
width: useSaved ? Math.max(saved.width, MIN_RESTORE_WINDOW_WIDTH) : 1280,
height: useSaved ? Math.max(saved.height, MIN_RESTORE_WINDOW_HEIGHT) : 800,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
show: false,
backgroundColor: '#151313',
// Tauri used an overlay title bar with explicit traffic-light placement.
// Electron's hiddenInset adds its own extra inset, which leaves the controls
// visibly lower than the app header. Use a plain hidden title bar instead.
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
webPreferences: {
additionalArguments: [
`--openchamber-local-origin=${desktopLocalOrigin}`,
`--openchamber-home=${desktopHome}`,
`--openchamber-macos-major=${desktopMacosMajor}`,
`--openchamber-boot-outcome=${JSON.stringify(state.bootOutcome || null)}`,
],
preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'),
backgroundThrottling: true,
contextIsolation: true,
nodeIntegration: false,
// sandbox must stay off: the preload uses contextBridge + ipcRenderer
// from Electron's Node layer. contextIsolation + nodeIntegration:false
// keep the renderer world walled off from Node. Do NOT flip to true —
// the preload would fail to load and __TAURI__ would go undefined.
sandbox: false,
},
};
const browserWindow = new BrowserWindow(options);
browserWindow.__ocLabel = label || nextWindowLabel();
if (useSaved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
browserWindow.setPosition(saved.x, saved.y);
}
if (useSaved && saved.maximized) {
browserWindow.maximize();
}
browserWindow.on('focus', () => {
state.focusedWindowIds.add(browserWindow.id);
});
browserWindow.on('blur', () => {
state.focusedWindowIds.delete(browserWindow.id);
});
// Traffic lights disappear during dock-restore animation when using
// titleBarStyle:'hidden' + custom trafficLightPosition. macOS caches a
// snapshot of the window at miniaturize time and plays it during the
// genie-restore animation. We re-assert button position on 'minimize'
// (before the snapshot) and 'restore'/'show'/'focus' to cover other
// transient reset states AppKit puts the buttons in.
if (process.platform === 'darwin') {
const refreshTrafficLights = () => {
if (browserWindow.isDestroyed()) return;
try {
browserWindow.setWindowButtonVisibility(true);
browserWindow.setTrafficLightPosition({ x: 16, y: 17 });
} catch {}
};
browserWindow.on('minimize', refreshTrafficLights);
browserWindow.on('restore', () => {
refreshTrafficLights();
setTimeout(refreshTrafficLights, 250);
});
browserWindow.on('show', refreshTrafficLights);
browserWindow.on('focus', refreshTrafficLights);
}
browserWindow.on('resize', () => {
emitToWindow(browserWindow, 'openchamber:window-resized');
debounceWindowStatePersist(browserWindow, false);
});
browserWindow.on('move', () => {
debounceWindowStatePersist(browserWindow, false);
});
browserWindow.on('close', (event) => {
if (process.platform === 'darwin' && !state.quitRequested) {
const remainingVisible = BrowserWindow.getAllWindows().filter(
(window) => !window.isDestroyed() && window.isVisible(),
).length;
if (remainingVisible <= 1) {
debounceWindowStatePersist(browserWindow, true);
event.preventDefault();
browserWindow.hide();
return;
}
}
debounceWindowStatePersist(browserWindow, true);
});
browserWindow.on('closed', () => {
state.focusedWindowIds.delete(browserWindow.id);
if (state.mainWindow && browserWindow.id === state.mainWindow.id) {
state.mainWindow = null;
}
if (BrowserWindow.getAllWindows().length === 0) {
if (!state.installingUpdate) {
killSidecar();
}
if (process.platform !== 'darwin') {
app.quit();
}
}
});
// Any navigation target that isn't our own UI (local server / configured
// desktop hosts) should open in the user's default browser, not spawn
// another Electron window loading arbitrary web content.
const isAllowedNavigationUrl = (raw) => {
try {
const url = new URL(raw);
if (url.protocol === 'file:' || url.protocol === 'about:' || url.protocol === 'devtools:') return true;
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
const hostname = url.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
if (state.localOrigin) {
try {
if (new URL(state.localOrigin).origin === url.origin) return true;
} catch {
}
}
const hosts = readDesktopHostsConfig()?.hosts || [];
for (const entry of hosts) {
if (typeof entry?.url !== 'string') continue;
try {
if (new URL(entry.url).origin === url.origin) return true;
} catch {
}
}
return false;
} catch {
return false;
}
};
browserWindow.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedNavigationUrl(url)) {
return { action: 'allow' };
}
void shell.openExternal(url).catch(() => {});
return { action: 'deny' };
});
browserWindow.webContents.on('will-navigate', (event, url) => {
if (isAllowedNavigationUrl(url)) return;
event.preventDefault();
void shell.openExternal(url).catch(() => {});
});
browserWindow.webContents.setZoomFactor(1);
browserWindow.webContents.on('zoom-changed', () => {
browserWindow.webContents.setZoomFactor(1);
});
browserWindow.webContents.on('dom-ready', () => {
if (state.initScript) {
void browserWindow.webContents.executeJavaScript(state.initScript).catch(() => {});
}
});
browserWindow.webContents.on('did-finish-load', () => {
browserWindow.webContents.setZoomFactor(1);
if (state.mainWindow && browserWindow.id === state.mainWindow.id && pendingDeepLinks.length > 0) {
const timer = setTimeout(flushPendingDeepLinks, 400);
if (typeof timer?.unref === 'function') timer.unref();
}
});
browserWindow.once('ready-to-show', () => {
browserWindow.show();
browserWindow.focus();
});
if (url) {
void navigateWindow(browserWindow, url);
} else {
void navigateWindow(
browserWindow,
`data:text/html;charset=utf-8,${encodeURIComponent(buildStartupSplashHtml())}`,
{ allowAbort: true },
);
}
return browserWindow;
};
const activateMainWindow = async (url, localOrigin, bootOutcome) => {
state.localOrigin = localOrigin;
state.bootOutcome = bootOutcome ?? null;
state.initScript = buildInitScript(localOrigin, state.bootOutcome);
const mainWindow = state.mainWindow;
if (mainWindow && !mainWindow.isDestroyed()) {
await navigateWindow(mainWindow, url, { allowAbort: true });
mainWindow.show();
mainWindow.focus();
return mainWindow;
}
state.mainWindow = createBrowserWindow({
label: 'main',
restoreGeometry: true,
url,
});
return state.mainWindow;
};
const createAdditionalWindow = async (url) => {
if (!state.localOrigin) {
return null;
}
const browserWindow = createBrowserWindow({
label: nextWindowLabel(),
restoreGeometry: false,
url,
});
return browserWindow;
};
const resolveInitialUrl = async () => {
const localUrl = isDev && await waitForHealth('http://127.0.0.1:3901', 5_000, 100)
? 'http://127.0.0.1:3901'
: await spawnLocalServer();
const localUiUrl = isDev && await waitForHealth('http://127.0.0.1:5173', 8_000, 100)
? 'http://127.0.0.1:5173'
: localUrl;
state.sidecarUrl = localUrl;
const localAvailable = Boolean(localUrl);
const localOrigin = new URL(localUiUrl).origin;
let initialUrl = localUiUrl;
let remoteProbe = null;
const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || '');
const config = readDesktopHostsConfig();
if (envTarget) {
initialUrl = envTarget;
} else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) {
const host = config.hosts.find((entry) => entry.id === config.defaultHostId);
if (host?.url) {
initialUrl = host.url;
}
}
if (initialUrl !== localUiUrl) {
remoteProbe = await probeHostWithTimeout(initialUrl, 2_000);
if (remoteProbe.status === 'unreachable') {
remoteProbe = await probeHostWithTimeout(initialUrl, 10_000);
}
if (remoteProbe.status === 'unreachable') {
state.unreachableHosts.add(initialUrl);
initialUrl = localUiUrl;
}
}
const bootOutcome = computeBootOutcome({
envTargetUrl: envTarget || null,
probe: remoteProbe,
config,
localAvailable,
});
return { initialUrl, localOrigin, localUiUrl, bootOutcome };
};
const compareSemver = (left, right) => {
const a = String(left || '').replace(/^v/, '').split('.').map((value) => Number.parseInt(value || '0', 10));
const b = String(right || '').replace(/^v/, '').split('.').map((value) => Number.parseInt(value || '0', 10));
const length = Math.max(a.length, b.length);
for (let index = 0; index < length; index += 1) {
const diff = (a[index] || 0) - (b[index] || 0);
if (diff !== 0) return diff;
}
return 0;
};
const parseGithubRepo = () => {
return { owner: 'btriapitsyn', repo: 'openchamber' };
};
const setupAutoUpdater = () => {
if (!app.isPackaged) {
return;
}
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.allowPrerelease = false;
autoUpdater.fullChangelog = true;
autoUpdater.disableWebInstaller = false;
autoUpdater.logger = log;
const { owner, repo } = parseGithubRepo();
autoUpdater.setFeedURL({
provider: 'github',
owner,
repo,
});
autoUpdater.on('download-progress', (progress) => {
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Progress',
data: {
chunkLength: Math.max(0, Math.round(progress.bytesPerSecond || 0)),
downloaded: Math.round(progress.transferred || 0),
total: Math.round(progress.total || 0),
},
}));
});
autoUpdater.on('update-downloaded', (info) => {
log.info(`[electron] update-downloaded version=${info?.version || 'unknown'}`);
if (state.pendingUpdate) {
state.pendingUpdate.downloaded = true;
}
});
autoUpdater.on('error', (err) => {
log.error('[electron] autoUpdater error', err);
});
};
const parseRelevantChangelogNotes = async (fromVersion, toVersion) => {
try {
const response = await fetch(CHANGELOG_URL, { signal: AbortSignal.timeout(10_000) });
if (!response.ok) return null;
const changelog = await response.text();
const sections = changelog.split(/^##\s+\[/m).slice(1);
const relevant = [];
for (const section of sections) {
const version = section.split(']')[0];
if (compareSemver(version, fromVersion) > 0 && compareSemver(version, toVersion) <= 0) {
relevant.push(`## [${section}`.trim());
}
}
return relevant.length > 0 ? relevant.join('\n\n') : null;
} catch {
return null;
}
};
const buildInstalledAppsCachePath = () => path.join(path.dirname(settingsFilePath()), INSTALLED_APPS_CACHE_FILE);
// Async variants. sips + mdfind via spawnSync blocked the Electron main event
// loop for 2-3s on boot (22 OPEN_IN_APPS × ~200 ms each). Use execFile promises
// so each child-process wait yields to the loop and the UI stays responsive.
const pathExists = async (candidate) => {
try {
await fsp.access(candidate);
return true;
} catch {
return false;
}
};
const resolveAppBundlePath = async (appName) => {
if (process.platform !== 'darwin') return null;
const bundleName = appName.endsWith('.app') ? appName : `${appName}.app`;
const candidates = [
`/Applications/${bundleName}`,
`/System/Applications/${bundleName}`,
`/System/Applications/Utilities/${bundleName}`,
path.join(os.homedir(), 'Applications', bundleName),
];
for (const candidate of candidates) {
if (await pathExists(candidate)) return candidate;
}
try {
const { stdout } = await execFileAsync('mdfind', ['-name', bundleName], { encoding: 'utf8' });
const first = (stdout || '').split('\n').map((line) => line.trim()).find(Boolean);
return first || null;
} catch {
return null;
}
};
const isAppBundleInstalled = async (appName) => Boolean(await resolveAppBundlePath(appName));
const iconToDataUrl = async (iconPath, appName) => {
if (!iconPath || !(await pathExists(iconPath))) return null;
const safeName = String(appName || 'app').replace(/[^a-z0-9]/gi, '_');
const tempPath = path.join(os.tmpdir(), `openchamber-icon-${safeName}-${Date.now()}.png`);
try {
await execFileAsync('sips', ['-s', 'format', 'png', '-Z', '32', iconPath, '--out', tempPath], { stdio: 'ignore' });
} catch {
return null;
}
if (!(await pathExists(tempPath))) return null;
try {
const bytes = await fsp.readFile(tempPath);
return `data:image/png;base64,${bytes.toString('base64')}`;
} finally {
await fsp.rm(tempPath, { force: true }).catch(() => {});
}
};
const resolveAppIconPath = async (appPath) => {
if (!appPath || !(await pathExists(appPath))) return null;
const resourcesPath = path.join(appPath, 'Contents', 'Resources');
if (!(await pathExists(resourcesPath))) return null;
let entries;
try {
entries = await fsp.readdir(resourcesPath);
} catch {
return null;
}
const icon = entries.find((entry) => entry.toLowerCase().endsWith('.icns'));
return icon ? path.join(resourcesPath, icon) : null;
};
const buildInstalledApps = async (apps) => {
const seen = new Set();
const names = apps
.map((raw) => String(raw || '').trim())
.filter((raw) => raw && !seen.has(raw) && seen.add(raw));
const results = [];
for (const name of names) {
const appPath = await resolveAppBundlePath(name);
if (!appPath) continue;
const iconDataUrl = await iconToDataUrl(await resolveAppIconPath(appPath), name);
results.push({ name, iconDataUrl });
}
return results;
};
const parseSshConfigImports = () => {
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
if (!fs.existsSync(sshConfigPath)) return [];
const lines = fs.readFileSync(sshConfigPath, 'utf8').split(/\r?\n/);
const results = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || !trimmed.toLowerCase().startsWith('host ')) {
continue;
}
const hosts = trimmed.slice(5).trim().split(/\s+/).filter(Boolean);
for (const host of hosts) {
results.push({
host,
pattern: /[*?]/.test(host),
source: sshConfigPath,
sshCommand: `ssh ${host}`,
});
}
}
return results;
};
const readDesktopSshInstances = () => {
const root = readSettingsRoot();
return { instances: Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : [] };
};
const writeDesktopSshInstances = async (config) => {
const nextInstances = Array.isArray(config?.instances) ? config.instances : [];
await mutateSettingsRoot((root) => {
root.desktopSshInstances = nextInstances;
});
return { instances: nextInstances };
};
const updateHostUrlForSshInstance = async (id, label, localUrl) => {
const config = readDesktopHostsConfig();
const nextHosts = config.hosts.filter((entry) => entry.id !== id);
nextHosts.push({ id, label, url: localUrl });
await writeDesktopHostsConfig({ hosts: nextHosts, defaultHostId: config.defaultHostId });
};
const JETBRAINS_APP_IDS = new Set([
'pycharm',
'intellij',
'webstorm',
'phpstorm',
'rider',
'rustrover',
'android-studio',
]);
const CLI_BY_APP_ID = {
vscode: 'code',
cursor: 'cursor',
vscodium: 'codium',
windsurf: 'windsurf',
zed: 'zed',
};
const buildOpenProjectSpecs = ({ projectPath, appId, appName }) => {
if (appId === 'finder') {
return [{ program: 'open', args: [projectPath] }];
}
if (appId === 'terminal' || appId === 'iterm2' || appId === 'ghostty') {
return [{ program: 'open', args: ['-a', appName, projectPath] }];
}
const specs = [];
const cli = CLI_BY_APP_ID[appId];
if (cli) {
specs.push({ program: cli, args: ['-n', projectPath] });
}
if (JETBRAINS_APP_IDS.has(appId)) {
specs.push({ program: 'open', args: ['-na', appName, '--args', projectPath] });
}
specs.push({ program: 'open', args: ['-a', appName, projectPath] });
return specs;
};
const buildOpenFileSpecs = ({ filePath, appId, appName }) => {
if (appId === 'finder') {
return [{ program: 'open', args: ['-R', filePath] }];
}
const parentDir = path.dirname(filePath);
if (appId === 'terminal' || appId === 'iterm2' || appId === 'ghostty') {
return [{ program: 'open', args: ['-a', appName, parentDir] }];
}
const specs = [];
const cli = CLI_BY_APP_ID[appId];
if (cli) {
specs.push({ program: cli, args: [filePath] });
}
specs.push({ program: 'open', args: ['-a', appName, filePath] });
return specs;
};
const runSpecChain = (specs, appName) => {
const failures = [];
for (const spec of specs) {
const result = spawnSync(spec.program, spec.args, { stdio: 'ignore' });
if (result.error) {
failures.push(`${spec.program}: ${result.error.message}`);
continue;
}
if (result.status === 0) {
return;
}
failures.push(`${spec.program} exited ${result.status}`);
}
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
};
const handleInvoke = async (browserWindow, command, args = {}) => {
switch (command) {
case 'desktop_start_window_drag':
return null;
case 'desktop_is_window_fullscreen':
return Boolean(browserWindow?.isFullScreen());
case 'desktop_set_window_title':
if (browserWindow && typeof args.title === 'string') {
browserWindow.setTitle(args.title);
}
return null;
case 'desktop_get_app_version':
return APP_VERSION;
case 'desktop_save_markdown_file': {
const defaultPath = typeof args.defaultFileName === 'string' ? args.defaultFileName.trim() : '';
if (!defaultPath) {
throw new Error('Default file name is required');
}
const content = typeof args.content === 'string' ? args.content : '';
const result = await dialog.showSaveDialog(browserWindow || undefined, {
defaultPath,
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (result.canceled || !result.filePath) {
return null;
}
await fsp.writeFile(result.filePath, content, 'utf8');
return result.filePath;
}
case 'desktop_read_file': {
const rawPath = typeof args.path === 'string' ? args.path : '';
if (!rawPath) throw new Error('Path is required');
// Defense in depth behind the IPC origin gate: even our own UI (or a
// prompt-injected agent) can't read credential stores. Resolve the
// path, require it under $HOME or tmpdir, and refuse known secret dirs
// / dotfiles commonly holding keys.
const filePath = path.resolve(rawPath);
const home = os.homedir() || '';
const tmp = os.tmpdir() || '';
const underHome = home && (filePath === home || filePath.startsWith(home + path.sep));
const underTmp = tmp && (filePath === tmp || filePath.startsWith(tmp + path.sep));
if (!underHome && !underTmp) {
throw new Error('File is outside the allowed workspace');
}
const DENIED_SEGMENTS = ['.ssh', '.aws', '.gnupg', '.gpg', '.config/gh', '.config/openchamber/credentials'];
const relFromHome = underHome ? filePath.slice(home.length + 1) : '';
const relNormalized = relFromHome.split(path.sep).join('/');
if (DENIED_SEGMENTS.some((segment) => relNormalized === segment || relNormalized.startsWith(`${segment}/`))) {
throw new Error('Access to this path is not allowed');
}
const basename = path.basename(filePath).toLowerCase();
if (basename === '.env' || basename.startsWith('.env.') || basename.endsWith('.pem') || basename.endsWith('.key')) {
throw new Error('Access to this path is not allowed');
}
const stats = await fsp.stat(filePath);
if (stats.size > 50 * 1024 * 1024) {
throw new Error('File is too large. Maximum size is 50MB.');
}
const bytes = await fsp.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = ({
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.txt': 'text/plain',
'.md': 'text/markdown',
'.json': 'application/json',
'.js': 'text/javascript',
'.ts': 'text/typescript',
'.tsx': 'text/typescript-jsx',
'.jsx': 'text/javascript-jsx',
'.html': 'text/html',
'.css': 'text/css',
'.py': 'text/x-python',
})[ext] || 'application/octet-stream';
return { mime, base64: bytes.toString('base64'), size: bytes.length };
}
case 'desktop_notify':
maybeShowNativeNotification(args);
return null;
case 'desktop_clear_cache':
await session.defaultSession.clearStorageData();
for (const browserWindow of BrowserWindow.getAllWindows()) {
browserWindow.webContents.reload();
}
return null;
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');
if (process.platform === 'darwin') {
const openArgs = appName ? ['-a', appName, targetPath] : [targetPath];
spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
return null;
}
await shell.openPath(targetPath);
return null;
}
case 'desktop_open_external_url': {
const target = typeof args.url === 'string' ? args.url.trim() : '';
if (!target) throw new Error('URL is required');
const parsed = new URL(target);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Only HTTP URLs can be opened externally');
}
await shell.openExternal(parsed.toString());
return null;
}
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);
return null;
}
shell.showItemInFolder(targetPath);
return null;
}
case 'desktop_open_in_app': {
if (process.platform !== 'darwin') {
throw new Error('desktop_open_in_app is only supported on macOS');
}
const projectPath = typeof args.projectPath === 'string' ? args.projectPath.trim() : '';
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
if (!projectPath || !appId || !appName) {
throw new Error('Project path, app id, and app name are required');
}
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName);
return null;
}
case 'desktop_open_file_in_app': {
if (process.platform !== 'darwin') {
throw new Error('desktop_open_file_in_app is only supported on macOS');
}
const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : '';
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
if (!filePath || !appId || !appName) {
throw new Error('File path, app id, and app name are required');
}
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName);
return null;
}
case 'desktop_filter_installed_apps': {
if (process.platform !== 'darwin') {
throw new Error('desktop_filter_installed_apps is only supported on macOS');
}
if (!Array.isArray(args.apps)) return [];
const results = await Promise.all(
args.apps.map(async (appName) => (await isAppBundleInstalled(String(appName))) ? String(appName) : null)
);
return results.filter(Boolean);
}
case 'desktop_fetch_app_icons': {
if (process.platform !== 'darwin') {
throw new Error('desktop_fetch_app_icons is only supported on macOS');
}
const names = Array.isArray(args.apps) ? args.apps : [];
const results = [];
for (const name of names) {
const appPath = await resolveAppBundlePath(String(name));
if (!appPath) continue;
const dataUrl = await iconToDataUrl(await resolveAppIconPath(appPath), String(name));
if (dataUrl) results.push({ app: String(name), dataUrl });
}
return results;
}
case 'desktop_get_installed_apps': {
if (process.platform !== 'darwin') {
throw new Error('desktop_get_installed_apps is only supported on macOS');
}
const cachePath = buildInstalledAppsCachePath();
const now = Math.floor(Date.now() / 1000);
let cache = null;
try {
cache = JSON.parse(await fsp.readFile(cachePath, 'utf8'));
} catch {
}
const cachedApps = Array.isArray(cache?.apps) ? cache.apps : [];
const hasCache = Boolean(cache);
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
const refresh = async () => {
const apps = await buildInstalledApps(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 (!hasCache || isCacheStale || args.force === true) {
void refresh();
}
return { apps: cachedApps, hasCache, isCacheStale };
}
case 'desktop_hosts_get':
return readDesktopHostsConfig();
case 'desktop_hosts_set': {
await writeDesktopHostsConfig(args.input || args.config || {});
const updatedConfig = readDesktopHostsConfig();
const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || '');
state.bootOutcome = computeBootOutcome({
envTargetUrl: envTarget || null,
probe: null,
config: updatedConfig,
localAvailable: Boolean(state.sidecarUrl || state.localOrigin),
});
state.initScript = buildInitScript(state.localOrigin, state.bootOutcome);
log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome);
return null;
}
case 'desktop_host_probe':
return probeHostWithTimeout(String(args.url || ''), 2_000);
case 'desktop_set_window_theme': {
const mode = typeof args.themeMode === 'string' ? args.themeMode : '';
const variant = typeof args.themeVariant === 'string' ? args.themeVariant : '';
// Priority order: themeMode expresses the user's intent (including
// "follow OS"). Variant is just the resolved variant at send time;
// when mode === 'system' with variant === 'dark' (because OS is
// currently dark), we must still pin themeSource to 'system' so
// Chromium keeps reacting to OS theme changes.
if (mode === 'system') {
nativeTheme.themeSource = 'system';
} else if (mode === 'light') {
nativeTheme.themeSource = 'light';
} else if (mode === 'dark') {
nativeTheme.themeSource = 'dark';
} else if (variant === 'light') {
nativeTheme.themeSource = 'light';
} else if (variant === 'dark') {
nativeTheme.themeSource = 'dark';
} else {
nativeTheme.themeSource = 'system';
}
return null;
}
case 'desktop_set_vibrancy': {
// Vibrancy (macOS blur) is not supported in the Electron shell — the
// Tauri build used NSVisualEffectView via Tauri plugin, Electron has
// no equivalent for our titleBarStyle:'hidden' setup. Persist the
// disabled state so settings UI reflects it; args.enabled is ignored.
await mutateSettingsRoot((root) => {
root.desktopVibrancy = false;
});
return { enabled: false, requiresRestart: false };
}
case 'desktop_check_for_updates': {
const currentVersion = APP_VERSION;
let payload = null;
try {
const response = await fetch(UPDATE_METADATA_URL, { signal: AbortSignal.timeout(10_000) });
payload = await response.json();
} catch {
}
let updateResult = null;
try {
updateResult = await autoUpdater.checkForUpdates();
} catch {
}
const updateInfo = updateResult?.updateInfo;
const nextVersion =
(typeof updateInfo?.version === 'string' && updateInfo.version) ||
(typeof payload?.version === 'string' && payload.version) ||
currentVersion;
const available = compareSemver(nextVersion, currentVersion) > 0;
const body =
(typeof payload?.notes === 'string' && payload.notes.trim() ? payload.notes : null) ||
(typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) ||
await parseRelevantChangelogNotes(currentVersion, nextVersion);
state.pendingUpdate = available ? { version: nextVersion, metadata: payload, electronUpdate: updateResult } : null;
return {
available,
currentVersion,
version: available ? nextVersion : null,
body: body || null,
date:
(typeof updateInfo?.releaseDate === 'string' && updateInfo.releaseDate) ||
(typeof payload?.pub_date === 'string' ? payload.pub_date : null),
};
}
case 'desktop_download_and_install_update':
if (!state.pendingUpdate) {
throw new Error('No pending update');
}
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Started',
data: {
contentLength: null,
},
}));
if (!state.pendingUpdate.electronUpdate) {
throw new Error('Electron updater metadata is not available for this build');
}
if (!state.pendingUpdate.downloaded) {
await new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
autoUpdater.off('update-downloaded', onDownloaded);
autoUpdater.off('error', onError);
};
const finish = (callback, value) => {
if (settled) return;
settled = true;
cleanup();
callback(value);
};
const onDownloaded = () => finish(resolve, null);
const onError = (error) => finish(reject, error);
autoUpdater.on('update-downloaded', onDownloaded);
autoUpdater.on('error', onError);
Promise.resolve(autoUpdater.downloadUpdate()).catch((error) => finish(reject, error));
});
}
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Finished',
data: {},
}));
return null;
case 'desktop_restart': {
const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged);
log.info(`[electron] desktop_restart applyUpdate=${applyUpdate} packaged=${app.isPackaged}`);
if (applyUpdate && process.platform === 'darwin' && typeof app.isInApplicationsFolder === 'function') {
try {
if (!app.isInApplicationsFolder()) {
throw new Error('Desktop update requires OpenChamber.app to be installed in /Applications');
}
} catch (error) {
log.warn('[electron] desktop_restart blocked', error);
throw error;
}
}
if (applyUpdate) {
// Match the working updater pattern closely: only bypass the macOS
// hide-on-close / quit-confirmation guards, leave the rest of the
// updater-driven quit/install sequence alone.
state.quitRequested = true;
state.installingUpdate = true;
state.quitConfirmationPending = false;
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
try {
debounceWindowStatePersist(state.mainWindow, true);
} catch {
}
}
}
// Defer so the IPC reply flushes before the app starts shutting down.
// Without this, quitAndInstall() can race with the renderer's pending
// invoke and the restart appears to do nothing from the UI side.
setImmediate(() => {
try {
if (applyUpdate) {
autoUpdater.quitAndInstall();
} else {
app.relaunch();
app.exit(0);
}
} catch (err) {
log.error('[electron] desktop_restart failed', err);
}
});
return null;
}
case 'desktop_get_lan_address':
return await detectLanIPv4Address();
case 'desktop_new_window': {
const config = readDesktopHostsConfig();
const localUiUrl = state.sidecarUrl || state.localOrigin;
let targetUrl = localUiUrl;
if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) {
const host = config.hosts.find((entry) => entry.id === config.defaultHostId);
if (host?.url && !state.unreachableHosts.has(host.url)) {
targetUrl = host.url;
}
}
await createAdditionalWindow(targetUrl);
return null;
}
case 'desktop_new_window_at_url': {
const targetUrl = normalizeHostUrl(String(args.url || ''));
if (!targetUrl) {
throw new Error('Invalid URL');
}
await createAdditionalWindow(targetUrl);
return null;
}
case 'desktop_ssh_instances_get':
return sshManager.readInstances();
case 'desktop_ssh_instances_set':
await sshManager.setInstances(args.config || {});
return null;
case 'desktop_ssh_import_hosts':
return await sshManager.importHosts();
case 'desktop_ssh_connect': {
const id = String(args.id || '').trim();
await sshManager.connect(id);
return null;
}
case 'desktop_ssh_disconnect': {
const id = String(args.id || '').trim();
await sshManager.disconnect(id);
return null;
}
case 'desktop_ssh_status': {
const id = String(args.id || '').trim();
return await sshManager.statusesWithDefaults(id || undefined);
}
case 'desktop_ssh_logs':
return sshManager.logsForInstance(String(args.id || '').trim(), Number(args.limit) || 200);
case 'desktop_ssh_logs_clear':
sshManager.clearLogsForInstance(String(args.id || '').trim());
return null;
default:
throw new Error(`Unknown desktop command: ${command}`);
}
};
const buildMacMenu = () => {
const dispatchAction = (action) => dispatchMenuAction(action);
const handleCopyAction = () => {
BrowserWindow.getFocusedWindow()?.webContents.copy();
dispatchAction('copy');
};
return Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{ role: 'about' },
{
label: 'Check for Updates',
click: () => dispatchCheckForUpdates(),
},
{ type: 'separator' },
{ label: 'Settings', accelerator: 'Cmd+,', click: () => dispatchAction('settings') },
{ label: 'Command Palette', accelerator: 'Cmd+K', click: () => dispatchAction('command-palette') },
{ label: 'Quick Open…', accelerator: 'Cmd+P', click: () => dispatchAction('quick-open') },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ type: 'separator' },
{ role: 'quit' },
],
},
{
label: 'File',
submenu: [
{ label: 'New Window', accelerator: 'Cmd+Shift+Alt+N', click: () => void handleInvoke(null, 'desktop_new_window') },
{ type: 'separator' },
{ label: 'New Session', accelerator: 'Cmd+N', click: () => dispatchAction('new-session') },
{ label: 'New Worktree', accelerator: 'Cmd+Shift+N', click: () => dispatchAction('new-worktree-session') },
{ type: 'separator' },
{ label: 'Add Workspace', click: () => dispatchAction('change-workspace') },
{ type: 'separator' },
{ role: 'close' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ label: 'Git', accelerator: 'Cmd+G', click: () => dispatchAction('open-git-tab') },
{ label: 'Diff', accelerator: 'Cmd+E', click: () => dispatchAction('open-diff-tab') },
{ label: 'Files', click: () => dispatchAction('open-files-tab') },
{ label: 'Terminal', accelerator: 'Cmd+T', click: () => dispatchAction('open-terminal-tab') },
{ type: 'separator' },
{ label: 'Light Theme', click: () => dispatchAction('theme-light') },
{ label: 'Dark Theme', click: () => dispatchAction('theme-dark') },
{ label: 'System Theme', click: () => dispatchAction('theme-system') },
{ type: 'separator' },
{ label: 'Toggle Session Sidebar', accelerator: 'Cmd+L', click: () => dispatchAction('toggle-sidebar') },
{ label: 'Toggle Memory Debug', accelerator: 'Cmd+Shift+D', click: () => dispatchAction('toggle-memory-debug') },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'close' },
],
},
{
label: 'Help',
submenu: [
{ label: 'Keyboard Shortcuts', accelerator: 'Cmd+.', click: () => dispatchAction('help-dialog') },
{ label: 'Show Diagnostics', accelerator: 'Cmd+Shift+L', click: () => dispatchAction('download-logs') },
{ type: 'separator' },
{ label: 'Clear Cache', click: () => void handleInvoke(null, 'desktop_clear_cache') },
{ type: 'separator' },
{ label: 'Report a Bug', click: () => shell.openExternal(GITHUB_BUG_REPORT_URL) },
{ label: 'Request a Feature', click: () => shell.openExternal(GITHUB_FEATURE_REQUEST_URL) },
{ type: 'separator' },
{ label: 'Join Discord', click: () => shell.openExternal(DISCORD_INVITE_URL) },
],
},
]);
};
contextMenu({
showInspectElement: isDev,
showSaveImageAs: true,
showCopyImage: true,
showCopyLink: true,
});
// All desktop_* IPC and dialog:open run with full Electron main privileges
// (fs access, shell.openPath, spawn, app.relaunch, …). The preload shim is
// injected into every webContents in the window, including remote hosts the
// user switches to via DesktopHostSwitcher. Without a gate, a malicious
// remote page could read arbitrary local files, open arbitrary apps, etc.
//
// Strategy: commands fall into two buckets by capability, not by origin.
// Window/host-switcher operations (probe a URL, open a new window, set
// title, read the hosts list) are safe for any renderer. Filesystem,
// shell.openPath, installed-app scans, app relaunch, and file dialogs
// are gated to local senders — even the user's own remote UI shouldn't
// need them, and a compromised remote can't use them either.
const isLocalSender = (webContents) => {
try {
const raw = typeof webContents?.getURL === 'function' ? webContents.getURL() : '';
if (!raw) return false;
if (raw.startsWith('file://') || raw === 'about:blank') return true;
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
const hostname = url.hostname;
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
if (state.localOrigin) {
try {
const allowed = new URL(state.localOrigin);
if (allowed.origin === url.origin) return true;
} catch {
}
}
return false;
} catch {
return false;
}
};
const COMMANDS_SAFE_FOR_REMOTE = new Set([
'desktop_hosts_get',
'desktop_host_probe',
'desktop_new_window',
'desktop_new_window_at_url',
'desktop_set_window_title',
'desktop_set_window_theme',
'desktop_is_window_fullscreen',
'desktop_start_window_drag',
'desktop_get_app_version',
'desktop_get_lan_address',
]);
ipcMain.handle('openchamber:invoke', async (event, command, args) => {
if (!isLocalSender(event.sender) && !COMMANDS_SAFE_FOR_REMOTE.has(command)) {
log.warn(`[ipc] rejected ${command} from non-local origin: ${event.sender?.getURL?.() || '(unknown)'}`);
throw new Error('IPC not available for this origin');
}
const browserWindow = BrowserWindow.fromWebContents(event.sender);
return handleInvoke(browserWindow, command, args);
});
ipcMain.handle('openchamber:dialog:open', async (event, options) => {
// Native file dialogs expose absolute local paths; never grant to remote.
if (!isLocalSender(event.sender)) {
log.warn(`[ipc] rejected dialog:open from non-local origin: ${event.sender?.getURL?.() || '(unknown)'}`);
throw new Error('IPC not available for this origin');
}
const browserWindow = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(browserWindow || undefined, {
title: typeof options?.title === 'string' ? options.title : undefined,
filters: Array.isArray(options?.filters)
? options.filters
.filter((filter) => filter && typeof filter === 'object')
.map((filter) => ({
name: typeof filter.name === 'string' && filter.name.trim().length > 0 ? filter.name : 'Files',
extensions: Array.isArray(filter.extensions)
? filter.extensions.filter((extension) => typeof extension === 'string' && extension.trim().length > 0)
: [],
}))
: undefined,
properties: [
options?.directory ? 'openDirectory' : 'openFile',
options?.multiple ? 'multiSelections' : null,
'createDirectory',
].filter(Boolean),
});
if (result.canceled) return null;
if (options?.multiple) return result.filePaths;
return result.filePaths[0] || null;
});
app.on('window-all-closed', () => {
if (process.platform === 'darwin' && !state.quitRequested) {
return;
}
if (!state.installingUpdate) {
killSidecar();
void sshManager.shutdownAll();
}
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', (event) => {
if (state.quitConfirmed || state.installingUpdate || process.platform !== 'darwin') {
state.quitRequested = true;
return;
}
event.preventDefault();
void requestQuitWithConfirmation();
});
app.on('second-instance', (_event, argv) => {
const urls = Array.isArray(argv)
? argv.filter((arg) => typeof arg === 'string' && arg.startsWith(`${DEEP_LINK_PROTOCOL}://`))
: [];
if (urls.length > 0) handleDeepLinks(urls);
focusForegroundWindow();
});
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLinks([url]);
});
app.on('activate', async () => {
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
if (windows.length > 0) {
const visibleWindow = windows.find((window) => window.isVisible());
const targetWindow = visibleWindow || state.mainWindow || windows[0];
if (targetWindow.isMinimized()) targetWindow.restore();
targetWindow.show();
targetWindow.focus();
return;
}
if (state.localOrigin) {
const config = readDesktopHostsConfig();
const localUiUrl = state.sidecarUrl || state.localOrigin;
const host = config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID
? config.hosts.find((entry) => entry.id === config.defaultHostId)
: null;
const targetUrl = host?.url && !state.unreachableHosts.has(host.url) ? host.url : localUiUrl;
await createAdditionalWindow(targetUrl);
}
});
app.whenReady().then(async () => {
log.info('[electron] app starting', {
version: APP_VERSION,
packaged: app.isPackaged,
platform: process.platform,
arch: process.arch,
});
nativeTheme.themeSource = readThemeSource();
setupAutoUpdater();
if (process.platform === 'darwin') {
Menu.setApplicationMenu(buildMacMenu());
}
const initial = extractInitialDeepLinks();
if (initial.length > 0) handleDeepLinks(initial);
const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl();
await activateMainWindow(initialUrl, localOrigin, bootOutcome);
startQuitRiskPoller();
}).catch((error) => {
log.error('[electron] startup failed:', error);
app.exit(1);
});