2026-06-02 00:43:05 +03:00
|
|
|
|
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, protocol, screen, session, shell, webContents } from 'electron';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
import { promisify } from 'node:util';
|
|
|
|
|
|
import updaterPkg from 'electron-updater';
|
|
|
|
|
|
import { ElectronSshManager } from './ssh-manager.mjs';
|
2026-06-10 00:20:23 +03:00
|
|
|
|
import { createTrayController } from './tray.mjs';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
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';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const UI_PROTOCOL = 'openchamber-ui';
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const PACKAGED_APP_USER_MODEL_ID = 'dev.openchamber.desktop';
|
|
|
|
|
|
const DEV_APP_USER_MODEL_ID = 'dev.openchamber.desktop.dev';
|
|
|
|
|
|
const APP_USER_MODEL_ID = app.isPackaged ? PACKAGED_APP_USER_MODEL_ID : DEV_APP_USER_MODEL_ID;
|
2026-05-26 01:36:11 +03:00
|
|
|
|
const BACKGROUND_START_ARG = '--background';
|
|
|
|
|
|
|
|
|
|
|
|
const readLoginItemSettings = () => {
|
|
|
|
|
|
if (process.platform !== 'darwin') return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return app.getLoginItemSettings();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
process.argv.includes(BACKGROUND_START_ARG) ||
|
|
|
|
|
|
loginItemSettings?.wasOpenedAtLogin === true ||
|
|
|
|
|
|
loginItemSettings?.wasOpenedAsHidden === true
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
// Set the product name early so electron-log derives its log directory as
|
|
|
|
|
|
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
|
|
|
|
|
|
app.setName('OpenChamber');
|
2026-05-27 01:14:51 +03:00
|
|
|
|
if (isDev) {
|
|
|
|
|
|
app.setPath('userData', path.join(app.getPath('appData'), 'OpenChamber Dev'));
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
app.setAppUserModelId(APP_USER_MODEL_ID);
|
|
|
|
|
|
app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>');
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
protocol.registerSchemesAsPrivileged([
|
|
|
|
|
|
{
|
|
|
|
|
|
scheme: UI_PROTOCOL,
|
|
|
|
|
|
privileges: {
|
|
|
|
|
|
standard: true,
|
|
|
|
|
|
secure: true,
|
|
|
|
|
|
supportFetchAPI: true,
|
|
|
|
|
|
corsEnabled: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (!app.requestSingleInstanceLock()) {
|
|
|
|
|
|
app.exit(0);
|
|
|
|
|
|
process.exit(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-04-22 21:03:02 +03:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const LOOPBACK_BIND_HOST = '127.0.0.1';
|
|
|
|
|
|
const LAN_BIND_HOST = '0.0.0.0';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const MIN_WINDOW_WIDTH = 800;
|
|
|
|
|
|
const MIN_WINDOW_HEIGHT = 520;
|
|
|
|
|
|
const MIN_RESTORE_WINDOW_WIDTH = 900;
|
|
|
|
|
|
const MIN_RESTORE_WINDOW_HEIGHT = 560;
|
2026-05-08 12:22:59 +03:00
|
|
|
|
const MINI_CHAT_WINDOW_WIDTH = 520;
|
|
|
|
|
|
const MINI_CHAT_WINDOW_HEIGHT = 760;
|
|
|
|
|
|
const MINI_CHAT_MIN_WINDOW_WIDTH = 360;
|
|
|
|
|
|
const MINI_CHAT_MIN_WINDOW_HEIGHT = 480;
|
2026-04-29 17:03:38 -04:00
|
|
|
|
const MAX_CAPTURE_PAGE_RECT_AREA = 4_000_000;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const LOCAL_HOST_ID = 'local';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const LOCAL_DESKTOP_CLIENT_KIND = 'desktop-local';
|
|
|
|
|
|
const LOCAL_DESKTOP_CLIENT_DEDUPE_KEY = 'desktop-local';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const ENV_OVERRIDE_HOST_ID = '__env';
|
2026-05-12 03:59:40 -04:00
|
|
|
|
const CHANGELOG_URL = 'https://raw.githubusercontent.com/openchamber/openchamber/main/CHANGELOG.md';
|
|
|
|
|
|
const GITHUB_BUG_REPORT_URL = 'https://github.com/openchamber/openchamber/issues/new?template=bug_report.yml';
|
|
|
|
|
|
const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/issues/new?template=feature_request.yml';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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';
|
2026-06-03 14:51:10 +03:00
|
|
|
|
const OPENCODE_SHUTDOWN_GRACE_MS = 100;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
const { autoUpdater } = updaterPkg;
|
|
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
serverHandle: null,
|
|
|
|
|
|
sidecarUrl: null,
|
|
|
|
|
|
localOrigin: null,
|
2026-06-02 00:43:05 +03:00
|
|
|
|
apiBaseUrl: null,
|
|
|
|
|
|
clientToken: null,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
bootOutcome: null,
|
|
|
|
|
|
initScript: null,
|
|
|
|
|
|
mainWindow: null,
|
|
|
|
|
|
quitRequested: false,
|
|
|
|
|
|
quitConfirmed: false,
|
2026-06-03 14:51:10 +03:00
|
|
|
|
quitInProgress: false,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
quitConfirmationPending: false,
|
2026-06-03 14:51:10 +03:00
|
|
|
|
backgroundShutdownComplete: false,
|
|
|
|
|
|
sshShutdownPromise: null,
|
2026-04-22 23:46:38 +03:00
|
|
|
|
installingUpdate: false,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
pendingUpdate: null,
|
|
|
|
|
|
unreachableHosts: new Set(),
|
|
|
|
|
|
windowCounter: 1,
|
|
|
|
|
|
focusedWindowIds: new Set(),
|
|
|
|
|
|
windowGeometryRevisions: new Map(),
|
2026-06-05 13:31:52 +03:00
|
|
|
|
windowGeometryTimers: new Map(),
|
2026-05-08 12:22:59 +03:00
|
|
|
|
miniChatWindowsBySession: new Map(),
|
2026-04-20 15:41:15 +03:00
|
|
|
|
sshStatuses: new Map(),
|
|
|
|
|
|
sshLogs: new Map(),
|
2026-06-10 00:20:23 +03:00
|
|
|
|
trayController: null,
|
|
|
|
|
|
lastFocusedWindowId: null,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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.`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-03 14:51:10 +03:00
|
|
|
|
const shutdownBackgroundServices = () => {
|
|
|
|
|
|
if (state.backgroundShutdownComplete) return;
|
|
|
|
|
|
state.backgroundShutdownComplete = true;
|
|
|
|
|
|
if (state.installingUpdate) return;
|
|
|
|
|
|
killSidecar();
|
|
|
|
|
|
setImmediate(() => {
|
|
|
|
|
|
void shutdownSshSessions();
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const shutdownSshSessions = async () => {
|
|
|
|
|
|
if (state.sshShutdownPromise) {
|
|
|
|
|
|
await state.sshShutdownPromise;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state.sshShutdownPromise = sshManager.shutdownAll().catch((error) => {
|
|
|
|
|
|
log.warn('[electron] failed to stop SSH sessions:', error);
|
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
|
state.sshShutdownPromise = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await state.sshShutdownPromise;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-22 23:46:38 +03:00
|
|
|
|
const prepareForQuit = ({ installingUpdate = false } = {}) => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
state.quitRequested = true;
|
2026-04-22 23:46:38 +03:00
|
|
|
|
state.quitConfirmed = true;
|
|
|
|
|
|
state.installingUpdate = installingUpdate;
|
|
|
|
|
|
state.quitConfirmationPending = false;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
2026-06-10 00:20:23 +03:00
|
|
|
|
if (state.trayController) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.trayController.destroy();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
state.trayController = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
debounceWindowStatePersist(state.mainWindow, true);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 14:51:10 +03:00
|
|
|
|
if (installingUpdate) {
|
|
|
|
|
|
state.backgroundShutdownComplete = true;
|
|
|
|
|
|
return;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
2026-06-03 14:51:10 +03:00
|
|
|
|
|
|
|
|
|
|
shutdownBackgroundServices();
|
2026-04-22 23:46:38 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const performConfirmedQuit = () => {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
if (state.quitInProgress) return;
|
|
|
|
|
|
state.quitInProgress = true;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
2026-06-03 14:51:10 +03:00
|
|
|
|
prepareForQuit();
|
|
|
|
|
|
app.exit(0);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const requestQuitWithConfirmation = async () => {
|
2026-04-30 13:54:58 +03:00
|
|
|
|
await refreshQuitRiskFlags();
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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 () => {
|
2026-04-30 13:54:58 +03:00
|
|
|
|
if (state.serverHandle && typeof state.serverHandle.getQuitRiskStatus === 'function') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const status = await state.serverHandle.getQuitRiskStatus();
|
|
|
|
|
|
const scheduled = status?.scheduledTasks;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
quitRisk.hasActiveTunnel = Boolean(status?.tunnel?.active);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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 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'));
|
2026-04-20 22:28:32 +03:00
|
|
|
|
} 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);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return {};
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const writeJsonFile = async (filePath, data) => {
|
|
|
|
|
|
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
2026-04-20 22:28:32 +03:00
|
|
|
|
// 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);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const readSettingsRoot = () => {
|
|
|
|
|
|
const root = readJsonFile(settingsFilePath());
|
|
|
|
|
|
return root && typeof root === 'object' && !Array.isArray(root) ? root : {};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 22:28:32 +03:00
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const sanitizeClientTokenForStorage = (raw) => {
|
|
|
|
|
|
const token = typeof raw === 'string' ? raw.trim() : '';
|
|
|
|
|
|
return token.length > 0 ? token : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const sameOrigin = (left, right) => {
|
|
|
|
|
|
if (!left || !right) return false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return new URL(left).origin === new URL(right).origin;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const readDesktopLocalClientToken = () => {
|
|
|
|
|
|
return sanitizeClientTokenForStorage(readSettingsRoot().desktopLocalClientToken) || '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const isLocalRuntimeUrl = (targetUrl) => {
|
|
|
|
|
|
const localUrl = state.sidecarUrl || state.localOrigin || '';
|
|
|
|
|
|
return Boolean(localUrl && sameOrigin(targetUrl, localUrl));
|
|
|
|
|
|
};
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url;
|
|
|
|
|
|
const clientToken = sanitizeClientTokenForStorage(entry?.clientToken);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
return { id, label, url, apiUrl, ...(clientToken ? { clientToken } : {}) };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
hosts,
|
|
|
|
|
|
defaultHostId: typeof root.desktopDefaultHostId === 'string' && root.desktopDefaultHostId.trim()
|
|
|
|
|
|
? root.desktopDefaultHostId.trim()
|
|
|
|
|
|
: null,
|
|
|
|
|
|
initialHostChoiceCompleted: root.desktopInitialHostChoiceCompleted === true,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const writeDesktopHostsConfig = async (config) => {
|
2026-04-20 22:28:32 +03:00
|
|
|
|
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;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url;
|
|
|
|
|
|
const clientToken = sanitizeClientTokenForStorage(entry?.clientToken);
|
2026-04-20 22:28:32 +03:00
|
|
|
|
return {
|
|
|
|
|
|
id,
|
|
|
|
|
|
label: typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : url,
|
|
|
|
|
|
url,
|
2026-06-02 00:43:05 +03:00
|
|
|
|
apiUrl,
|
|
|
|
|
|
...(clientToken ? { clientToken } : {}),
|
2026-04-20 22:28:32 +03:00
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
root.desktopDefaultHostId = typeof config?.defaultHostId === 'string' && config.defaultHostId.trim()
|
|
|
|
|
|
? config.defaultHostId.trim()
|
|
|
|
|
|
: null;
|
|
|
|
|
|
if (typeof config?.initialHostChoiceCompleted === 'boolean') {
|
|
|
|
|
|
root.desktopInitialHostChoiceCompleted = config.initialHostChoiceCompleted;
|
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (Object.prototype.hasOwnProperty.call(config || {}, 'localClientToken')) {
|
|
|
|
|
|
const localClientToken = sanitizeClientTokenForStorage(config.localClientToken);
|
|
|
|
|
|
if (localClientToken) {
|
|
|
|
|
|
root.desktopLocalClientToken = localClientToken;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delete root.desktopLocalClientToken;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-20 22:28:32 +03:00
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const readWindowState = () => {
|
|
|
|
|
|
const stateValue = readSettingsRoot().desktopWindowState;
|
|
|
|
|
|
return stateValue && typeof stateValue === 'object' ? stateValue : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const clampWindowBoundsToVisibleWorkArea = (bounds) => {
|
|
|
|
|
|
const width = Math.max(MIN_RESTORE_WINDOW_WIDTH, Math.round(Number(bounds?.width) || 0));
|
|
|
|
|
|
const height = Math.max(MIN_RESTORE_WINDOW_HEIGHT, Math.round(Number(bounds?.height) || 0));
|
|
|
|
|
|
const x = Math.round(Number(bounds?.x));
|
|
|
|
|
|
const y = Math.round(Number(bounds?.y));
|
|
|
|
|
|
|
|
|
|
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
|
|
|
|
return { width, height };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const display = screen.getDisplayMatching({ x, y, width, height }) || screen.getPrimaryDisplay();
|
|
|
|
|
|
const workArea = display.workArea;
|
|
|
|
|
|
const clampedWidth = Math.min(width, Math.max(MIN_WINDOW_WIDTH, workArea.width));
|
|
|
|
|
|
const clampedHeight = Math.min(height, Math.max(MIN_WINDOW_HEIGHT, workArea.height));
|
|
|
|
|
|
const maxX = workArea.x + workArea.width - clampedWidth;
|
|
|
|
|
|
const maxY = workArea.y + workArea.height - clampedHeight;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: clampedWidth >= workArea.width ? workArea.x : Math.min(Math.max(x, workArea.x), maxX),
|
|
|
|
|
|
y: clampedHeight >= workArea.height ? workArea.y : Math.min(Math.max(y, workArea.y), maxY),
|
|
|
|
|
|
width: clampedWidth,
|
|
|
|
|
|
height: clampedHeight,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { x, y, width, height };
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const writeWindowState = async (browserWindow) => {
|
|
|
|
|
|
if (!browserWindow || browserWindow.isDestroyed()) return;
|
|
|
|
|
|
if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return;
|
|
|
|
|
|
|
|
|
|
|
|
const bounds = browserWindow.getBounds();
|
2026-04-20 22:28:32 +03:00
|
|
|
|
await mutateSettingsRoot((root) => {
|
2026-05-27 01:14:51 +03:00
|
|
|
|
if (!browserWindow || browserWindow.isDestroyed()) return root;
|
2026-04-20 22:28:32 +03:00
|
|
|
|
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(),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-06-05 13:31:52 +03:00
|
|
|
|
const existingTimer = state.windowGeometryTimers.get(key);
|
|
|
|
|
|
if (existingTimer) {
|
|
|
|
|
|
clearTimeout(existingTimer);
|
|
|
|
|
|
state.windowGeometryTimers.delete(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const persist = async () => {
|
|
|
|
|
|
if (state.windowGeometryRevisions.get(key) !== revision) return;
|
2026-06-05 13:31:52 +03:00
|
|
|
|
state.windowGeometryTimers.delete(key);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
await writeWindowState(browserWindow);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (immediate) {
|
|
|
|
|
|
void persist();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 13:31:52 +03:00
|
|
|
|
const timer = setTimeout(() => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
void persist();
|
|
|
|
|
|
}, 300);
|
2026-06-05 13:31:52 +03:00
|
|
|
|
state.windowGeometryTimers.set(key, timer);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildHealthUrl = (url) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
|
parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/health`;
|
|
|
|
|
|
return parsed.toString();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const buildVersionUrl = (url) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
|
parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/api/version`;
|
|
|
|
|
|
return parsed.toString();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const classifyVersionPayload = (payload) => {
|
|
|
|
|
|
const compatibility = payload?.compatibility;
|
|
|
|
|
|
if (!payload || payload.status !== 'ok' || !compatibility || typeof compatibility !== 'object') {
|
|
|
|
|
|
return 'wrong-service';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(compatibility.capabilities) || !compatibility.capabilities.includes('api.runtime-url.v1')) {
|
|
|
|
|
|
return 'incompatible';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (compatibility.apiVersion !== 1 || compatibility.minClientApiVersion > 1) {
|
|
|
|
|
|
return 'update-recommended';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return 'ok';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => {
|
|
|
|
|
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await fetch(versionUrl, { signal: timeoutSignal, headers });
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (timeoutSignal.aborted) {
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
return await Promise.race([
|
|
|
|
|
|
electronNet.fetch(versionUrl, { headers }),
|
|
|
|
|
|
new Promise((_, reject) => setTimeout(() => reject(error), timeoutMs)),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const probeHostWithTimeout = async (url, timeoutMs, clientToken = '') => {
|
|
|
|
|
|
const versionUrl = buildVersionUrl(url);
|
|
|
|
|
|
if (!versionUrl) {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
throw new Error('Invalid URL');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
try {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const headers = { Accept: 'application/json' };
|
|
|
|
|
|
const token = typeof clientToken === 'string' ? clientToken.trim() : '';
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
const response = await fetchVersionPayload(versionUrl, { headers, timeoutMs });
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const status = response.status;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (status === 401 || status === 403) {
|
|
|
|
|
|
return { status: 'auth', latencyMs: Date.now() - started };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (status < 200 || status >= 300) {
|
|
|
|
|
|
return { status: 'unreachable', latencyMs: Date.now() - started };
|
|
|
|
|
|
}
|
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
status: classifyVersionPayload(payload),
|
2026-04-20 15:41:15 +03:00
|
|
|
|
latencyMs: Date.now() - started,
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return { status: 'unreachable', latencyMs: Date.now() - started };
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const resolveStoredClientTokenForUrl = (targetUrl, config = readDesktopHostsConfig()) => {
|
|
|
|
|
|
const normalizedTarget = normalizeHostUrl(targetUrl);
|
|
|
|
|
|
if (!normalizedTarget) return '';
|
|
|
|
|
|
if (isLocalRuntimeUrl(normalizedTarget)) {
|
|
|
|
|
|
return readDesktopLocalClientToken();
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const host of config.hosts || []) {
|
|
|
|
|
|
const hostUrl = normalizeHostUrl(host?.url || '');
|
|
|
|
|
|
const apiUrl = normalizeHostUrl(host?.apiUrl || host?.url || '');
|
|
|
|
|
|
if (normalizedTarget === hostUrl || normalizedTarget === apiUrl) {
|
|
|
|
|
|
return sanitizeClientTokenForStorage(host?.clientToken);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const pickUnusedPort = async (host = '127.0.0.1') => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const net = await import('node:net');
|
|
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
|
|
|
|
const server = net.createServer();
|
2026-06-02 00:43:05 +03:00
|
|
|
|
server.listen(0, host, () => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const address = server.address();
|
|
|
|
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
|
|
|
|
server.close(() => resolve(port));
|
|
|
|
|
|
});
|
|
|
|
|
|
server.on('error', reject);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const isPortFree = async (port, host = '127.0.0.1') => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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));
|
2026-06-02 00:43:05 +03:00
|
|
|
|
test.listen(port, host, () => done(true));
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 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');
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const shouldUsePackagedUi = () => {
|
|
|
|
|
|
if (process.env.OPENCHAMBER_ELECTRON_LOAD_SERVER_UI === '1') return false;
|
|
|
|
|
|
if (process.env.OPENCHAMBER_ELECTRON_USE_BUNDLED_UI === '1') return true;
|
|
|
|
|
|
return app.isPackaged;
|
|
|
|
|
|
};
|
|
|
|
|
|
const packagedUiOrigin = () => `${UI_PROTOCOL}://app`;
|
|
|
|
|
|
const buildPackagedUiUrl = (pathname = '/index.html') => new URL(pathname, `${packagedUiOrigin()}/`).toString();
|
|
|
|
|
|
|
|
|
|
|
|
const injectRuntimeConfigIntoHtml = (html) => {
|
|
|
|
|
|
const apiBaseUrl = state.apiBaseUrl || state.sidecarUrl || '';
|
|
|
|
|
|
const localOrigin = state.localOrigin || state.sidecarUrl || '';
|
|
|
|
|
|
const initScript = `<script>if(window.__OPENCHAMBER_LOCAL_ORIGIN__===undefined){window.__OPENCHAMBER_LOCAL_ORIGIN__=${JSON.stringify(localOrigin)};}if(window.__OPENCHAMBER_API_BASE_URL__===undefined){window.__OPENCHAMBER_API_BASE_URL__=${JSON.stringify(apiBaseUrl)};}if(window.__OPENCHAMBER_CLIENT_TOKEN__===undefined&&${JSON.stringify(state.clientToken || '')}){window.__OPENCHAMBER_CLIENT_TOKEN__=${JSON.stringify(state.clientToken || '')};}</script>`;
|
|
|
|
|
|
if (html.includes('<head>')) return html.replace('<head>', `<head>${initScript}`);
|
|
|
|
|
|
if (html.includes('</head>')) return html.replace('</head>', `${initScript}</head>`);
|
|
|
|
|
|
return `${initScript}${html}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const registerPackagedUiProtocol = () => {
|
|
|
|
|
|
if (!shouldUsePackagedUi()) return;
|
|
|
|
|
|
protocol.handle(UI_PROTOCOL, async (request) => {
|
|
|
|
|
|
const distPath = resolveWebDistDir();
|
|
|
|
|
|
let requestedPath = '/index.html';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(request.url);
|
|
|
|
|
|
requestedPath = decodeURIComponent(url.pathname || '/index.html');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
requestedPath = '/index.html';
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalized = path.normalize(requestedPath).replace(/^([/\\])+/, '');
|
|
|
|
|
|
const candidate = path.join(distPath, normalized || 'index.html');
|
|
|
|
|
|
const relative = path.relative(distPath, candidate);
|
|
|
|
|
|
const isInsideDist = relative && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
|
|
|
|
const filePath = isInsideDist ? candidate : path.join(distPath, 'index.html');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const info = await fsp.stat(filePath);
|
|
|
|
|
|
if (info.isFile()) {
|
|
|
|
|
|
if (filePath.endsWith('.html')) {
|
|
|
|
|
|
const html = await fsp.readFile(filePath, 'utf8');
|
|
|
|
|
|
const body = injectRuntimeConfigIntoHtml(html);
|
|
|
|
|
|
return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
|
|
|
|
|
}
|
|
|
|
|
|
return electronNet.fetch(pathToFileURL(filePath).toString());
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
const indexPath = path.join(distPath, 'index.html');
|
|
|
|
|
|
const html = await fsp.readFile(indexPath, 'utf8');
|
|
|
|
|
|
const body = injectRuntimeConfigIntoHtml(html);
|
|
|
|
|
|
return new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
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];
|
2026-04-20 23:46:24 +03:00
|
|
|
|
// 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 });
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (target.isMinimized()) target.restore();
|
2026-04-20 23:46:24 +03:00
|
|
|
|
target.show();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
target.focus();
|
2026-04-20 23:46:24 +03:00
|
|
|
|
if (typeof target.moveTop === 'function') target.moveTop();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 23:46:24 +03:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const directory = typeof payload.directory === 'string' && payload.directory.trim()
|
|
|
|
|
|
? payload.directory.trim()
|
|
|
|
|
|
: null;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
const notification = new Notification({
|
|
|
|
|
|
title,
|
|
|
|
|
|
body,
|
|
|
|
|
|
silent: false,
|
|
|
|
|
|
...(process.platform === 'darwin' ? { sound: 'Glass' } : {}),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 23:46:24 +03:00
|
|
|
|
activeNotifications.add(notification);
|
|
|
|
|
|
const release = () => { activeNotifications.delete(notification); };
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
notification.on('click', () => {
|
|
|
|
|
|
focusForegroundWindow();
|
|
|
|
|
|
if (sessionId) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
emitToAllWindows('openchamber:open-session', { sessionId, directory });
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
2026-04-20 23:46:24 +03:00
|
|
|
|
release();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
2026-04-20 23:46:24 +03:00
|
|
|
|
notification.on('close', release);
|
|
|
|
|
|
notification.on('failed', release);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const queryWindowsRegistryValue = (key, name) => {
|
|
|
|
|
|
const result = spawnSync('reg.exe', ['query', key, '/v', name], {
|
|
|
|
|
|
encoding: 'utf8',
|
|
|
|
|
|
windowsHide: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (result.error || result.status !== 0) return '';
|
|
|
|
|
|
const line = String(result.stdout || '')
|
|
|
|
|
|
.split(/\r?\n/)
|
|
|
|
|
|
.map((entry) => entry.trim())
|
|
|
|
|
|
.find((entry) => entry.toLowerCase().startsWith(name.toLowerCase()));
|
|
|
|
|
|
if (!line) return '';
|
|
|
|
|
|
const match = line.match(/^\S+\s+REG_\S+\s+(.+)$/);
|
|
|
|
|
|
return match?.[1]?.trim() || '';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const expandWindowsEnvRefs = (value) => String(value || '').replace(/%([^%]+)%/g, (_match, key) => process.env[key] || '');
|
|
|
|
|
|
|
|
|
|
|
|
const loadWindowsEnv = () => {
|
|
|
|
|
|
const machinePath = queryWindowsRegistryValue('HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path');
|
|
|
|
|
|
const userPath = queryWindowsRegistryValue('HKCU\\Environment', 'Path');
|
|
|
|
|
|
const homeDir = os.homedir();
|
|
|
|
|
|
const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
|
|
|
|
|
|
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
|
|
|
|
|
const commonPaths = [
|
|
|
|
|
|
path.join(homeDir, '.opencode', 'bin'),
|
|
|
|
|
|
path.join(homeDir, '.bun', 'bin'),
|
|
|
|
|
|
path.join(homeDir, '.local', 'bin'),
|
|
|
|
|
|
path.join(localAppData, 'Programs', 'Microsoft VS Code', 'bin'),
|
|
|
|
|
|
path.join(localAppData, 'Programs', 'Cursor', 'resources', 'app', 'bin'),
|
|
|
|
|
|
path.join(appData, 'npm'),
|
|
|
|
|
|
];
|
|
|
|
|
|
return {
|
|
|
|
|
|
PATH: [machinePath, userPath, process.env.PATH, ...commonPaths]
|
|
|
|
|
|
.map(expandWindowsEnvRefs)
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(path.delimiter),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
// 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;
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
|
cachedShellEnv = loadWindowsEnv();
|
|
|
|
|
|
return cachedShellEnv;
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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
|
2026-04-26 16:24:07 +03:00
|
|
|
|
import { pathLooksUserConfigured, mergePathValues } from '@openchamber/web/server/lib/opencode/path-utils.js';
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
// 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();
|
2026-04-25 20:44:07 +03:00
|
|
|
|
const currentPath = process.env.PATH || '';
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const delimiter = process.platform === 'win32' ? ';' : ':';
|
|
|
|
|
|
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(shellEnv)) {
|
|
|
|
|
|
if (key === 'PATH') continue;
|
|
|
|
|
|
if (typeof process.env[key] === 'undefined') {
|
|
|
|
|
|
process.env[key] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-26 16:24:07 +03:00
|
|
|
|
|
|
|
|
|
|
const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : '';
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if ((process.platform === 'win32' || !currentPathLooksUserConfigured) && shellPath) {
|
|
|
|
|
|
process.env.PATH = mergePathValues(shellPath, currentPath, delimiter);
|
2026-04-25 20:44:07 +03:00
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const bindHost = lanAccessEnabled ? LAN_BIND_HOST : LOOPBACK_BIND_HOST;
|
2026-05-25 15:20:56 +03:00
|
|
|
|
const desktopUiPassword = typeof settings.desktopUiPassword === 'string' ? settings.desktopUiPassword.trim() : '';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (await isPortFree(candidate, bindHost)) {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
chosenPort = candidate;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (chosenPort === 0) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
chosenPort = await pickUnusedPort(bindHost);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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';
|
2026-05-25 15:20:56 +03:00
|
|
|
|
if (desktopUiPassword) {
|
|
|
|
|
|
process.env.OPENCHAMBER_UI_PASSWORD = desktopUiPassword;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delete process.env.OPENCHAMBER_UI_PASSWORD;
|
|
|
|
|
|
}
|
2026-05-05 18:47:00 +03:00
|
|
|
|
process.env.OPENCHAMBER_SKIP_API_COMPRESSION = process.env.OPENCHAMBER_SKIP_API_COMPRESSION || 'true';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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,
|
2026-05-25 15:20:56 +03:00
|
|
|
|
uiPassword: desktopUiPassword || null,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
attachSignals: false,
|
|
|
|
|
|
exitOnShutdown: false,
|
2026-06-03 12:21:30 +03:00
|
|
|
|
apiOnly: false,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
onDesktopNotification: (payload) => maybeShowNativeNotification(payload),
|
2026-05-13 17:43:08 +03:00
|
|
|
|
getIsWindowFocused: isAnyWindowFocused,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const port = handle.getPort();
|
|
|
|
|
|
const url = buildLocalUrl(port);
|
|
|
|
|
|
|
|
|
|
|
|
state.serverHandle = handle;
|
|
|
|
|
|
state.sidecarUrl = url;
|
|
|
|
|
|
|
2026-04-20 22:28:32 +03:00
|
|
|
|
await mutateSettingsRoot((root) => {
|
|
|
|
|
|
root.desktopLocalPort = port;
|
|
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
return url;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-03 14:51:10 +03:00
|
|
|
|
const launchDetachedOpenCodeKiller = (processInfo) => {
|
|
|
|
|
|
if (!processInfo?.managed) return;
|
|
|
|
|
|
const pid = Number(processInfo.pid);
|
|
|
|
|
|
const port = Number(processInfo.port);
|
|
|
|
|
|
const hasPid = Number.isFinite(pid) && pid > 0;
|
|
|
|
|
|
const hasPort = Number.isFinite(port) && port > 0;
|
|
|
|
|
|
if (!hasPid && !hasPort) return;
|
|
|
|
|
|
const normalizedPid = hasPid ? String(Math.trunc(pid)) : '0';
|
|
|
|
|
|
const normalizedPort = Number.isFinite(port) && port > 0 ? String(Math.trunc(port)) : '0';
|
|
|
|
|
|
|
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
|
if (!hasPid) return;
|
2026-06-04 18:11:51 +03:00
|
|
|
|
const script = `
|
|
|
|
|
|
$ErrorActionPreference = 'SilentlyContinue'
|
|
|
|
|
|
$targetPid = ${normalizedPid}
|
|
|
|
|
|
$graceMs = ${Math.max(0, Math.trunc(OPENCODE_SHUTDOWN_GRACE_MS))}
|
|
|
|
|
|
function Stop-ProcessTree([int]$processId, [bool]$force) {
|
|
|
|
|
|
if ($processId -le 0) { return }
|
|
|
|
|
|
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$processId"
|
|
|
|
|
|
foreach ($child in $children) {
|
|
|
|
|
|
Stop-ProcessTree ([int]$child.ProcessId) $force
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($force) {
|
|
|
|
|
|
Stop-Process -Id $processId -Force
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Stop-Process -Id $processId
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Stop-ProcessTree $targetPid $false
|
|
|
|
|
|
Start-Sleep -Milliseconds $graceMs
|
|
|
|
|
|
Stop-ProcessTree $targetPid $true
|
|
|
|
|
|
`;
|
|
|
|
|
|
const encodedScript = Buffer.from(script, 'utf16le').toString('base64');
|
|
|
|
|
|
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
|
|
|
|
const child = spawn(powershell, [
|
|
|
|
|
|
'-NoLogo',
|
|
|
|
|
|
'-NoProfile',
|
|
|
|
|
|
'-NonInteractive',
|
|
|
|
|
|
'-ExecutionPolicy',
|
|
|
|
|
|
'Bypass',
|
|
|
|
|
|
'-WindowStyle',
|
|
|
|
|
|
'Hidden',
|
|
|
|
|
|
'-EncodedCommand',
|
|
|
|
|
|
encodedScript,
|
|
|
|
|
|
], {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
detached: true,
|
|
|
|
|
|
stdio: 'ignore',
|
|
|
|
|
|
windowsHide: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
child.unref();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasPid) {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
try {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
process.kill(-pid, 'SIGTERM');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
process.kill(pid, 'SIGTERM');
|
2026-04-20 15:41:15 +03:00
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 14:51:10 +03:00
|
|
|
|
|
|
|
|
|
|
const script = [
|
|
|
|
|
|
'pid="$1"',
|
|
|
|
|
|
'port="$2"',
|
|
|
|
|
|
'grace="$3"',
|
|
|
|
|
|
'if [ "$pid" -gt 0 ] 2>/dev/null; then kill -TERM "$pid" 2>/dev/null; kill -TERM "-$pid" 2>/dev/null; fi',
|
|
|
|
|
|
'sleep "$grace"',
|
|
|
|
|
|
'if [ "$pid" -gt 0 ] 2>/dev/null; then kill -KILL "-$pid" 2>/dev/null; kill -KILL "$pid" 2>/dev/null; fi',
|
|
|
|
|
|
'if [ "$port" -gt 0 ] 2>/dev/null && command -v lsof >/dev/null 2>&1; then for target in $(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null; lsof -ti ":$port" 2>/dev/null); do [ "$target" = "$$" ] || kill -KILL "$target" 2>/dev/null; done; fi',
|
|
|
|
|
|
].join('; ');
|
|
|
|
|
|
const child = spawn('/bin/sh', ['-c', script, 'openchamber-opencode-killer', normalizedPid, normalizedPort, String(OPENCODE_SHUTDOWN_GRACE_MS / 1000)], {
|
|
|
|
|
|
detached: true,
|
|
|
|
|
|
stdio: 'ignore',
|
|
|
|
|
|
windowsHide: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
child.unref();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const killSidecar = () => {
|
|
|
|
|
|
const handle = state.serverHandle;
|
|
|
|
|
|
state.serverHandle = null;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
state.sidecarUrl = null;
|
2026-06-03 14:51:10 +03:00
|
|
|
|
if (!handle) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
launchDetachedOpenCodeKiller(handle.getOpenCodeProcessInfo?.());
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log.warn('[electron] failed to launch OpenCode killer:', error);
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken = '') => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const home = JSON.stringify(os.homedir() || '');
|
|
|
|
|
|
const local = JSON.stringify(localOrigin || '');
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const apiBase = JSON.stringify(apiBaseUrl || '');
|
|
|
|
|
|
const token = JSON.stringify(clientToken || '');
|
|
|
|
|
|
const packagedOrigin = JSON.stringify(packagedUiOrigin());
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const macVersion = macosMajorVersion();
|
|
|
|
|
|
const outcome = JSON.stringify(bootOutcome ?? null);
|
|
|
|
|
|
return [
|
|
|
|
|
|
'(function(){',
|
2026-06-02 00:43:05 +03:00
|
|
|
|
`try{var __oc_local=${local};var __oc_api=${apiBase};var __oc_packaged=${packagedOrigin};var __oc_origin=window.location&&window.location.origin||'';var __oc_is_packaged=__oc_origin===__oc_packaged;var __oc_is_local=__oc_local&&__oc_origin===new URL(__oc_local).origin;window.__OPENCHAMBER_MACOS_MAJOR__=${macVersion};window.__OPENCHAMBER_LOCAL_ORIGIN__=__oc_local;window.__OPENCHAMBER_API_BASE_URL__=__oc_api;if(__oc_is_local||__oc_is_packaged){window.__OPENCHAMBER_HOME__=${home};}if((__oc_is_local||__oc_is_packaged)&&${token}){window.__OPENCHAMBER_CLIENT_TOKEN__=${token};}var __oc_bo=${outcome};if(__oc_bo){window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__=__oc_bo;}}catch(_e){}`,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
'}())',
|
|
|
|
|
|
].join('');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
|
|
|
|
|
|
if (envTargetUrl) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const status = probe?.status === 'unreachable'
|
|
|
|
|
|
? 'unreachable'
|
|
|
|
|
|
: probe?.status === 'incompatible'
|
|
|
|
|
|
? 'incompatible'
|
|
|
|
|
|
: probe?.status === 'wrong-service'
|
|
|
|
|
|
? 'wrong-service'
|
|
|
|
|
|
: 'ok';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const status = probe?.status === 'unreachable'
|
|
|
|
|
|
? 'unreachable'
|
|
|
|
|
|
: probe?.status === 'incompatible'
|
|
|
|
|
|
? 'incompatible'
|
|
|
|
|
|
: probe?.status === 'wrong-service'
|
|
|
|
|
|
? 'wrong-service'
|
|
|
|
|
|
: 'ok';
|
|
|
|
|
|
return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 `<!doctype html>
|
|
|
|
|
|
<html>
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8" />
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
|
|
<style>
|
|
|
|
|
|
:root { color-scheme: light dark; }
|
2026-05-08 00:17:32 +03:00
|
|
|
|
:root {
|
|
|
|
|
|
--splash-background: ${splashBgLight};
|
|
|
|
|
|
--splash-stroke: ${splashFgLight};
|
|
|
|
|
|
--splash-face-fill: rgba(0, 0, 0, 0.15);
|
|
|
|
|
|
--splash-cell-fill: rgba(0, 0, 0, 0.4);
|
|
|
|
|
|
--splash-logo-fill: var(--splash-stroke);
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
body {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-family: "IBM Plex Sans", sans-serif;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
height: 100vh;
|
2026-05-08 00:17:32 +03:00
|
|
|
|
background: var(--splash-background);
|
|
|
|
|
|
color: var(--splash-stroke);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
@media (prefers-color-scheme: dark) {
|
2026-05-08 00:17:32 +03:00
|
|
|
|
:root {
|
|
|
|
|
|
--splash-background: ${splashBgDark};
|
|
|
|
|
|
--splash-stroke: ${splashFgDark};
|
|
|
|
|
|
--splash-face-fill: rgba(255, 255, 255, 0.15);
|
|
|
|
|
|
--splash-cell-fill: rgba(255, 255, 255, 0.35);
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
2026-05-08 00:17:32 +03:00
|
|
|
|
@supports (color: color-mix(in srgb, white 50%, transparent)) {
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--splash-face-fill: color-mix(in srgb, var(--splash-stroke) 15%, transparent);
|
|
|
|
|
|
--splash-cell-fill: color-mix(in srgb, var(--splash-stroke) 35%, transparent);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.stack {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
justify-items: center;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-05-08 00:17:32 +03:00
|
|
|
|
<div class="stack">
|
|
|
|
|
|
<svg width="120" height="120" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading icon">
|
|
|
|
|
|
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
|
|
|
|
|
<path d="M50 50 L39.608 44 L39.608 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
|
|
|
|
<path d="M39.608 44 L29.216 38 L29.216 50 L39.608 56 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
|
|
|
|
<path d="M29.216 38 L18.824 32 L18.824 44 L29.216 50 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
|
|
|
|
<path d="M18.824 32 L8.432 26 L8.432 38 L18.824 44 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
|
|
|
|
|
<path d="M50 62 L39.608 56 L39.608 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
|
|
|
|
|
<path d="M39.608 56 L29.216 50 L29.216 62 L39.608 68 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
|
|
|
|
|
<path d="M29.216 50 L18.824 44 L18.824 56 L29.216 62 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
|
|
|
|
|
<path d="M18.824 44 L8.432 38 L8.432 50 L18.824 56 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
|
|
|
|
|
<path d="M50 74 L39.608 68 L39.608 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
|
|
|
|
|
<path d="M39.608 68 L29.216 62 L29.216 74 L39.608 80 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
|
|
|
|
|
<path d="M29.216 62 L18.824 56 L18.824 68 L29.216 74 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
|
|
|
|
<path d="M18.824 56 L8.432 50 L8.432 62 L18.824 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
|
|
|
|
<path d="M50 86 L39.608 80 L39.608 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
|
|
|
|
|
<path d="M39.608 80 L29.216 74 L29.216 86 L39.608 92 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
|
|
|
|
<path d="M29.216 74 L18.824 68 L18.824 80 L29.216 86 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
|
|
|
|
|
<path d="M18.824 68 L8.432 62 L8.432 74 L18.824 80 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
|
|
|
|
|
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
|
|
|
|
|
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
|
|
|
|
|
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
|
|
|
|
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
|
|
|
|
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
|
|
|
|
|
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
|
|
|
|
|
|
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
|
|
|
|
|
|
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
|
|
|
|
|
|
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
|
|
|
|
|
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
|
|
|
|
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
|
|
|
|
|
|
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
|
|
|
|
|
|
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
|
|
|
|
|
|
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
|
|
|
|
|
|
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
|
|
|
|
|
|
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
|
|
|
|
|
|
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
|
|
|
|
|
|
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
|
|
|
|
|
|
<g transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
|
|
|
|
|
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
|
|
|
|
|
|
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="var(--splash-logo-fill)" fill-opacity="0.4"/>
|
|
|
|
|
|
</g>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
2026-04-20 15:41:15 +03:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const extractCookieHeader = (response) => {
|
|
|
|
|
|
const getSetCookie = typeof response.headers?.getSetCookie === 'function'
|
|
|
|
|
|
? response.headers.getSetCookie.bind(response.headers)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const cookies = getSetCookie ? getSetCookie() : [];
|
|
|
|
|
|
const rawCookies = cookies.length > 0
|
|
|
|
|
|
? cookies
|
|
|
|
|
|
: String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/);
|
|
|
|
|
|
return rawCookies
|
|
|
|
|
|
.map((cookie) => String(cookie || '').split(';')[0].trim())
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join('; ');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => {
|
|
|
|
|
|
const baseUrl = normalizeHostUrl(String(url || ''));
|
|
|
|
|
|
const candidatePassword = typeof password === 'string' ? password : '';
|
|
|
|
|
|
if (!baseUrl) throw new Error('Invalid URL');
|
|
|
|
|
|
if (!candidatePassword) throw new Error('Password is required');
|
|
|
|
|
|
|
|
|
|
|
|
const loginResponse = await fetch(new URL('/auth/session', `${baseUrl}/`).toString(), {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
signal: AbortSignal.timeout(10_000),
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
password: candidatePassword,
|
|
|
|
|
|
trustDevice: trustDevice === true,
|
|
|
|
|
|
issueClientToken: true,
|
|
|
|
|
|
clientLabel: 'OpenChamber Desktop',
|
|
|
|
|
|
...(isLocalRuntimeUrl(baseUrl) ? {
|
|
|
|
|
|
clientKind: LOCAL_DESKTOP_CLIENT_KIND,
|
|
|
|
|
|
dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY,
|
|
|
|
|
|
} : {}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!loginResponse.ok) {
|
|
|
|
|
|
return { ok: false, status: loginResponse.status };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loginPayload = await loginResponse.json().catch(() => null);
|
|
|
|
|
|
if (typeof loginPayload?.clientToken === 'string' && loginPayload.clientToken.trim()) {
|
|
|
|
|
|
return { ok: true, token: loginPayload.clientToken.trim() };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const cookie = extractCookieHeader(loginResponse);
|
|
|
|
|
|
if (!cookie) {
|
|
|
|
|
|
return { ok: false, status: 401 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${baseUrl}/`).toString(), {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
signal: AbortSignal.timeout(10_000),
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
Cookie: cookie,
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
label: 'OpenChamber Desktop',
|
|
|
|
|
|
...(isLocalRuntimeUrl(baseUrl) ? {
|
|
|
|
|
|
clientKind: LOCAL_DESKTOP_CLIENT_KIND,
|
|
|
|
|
|
dedupeKey: LOCAL_DESKTOP_CLIENT_DEDUPE_KEY,
|
|
|
|
|
|
} : {}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!tokenResponse.ok) {
|
|
|
|
|
|
return { ok: false, status: tokenResponse.status };
|
|
|
|
|
|
}
|
|
|
|
|
|
const tokenPayload = await tokenResponse.json().catch(() => null);
|
|
|
|
|
|
const token = typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : '';
|
|
|
|
|
|
return token ? { ok: true, token } : { ok: false, status: 500 };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-09 18:38:01 +03:00
|
|
|
|
// macOS vibrancy: the native NSVisualEffectView needs a moment to settle after
|
|
|
|
|
|
// the window is shown/restored. Until then the renderer keeps the sidebar solid
|
|
|
|
|
|
// to avoid a flash of raw transparency; once ready it switches to the
|
|
|
|
|
|
// translucent overlay. We toggle this readiness over the same IPC bridge.
|
|
|
|
|
|
// Apply vibrancy to a live, on-screen window. Done after show (not in the
|
|
|
|
|
|
// BrowserWindow constructor) because macOS otherwise leaves the material
|
|
|
|
|
|
// uncomposited on a cold launch until the window gets a state change.
|
|
|
|
|
|
const applyMacVibrancy = (browserWindow) => {
|
|
|
|
|
|
if (process.platform !== 'darwin' || !browserWindow || browserWindow.isDestroyed()) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
browserWindow.setVibrancy('sidebar');
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setMacVibrancyReady = (browserWindow, ready) => {
|
|
|
|
|
|
if (process.platform !== 'darwin' || !browserWindow || browserWindow.isDestroyed()) return;
|
|
|
|
|
|
emitToWindow(browserWindow, 'openchamber:vibrancy-ready', { ready });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const scheduleMacVibrancyReady = (browserWindow, delayMs = 160) => {
|
|
|
|
|
|
if (process.platform !== 'darwin' || !browserWindow || browserWindow.isDestroyed()) return;
|
|
|
|
|
|
setMacVibrancyReady(browserWindow, false);
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
|
if (browserWindow.isDestroyed() || browserWindow.isMinimized() || !browserWindow.isVisible()) return;
|
|
|
|
|
|
setMacVibrancyReady(browserWindow, true);
|
|
|
|
|
|
}, delayMs);
|
|
|
|
|
|
if (typeof timer?.unref === 'function') timer.unref();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const setTaskbarProgress = (value) => {
|
|
|
|
|
|
if (process.platform !== 'win32') return;
|
|
|
|
|
|
for (const browserWindow of BrowserWindow.getAllWindows()) {
|
|
|
|
|
|
if (!browserWindow.isDestroyed()) {
|
|
|
|
|
|
browserWindow.setProgressBar(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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('/'))
|
|
|
|
|
|
: '';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
return { type, value, raw: trimmed };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const parseConnectDeepLinkPayload = (raw) => {
|
|
|
|
|
|
if (typeof raw !== 'string') return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(raw.trim());
|
|
|
|
|
|
if (url.protocol !== `${DEEP_LINK_PROTOCOL}:` || url.hostname !== 'connect') return null;
|
|
|
|
|
|
const version = url.searchParams.get('v');
|
|
|
|
|
|
const serverUrl = normalizeHostUrl(url.searchParams.get('server') || '');
|
|
|
|
|
|
const token = sanitizeClientTokenForStorage(url.searchParams.get('token') || '');
|
|
|
|
|
|
const label = typeof url.searchParams.get('label') === 'string'
|
|
|
|
|
|
? url.searchParams.get('label').trim()
|
|
|
|
|
|
: '';
|
|
|
|
|
|
if (version !== '1' || !serverUrl || !token) return null;
|
|
|
|
|
|
return { serverUrl, token, label: label || serverUrl };
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const importConnectDeepLink = async (payload) => {
|
|
|
|
|
|
if (!payload?.serverUrl || !payload?.token) return null;
|
|
|
|
|
|
const config = readDesktopHostsConfig();
|
|
|
|
|
|
const existing = config.hosts.find((host) => {
|
|
|
|
|
|
const hostUrl = normalizeHostUrl(host?.url || '');
|
|
|
|
|
|
const apiUrl = normalizeHostUrl(host?.apiUrl || host?.url || '');
|
|
|
|
|
|
return payload.serverUrl === hostUrl || payload.serverUrl === apiUrl;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const id = existing?.id || `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
|
|
|
|
const importedHost = {
|
|
|
|
|
|
...(existing || {}),
|
|
|
|
|
|
id,
|
|
|
|
|
|
label: payload.label || existing?.label || payload.serverUrl,
|
|
|
|
|
|
url: payload.serverUrl,
|
|
|
|
|
|
apiUrl: payload.serverUrl,
|
|
|
|
|
|
clientToken: payload.token,
|
|
|
|
|
|
};
|
|
|
|
|
|
const hosts = existing
|
|
|
|
|
|
? config.hosts.map((host) => (host.id === existing.id ? importedHost : host))
|
|
|
|
|
|
: [importedHost, ...config.hosts];
|
|
|
|
|
|
await writeDesktopHostsConfig({
|
|
|
|
|
|
...config,
|
|
|
|
|
|
hosts,
|
|
|
|
|
|
defaultHostId: config.defaultHostId || id,
|
|
|
|
|
|
initialHostChoiceCompleted: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
return id;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const switchToHostById = async (rawId) => {
|
|
|
|
|
|
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
const config = readDesktopHostsConfig();
|
|
|
|
|
|
let targetUrl = null;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
let apiBaseUrl = null;
|
|
|
|
|
|
let clientToken = '';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (id === LOCAL_HOST_ID) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin);
|
|
|
|
|
|
apiBaseUrl = state.sidecarUrl;
|
|
|
|
|
|
clientToken = readDesktopLocalClientToken();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
const host = config.hosts.find((entry) => entry.id === id);
|
|
|
|
|
|
if (!host) {
|
|
|
|
|
|
log.warn('[electron] deep-link host not found:', id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url;
|
|
|
|
|
|
apiBaseUrl = host.apiUrl || host.url;
|
|
|
|
|
|
clientToken = host.clientToken || '';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (!targetUrl || !apiBaseUrl) {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
log.warn('[electron] deep-link host has no target URL:', id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const bootOutcome = id === LOCAL_HOST_ID
|
|
|
|
|
|
? { target: 'local', status: 'ok' }
|
2026-06-02 00:43:05 +03:00
|
|
|
|
: { target: 'remote', status: 'ok', hostId: id, url: apiBaseUrl };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
log.info('[electron] switching to host', { id, bootOutcome });
|
2026-06-02 00:43:05 +03:00
|
|
|
|
await activateMainWindow(targetUrl, state.localOrigin, bootOutcome, { apiBaseUrl, clientToken });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const confirmConnectDeepLink = async (payload) => {
|
|
|
|
|
|
// A connect deep-link can be triggered from a browser/email/chat with no
|
|
|
|
|
|
// in-app interaction. Importing it stores a client token and points all of
|
|
|
|
|
|
// this app's API traffic at the given server, so require explicit consent
|
|
|
|
|
|
// BEFORE writing anything to the hosts config. Never surface the token.
|
|
|
|
|
|
const visible = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed() && window.isVisible());
|
|
|
|
|
|
if (visible) {
|
|
|
|
|
|
visible.show();
|
|
|
|
|
|
visible.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
const options = {
|
|
|
|
|
|
type: 'warning',
|
|
|
|
|
|
title: 'Connect to OpenChamber server?',
|
|
|
|
|
|
message: `Connect to "${payload.label}"?`,
|
|
|
|
|
|
detail:
|
|
|
|
|
|
`This will add ${payload.serverUrl} as a remote instance and route this app's activity ` +
|
|
|
|
|
|
'through it. Only continue if you trust this server and started the connection yourself.',
|
|
|
|
|
|
buttons: ['Connect', 'Cancel'],
|
|
|
|
|
|
defaultId: 1,
|
|
|
|
|
|
cancelId: 1,
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = visible
|
|
|
|
|
|
? await dialog.showMessageBox(visible, options)
|
|
|
|
|
|
: await dialog.showMessageBox(options);
|
|
|
|
|
|
return result.response === 0;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log.warn('[electron] connect deep-link confirmation failed:', error);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const dispatchDeepLink = (link) => {
|
|
|
|
|
|
if (!link) return;
|
|
|
|
|
|
log.info('[electron] dispatching deep-link', { type: link.type, valueLen: link.value?.length || 0 });
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (link.type === 'connect') {
|
|
|
|
|
|
const payload = parseConnectDeepLinkPayload(link.raw);
|
|
|
|
|
|
if (!payload) {
|
|
|
|
|
|
log.warn('[electron] invalid connect deep-link payload');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
void confirmConnectDeepLink(payload).then((confirmed) => {
|
|
|
|
|
|
if (!confirmed) {
|
|
|
|
|
|
log.info('[electron] connect deep-link declined by user');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
return importConnectDeepLink(payload).then((id) => {
|
|
|
|
|
|
if (id) void switchToHostById(id);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-10 00:20:23 +03:00
|
|
|
|
// Mini-chat draft windows are not deduplicated, so this must reach the renderer
|
|
|
|
|
|
// exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer
|
|
|
|
|
|
// resolves the active directory/project and opens the window.
|
|
|
|
|
|
const dispatchOpenMiniChat = (browserWindow) => {
|
|
|
|
|
|
const target = browserWindow && !browserWindow.isDestroyed() ? browserWindow : getMenuTargetWindow();
|
|
|
|
|
|
if (target) emitToWindow(target, 'openchamber:open-mini-chat');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const dispatchCheckForUpdates = () => {
|
|
|
|
|
|
emitToAllWindows('openchamber:check-for-updates');
|
|
|
|
|
|
for (const browserWindow of BrowserWindow.getAllWindows()) {
|
|
|
|
|
|
dispatchDomEventToWindow(browserWindow, 'openchamber:check-for-updates');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-13 15:57:24 +03:00
|
|
|
|
const reloadMenuTargetWindow = () => {
|
|
|
|
|
|
const target = getMenuTargetWindow();
|
|
|
|
|
|
if (!target || target.isDestroyed()) return;
|
|
|
|
|
|
target.webContents.reload();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const relaunchFromMenu = () => {
|
|
|
|
|
|
prepareForQuit();
|
2026-06-03 14:51:10 +03:00
|
|
|
|
app.relaunch();
|
|
|
|
|
|
app.exit(0);
|
2026-05-13 15:57:24 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const nextWindowLabel = () => {
|
|
|
|
|
|
const value = state.windowCounter++;
|
|
|
|
|
|
return value === 1 ? 'main' : `main-${value}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const readThemeSource = () => {
|
|
|
|
|
|
const settings = readSettingsRoot();
|
2026-04-22 15:53:57 +03:00
|
|
|
|
// 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';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return 'system';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const getWindowIconPath = () => {
|
|
|
|
|
|
if (process.platform !== 'win32' && process.platform !== 'linux') {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
const iconPath = isDev
|
|
|
|
|
|
? path.join(__dirname, 'resources', 'icons', 'icon.ico')
|
|
|
|
|
|
: path.join(process.resourcesPath, 'icons', 'icon.ico');
|
|
|
|
|
|
return fs.existsSync(iconPath) ? iconPath : undefined;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const canUseTitleBarOverlay = (browserWindow) => (
|
|
|
|
|
|
process.platform === 'win32' &&
|
|
|
|
|
|
Boolean(browserWindow?.__ocTitleBarOverlayEnabled) &&
|
|
|
|
|
|
typeof browserWindow.setTitleBarOverlay === 'function' &&
|
|
|
|
|
|
!browserWindow.isDestroyed()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }) => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const saved = restoreGeometry ? readWindowState() : null;
|
|
|
|
|
|
const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number';
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const restoredBounds = useSaved ? clampWindowBoundsToVisibleWorkArea(saved) : null;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const desktopLocalOrigin = state.localOrigin || state.sidecarUrl || '';
|
|
|
|
|
|
const desktopApiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : (state.apiBaseUrl || '');
|
|
|
|
|
|
const desktopClientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : (state.clientToken || '');
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const desktopHome = os.homedir() || '';
|
|
|
|
|
|
const desktopMacosMajor = String(macosMajorVersion());
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32';
|
2026-06-09 18:38:01 +03:00
|
|
|
|
// macOS vibrancy, on by default; users can disable it (Appearance settings).
|
|
|
|
|
|
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const titleBarOverlayEnabled = false;
|
|
|
|
|
|
const autoHidesNativeMenuBar = process.platform !== 'darwin';
|
|
|
|
|
|
const windowIconPath = getWindowIconPath();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const options = {
|
|
|
|
|
|
title: 'OpenChamber',
|
2026-05-26 11:13:59 -04:00
|
|
|
|
...(Number.isFinite(restoredBounds?.x) && Number.isFinite(restoredBounds?.y)
|
|
|
|
|
|
? { x: restoredBounds.x, y: restoredBounds.y }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
width: restoredBounds?.width ?? 1280,
|
|
|
|
|
|
height: restoredBounds?.height ?? 800,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
minWidth: MIN_WINDOW_WIDTH,
|
|
|
|
|
|
minHeight: MIN_WINDOW_HEIGHT,
|
2026-05-26 11:13:59 -04:00
|
|
|
|
icon: windowIconPath,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
show: false,
|
2026-06-09 18:38:01 +03:00
|
|
|
|
backgroundColor: useVibrancy ? '#00000000' : '#151313',
|
|
|
|
|
|
// Vibrancy is applied after the window is shown (see applyMacVibrancy), not
|
|
|
|
|
|
// here: setting it in the constructor leaves the material uncomposited on a
|
|
|
|
|
|
// cold launch until a window event. No `transparent: true` either — vibrancy
|
|
|
|
|
|
// alone is enough and composites reliably once applied to a live window.
|
2026-05-26 11:13:59 -04:00
|
|
|
|
frame: process.platform === 'win32' ? false : undefined,
|
|
|
|
|
|
autoHideMenuBar: autoHidesNativeMenuBar,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
// 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.
|
2026-05-26 11:13:59 -04:00
|
|
|
|
titleBarStyle: usesCustomTitleBar ? 'hidden' : 'default',
|
|
|
|
|
|
titleBarOverlay: titleBarOverlayEnabled,
|
2026-04-22 18:58:52 +03:00
|
|
|
|
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
webPreferences: {
|
|
|
|
|
|
additionalArguments: [
|
|
|
|
|
|
`--openchamber-local-origin=${desktopLocalOrigin}`,
|
2026-06-02 00:43:05 +03:00
|
|
|
|
`--openchamber-api-base-url=${desktopApiBaseUrl}`,
|
|
|
|
|
|
`--openchamber-client-token=${desktopClientToken}`,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
`--openchamber-home=${desktopHome}`,
|
|
|
|
|
|
`--openchamber-macos-major=${desktopMacosMajor}`,
|
2026-06-09 18:38:01 +03:00
|
|
|
|
`--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
`--openchamber-boot-outcome=${JSON.stringify(state.bootOutcome || null)}`,
|
|
|
|
|
|
],
|
|
|
|
|
|
preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'),
|
2026-05-27 01:14:07 +03:00
|
|
|
|
backgroundThrottling: false,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
contextIsolation: true,
|
|
|
|
|
|
nodeIntegration: false,
|
2026-05-14 14:45:04 +03:00
|
|
|
|
webviewTag: true,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
// 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 —
|
2026-06-03 02:42:00 +03:00
|
|
|
|
// the preload would fail to load and the desktop bridge would be unavailable.
|
2026-04-20 15:41:15 +03:00
|
|
|
|
sandbox: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const browserWindow = new BrowserWindow(options);
|
|
|
|
|
|
browserWindow.__ocLabel = label || nextWindowLabel();
|
2026-06-02 00:43:05 +03:00
|
|
|
|
browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken };
|
|
|
|
|
|
browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken);
|
2026-05-26 11:13:59 -04:00
|
|
|
|
browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
if (useSaved && saved.maximized) {
|
|
|
|
|
|
browserWindow.maximize();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
browserWindow.on('focus', () => {
|
|
|
|
|
|
state.focusedWindowIds.add(browserWindow.id);
|
|
|
|
|
|
});
|
|
|
|
|
|
browserWindow.on('blur', () => {
|
|
|
|
|
|
state.focusedWindowIds.delete(browserWindow.id);
|
|
|
|
|
|
});
|
2026-04-22 18:58:52 +03:00
|
|
|
|
|
|
|
|
|
|
// 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 {}
|
|
|
|
|
|
};
|
2026-06-09 18:38:01 +03:00
|
|
|
|
browserWindow.on('minimize', () => {
|
|
|
|
|
|
refreshTrafficLights();
|
|
|
|
|
|
setMacVibrancyReady(browserWindow, false);
|
|
|
|
|
|
});
|
2026-04-22 18:58:52 +03:00
|
|
|
|
browserWindow.on('restore', () => {
|
|
|
|
|
|
refreshTrafficLights();
|
|
|
|
|
|
setTimeout(refreshTrafficLights, 250);
|
2026-06-09 18:38:01 +03:00
|
|
|
|
scheduleMacVibrancyReady(browserWindow, 180);
|
2026-04-22 18:58:52 +03:00
|
|
|
|
});
|
2026-06-09 18:38:01 +03:00
|
|
|
|
// Only suppress vibrancy around the minimize/restore cycle (it flashes raw
|
|
|
|
|
|
// transparency during the genie animation). A plain show — cold launch from
|
|
|
|
|
|
// the dock, un-hide — must NOT suppress, or the sidebar gets stuck solid
|
|
|
|
|
|
// when the post-show `ready` re-enable is skipped while the window is still
|
|
|
|
|
|
// animating in.
|
2026-04-22 18:58:52 +03:00
|
|
|
|
browserWindow.on('show', refreshTrafficLights);
|
|
|
|
|
|
browserWindow.on('focus', refreshTrafficLights);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
browserWindow.on('resize', () => {
|
2026-06-05 13:31:52 +03:00
|
|
|
|
if (process.platform === 'darwin') {
|
|
|
|
|
|
emitToWindow(browserWindow, 'openchamber:window-resized');
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
debounceWindowStatePersist(browserWindow, false);
|
|
|
|
|
|
});
|
2026-05-26 11:13:59 -04:00
|
|
|
|
browserWindow.on('maximize', () => {
|
|
|
|
|
|
emitToWindow(browserWindow, 'openchamber:window-maximized-changed', { maximized: true });
|
|
|
|
|
|
debounceWindowStatePersist(browserWindow, false);
|
|
|
|
|
|
});
|
|
|
|
|
|
browserWindow.on('unmaximize', () => {
|
|
|
|
|
|
emitToWindow(browserWindow, 'openchamber:window-maximized-changed', { maximized: false });
|
|
|
|
|
|
debounceWindowStatePersist(browserWindow, false);
|
|
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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 (process.platform !== 'darwin') {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
if (state.installingUpdate) {
|
|
|
|
|
|
app.quit();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
performConfirmedQuit();
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (url.protocol === 'devtools:') return true;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
|
|
|
|
if (state.localOrigin) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (new URL(state.localOrigin).origin === url.origin) return true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (state.sidecarUrl) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (new URL(state.sidecarUrl).origin === url.origin) return true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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', () => {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const initScript = browserWindow.__ocInitScript || state.initScript;
|
|
|
|
|
|
if (initScript) {
|
|
|
|
|
|
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-06-09 18:38:01 +03:00
|
|
|
|
if (useVibrancy) applyMacVibrancy(browserWindow);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (url) {
|
|
|
|
|
|
void navigateWindow(browserWindow, url);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
void navigateWindow(
|
|
|
|
|
|
browserWindow,
|
|
|
|
|
|
`data:text/html;charset=utf-8,${encodeURIComponent(buildStartupSplashHtml())}`,
|
|
|
|
|
|
{ allowAbort: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return browserWindow;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = {}) => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
state.localOrigin = localOrigin;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl;
|
|
|
|
|
|
state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : '';
|
2026-04-20 15:41:15 +03:00
|
|
|
|
state.bootOutcome = bootOutcome ?? null;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
state.initScript = buildInitScript(localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
const mainWindow = state.mainWindow;
|
|
|
|
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
mainWindow.__ocRuntimeConfig = { apiBaseUrl: state.apiBaseUrl || '', clientToken: state.clientToken || '' };
|
|
|
|
|
|
mainWindow.__ocInitScript = state.initScript;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
await navigateWindow(mainWindow, url, { allowAbort: true });
|
|
|
|
|
|
mainWindow.show();
|
|
|
|
|
|
mainWindow.focus();
|
|
|
|
|
|
return mainWindow;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state.mainWindow = createBrowserWindow({
|
|
|
|
|
|
label: 'main',
|
|
|
|
|
|
restoreGeometry: true,
|
|
|
|
|
|
url,
|
2026-06-02 00:43:05 +03:00
|
|
|
|
runtimeConfig,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
return state.mainWindow;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 01:36:11 +03:00
|
|
|
|
const openMainWindow = async () => {
|
|
|
|
|
|
if (!state.localOrigin) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl();
|
|
|
|
|
|
return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken });
|
2026-05-26 01:36:11 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const config = readDesktopHostsConfig();
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const localUiUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin);
|
2026-05-26 01:36:11 +03:00
|
|
|
|
const host = config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID
|
|
|
|
|
|
? config.hosts.find((entry) => entry.id === config.defaultHostId)
|
|
|
|
|
|
: null;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const apiBaseUrl = host?.apiUrl || host?.url || state.sidecarUrl || state.apiBaseUrl || '';
|
|
|
|
|
|
const clientToken = host?.clientToken || resolveStoredClientTokenForUrl(apiBaseUrl, config) || state.clientToken || '';
|
|
|
|
|
|
const targetUrl = host?.url && apiBaseUrl && !state.unreachableHosts.has(apiBaseUrl)
|
|
|
|
|
|
? (shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url)
|
|
|
|
|
|
: localUiUrl;
|
|
|
|
|
|
return activateMainWindow(targetUrl, state.localOrigin, state.bootOutcome, { apiBaseUrl, clientToken });
|
2026-05-26 01:36:11 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const createAdditionalWindow = async (url, runtimeConfig = {}) => {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (!state.localOrigin) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const browserWindow = createBrowserWindow({
|
|
|
|
|
|
label: nextWindowLabel(),
|
|
|
|
|
|
restoreGeometry: false,
|
|
|
|
|
|
url,
|
2026-06-02 00:43:05 +03:00
|
|
|
|
runtimeConfig,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
return browserWindow;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-08 12:22:59 +03:00
|
|
|
|
const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => {
|
|
|
|
|
|
const base = state.localOrigin || state.sidecarUrl;
|
|
|
|
|
|
if (!base) {
|
|
|
|
|
|
throw new Error('Local UI is not available');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const url = new URL(shouldUsePackagedUi() ? buildPackagedUiUrl('/mini-chat.html') : '/mini-chat.html', base);
|
2026-05-08 12:22:59 +03:00
|
|
|
|
url.searchParams.set('mode', mode === 'session' ? 'session' : 'draft');
|
|
|
|
|
|
if (sessionId) url.searchParams.set('sessionId', sessionId);
|
|
|
|
|
|
if (directory) url.searchParams.set('directory', directory);
|
|
|
|
|
|
if (projectId) url.searchParams.set('projectId', projectId);
|
|
|
|
|
|
return url.toString();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const miniChatSessionWindowKey = (runtimeConfig, sessionId) => {
|
|
|
|
|
|
const runtimeKey = normalizeHostUrl(runtimeConfig?.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '') || 'local';
|
|
|
|
|
|
return `${runtimeKey}\n${sessionId}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getWindowRuntimeConfig = (browserWindow) => {
|
|
|
|
|
|
const fallback = {
|
|
|
|
|
|
apiBaseUrl: state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '',
|
|
|
|
|
|
clientToken: state.clientToken || '',
|
|
|
|
|
|
};
|
|
|
|
|
|
if (!browserWindow || browserWindow.isDestroyed()) return fallback;
|
|
|
|
|
|
const config = browserWindow.__ocRuntimeConfig;
|
|
|
|
|
|
return {
|
|
|
|
|
|
apiBaseUrl: typeof config?.apiBaseUrl === 'string' ? config.apiBaseUrl : fallback.apiBaseUrl,
|
|
|
|
|
|
clientToken: typeof config?.clientToken === 'string' ? config.clientToken : fallback.clientToken,
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', projectId = '', runtimeConfig = {} } = {}) => {
|
|
|
|
|
|
const effectiveRuntimeConfig = {
|
|
|
|
|
|
apiBaseUrl: normalizeHostUrl(runtimeConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || ''),
|
|
|
|
|
|
clientToken: sanitizeClientTokenForStorage(runtimeConfig.clientToken || state.clientToken || ''),
|
|
|
|
|
|
};
|
|
|
|
|
|
const sessionWindowKey = mode === 'session' && sessionId ? miniChatSessionWindowKey(effectiveRuntimeConfig, sessionId) : '';
|
2026-05-08 12:22:59 +03:00
|
|
|
|
if (mode === 'session' && sessionId) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const existing = state.miniChatWindowsBySession.get(sessionWindowKey);
|
2026-05-08 12:22:59 +03:00
|
|
|
|
if (existing && !existing.isDestroyed()) {
|
|
|
|
|
|
if (existing.isMinimized()) existing.restore();
|
|
|
|
|
|
existing.show();
|
|
|
|
|
|
existing.focus();
|
|
|
|
|
|
return existing;
|
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
state.miniChatWindowsBySession.delete(sessionWindowKey);
|
2026-05-08 12:22:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const desktopLocalOrigin = state.localOrigin || '';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const desktopApiBaseUrl = effectiveRuntimeConfig.apiBaseUrl || '';
|
|
|
|
|
|
const desktopClientToken = effectiveRuntimeConfig.clientToken || '';
|
2026-05-08 12:22:59 +03:00
|
|
|
|
const desktopHome = os.homedir() || '';
|
|
|
|
|
|
const desktopMacosMajor = String(macosMajorVersion());
|
2026-06-09 18:38:01 +03:00
|
|
|
|
// macOS vibrancy, on by default; users can disable it (Appearance settings).
|
|
|
|
|
|
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
|
2026-05-08 12:22:59 +03:00
|
|
|
|
const browserWindow = new BrowserWindow({
|
|
|
|
|
|
title: 'OpenChamber Mini Chat',
|
|
|
|
|
|
width: MINI_CHAT_WINDOW_WIDTH,
|
|
|
|
|
|
height: MINI_CHAT_WINDOW_HEIGHT,
|
|
|
|
|
|
minWidth: MINI_CHAT_MIN_WINDOW_WIDTH,
|
|
|
|
|
|
minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT,
|
2026-05-26 11:13:59 -04:00
|
|
|
|
icon: getWindowIconPath(),
|
2026-05-08 12:22:59 +03:00
|
|
|
|
show: false,
|
2026-06-09 18:38:01 +03:00
|
|
|
|
backgroundColor: useVibrancy ? '#00000000' : '#151313',
|
|
|
|
|
|
// Vibrancy is applied after the window is shown (see applyMacVibrancy), not
|
|
|
|
|
|
// here: setting it in the constructor leaves the material uncomposited on a
|
|
|
|
|
|
// cold launch until a window event. No `transparent: true` either — vibrancy
|
|
|
|
|
|
// alone is enough and composites reliably once applied to a live window.
|
2026-06-04 18:43:07 +03:00
|
|
|
|
frame: process.platform === 'win32' ? false : undefined,
|
|
|
|
|
|
autoHideMenuBar: process.platform !== 'darwin',
|
|
|
|
|
|
titleBarStyle: process.platform === 'darwin' || process.platform === 'win32' ? 'hidden' : 'default',
|
2026-05-08 12:22:59 +03:00
|
|
|
|
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
|
|
|
|
|
|
webPreferences: {
|
|
|
|
|
|
additionalArguments: [
|
|
|
|
|
|
`--openchamber-local-origin=${desktopLocalOrigin}`,
|
2026-06-02 00:43:05 +03:00
|
|
|
|
`--openchamber-api-base-url=${desktopApiBaseUrl}`,
|
|
|
|
|
|
`--openchamber-client-token=${desktopClientToken}`,
|
2026-05-08 12:22:59 +03:00
|
|
|
|
`--openchamber-home=${desktopHome}`,
|
|
|
|
|
|
`--openchamber-macos-major=${desktopMacosMajor}`,
|
|
|
|
|
|
],
|
|
|
|
|
|
preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'),
|
2026-05-27 01:14:07 +03:00
|
|
|
|
backgroundThrottling: false,
|
2026-05-08 12:22:59 +03:00
|
|
|
|
contextIsolation: true,
|
|
|
|
|
|
nodeIntegration: false,
|
2026-05-14 14:45:04 +03:00
|
|
|
|
webviewTag: true,
|
|
|
|
|
|
// sandbox must stay off
|
2026-05-08 12:22:59 +03:00
|
|
|
|
sandbox: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
browserWindow.__ocLabel = nextWindowLabel();
|
2026-06-02 00:43:05 +03:00
|
|
|
|
browserWindow.__ocRuntimeConfig = effectiveRuntimeConfig;
|
|
|
|
|
|
browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken);
|
2026-05-08 12:22:59 +03:00
|
|
|
|
browserWindow.__ocMiniChat = true;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
browserWindow.__ocMiniChatSessionId = sessionWindowKey;
|
2026-05-08 12:22:59 +03:00
|
|
|
|
browserWindow.__ocPinned = false;
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (sessionWindowKey) {
|
|
|
|
|
|
state.miniChatWindowsBySession.set(sessionWindowKey, browserWindow);
|
2026-05-08 12:22:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
browserWindow.on('closed', () => {
|
|
|
|
|
|
if (browserWindow.__ocMiniChatSessionId) {
|
|
|
|
|
|
const existing = state.miniChatWindowsBySession.get(browserWindow.__ocMiniChatSessionId);
|
|
|
|
|
|
if (existing?.id === browserWindow.id) {
|
|
|
|
|
|
state.miniChatWindowsBySession.delete(browserWindow.__ocMiniChatSessionId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (process.platform === 'darwin') {
|
|
|
|
|
|
const refreshTrafficLights = () => {
|
|
|
|
|
|
if (browserWindow.isDestroyed()) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
browserWindow.setWindowButtonVisibility(true);
|
|
|
|
|
|
browserWindow.setTrafficLightPosition({ x: 16, y: 17 });
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
};
|
2026-06-09 18:38:01 +03:00
|
|
|
|
// Suppress vibrancy only around minimize/restore, never on a plain show.
|
2026-05-08 12:22:59 +03:00
|
|
|
|
browserWindow.on('show', refreshTrafficLights);
|
|
|
|
|
|
browserWindow.on('focus', refreshTrafficLights);
|
2026-06-09 18:38:01 +03:00
|
|
|
|
browserWindow.on('minimize', () => setMacVibrancyReady(browserWindow, false));
|
|
|
|
|
|
browserWindow.on('restore', () => scheduleMacVibrancyReady(browserWindow, 180));
|
2026-05-08 12:22:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
browserWindow.once('ready-to-show', () => {
|
|
|
|
|
|
browserWindow.show();
|
|
|
|
|
|
browserWindow.focus();
|
2026-06-09 18:38:01 +03:00
|
|
|
|
if (useVibrancy) applyMacVibrancy(browserWindow);
|
2026-05-08 12:22:59 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
browserWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
|
|
|
|
void shell.openExternal(url).catch(() => {});
|
|
|
|
|
|
return { action: 'deny' };
|
|
|
|
|
|
});
|
|
|
|
|
|
browserWindow.webContents.on('will-navigate', (event, url) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const target = new URL(url);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const local = new URL(shouldUsePackagedUi() ? packagedUiOrigin() : (state.localOrigin || state.sidecarUrl || ''));
|
2026-05-08 12:22:59 +03:00
|
|
|
|
if (target.origin === local.origin) return;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
void shell.openExternal(url).catch(() => {});
|
|
|
|
|
|
});
|
|
|
|
|
|
browserWindow.webContents.on('dom-ready', () => {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const initScript = browserWindow.__ocInitScript || state.initScript;
|
|
|
|
|
|
if (initScript) {
|
|
|
|
|
|
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
|
2026-05-08 12:22:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await navigateWindow(browserWindow, buildMiniChatUrl({ mode, sessionId, directory, projectId }));
|
|
|
|
|
|
return browserWindow;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setMiniChatPinned = (browserWindow, pinned) => {
|
|
|
|
|
|
if (!browserWindow || browserWindow.isDestroyed()) {
|
|
|
|
|
|
throw new Error('Window is not available');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (browserWindow.__ocMiniChat !== true) {
|
|
|
|
|
|
throw new Error('Pinning is only available for Mini Chat windows');
|
|
|
|
|
|
}
|
|
|
|
|
|
const nextPinned = pinned === true;
|
|
|
|
|
|
browserWindow.__ocPinned = nextPinned;
|
|
|
|
|
|
if (nextPinned) {
|
|
|
|
|
|
browserWindow.setAlwaysOnTop(true, 'floating');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
browserWindow.setAlwaysOnTop(false);
|
|
|
|
|
|
if (process.platform === 'darwin') {
|
|
|
|
|
|
browserWindow.setVisibleOnAllWorkspaces(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return { pinned: nextPinned };
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const resolveMiniChatRuntimeConfig = (browserWindow, args = {}) => {
|
|
|
|
|
|
const windowConfig = getWindowRuntimeConfig(browserWindow);
|
|
|
|
|
|
const argApiBaseUrl = typeof args.apiBaseUrl === 'string' ? args.apiBaseUrl : '';
|
|
|
|
|
|
const targetUrl = normalizeHostUrl(argApiBaseUrl || windowConfig.apiBaseUrl || state.apiBaseUrl || state.localOrigin || state.sidecarUrl || '');
|
|
|
|
|
|
const providedToken = sanitizeClientTokenForStorage(args.clientToken);
|
|
|
|
|
|
const storedToken = targetUrl ? resolveStoredClientTokenForUrl(targetUrl) : '';
|
|
|
|
|
|
const windowToken = targetUrl && sameOrigin(windowConfig.apiBaseUrl, targetUrl) ? windowConfig.clientToken : '';
|
|
|
|
|
|
return {
|
|
|
|
|
|
apiBaseUrl: targetUrl,
|
|
|
|
|
|
clientToken: providedToken || windowToken || storedToken || '',
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const resolveInitialUrl = async () => {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const hmrApiPort = process.env.OPENCHAMBER_HMR_API_PORT || '3901';
|
|
|
|
|
|
const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173';
|
|
|
|
|
|
const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`;
|
|
|
|
|
|
const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`;
|
|
|
|
|
|
const localUrl = isDev && await waitForHealth(hmrApiUrl, 5_000, 100)
|
|
|
|
|
|
? hmrApiUrl
|
2026-04-20 15:41:15 +03:00
|
|
|
|
: await spawnLocalServer();
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const localUiUrl = shouldUsePackagedUi()
|
|
|
|
|
|
? buildPackagedUiUrl('/index.html')
|
|
|
|
|
|
: isDev && await waitForHealth(hmrUiUrl, 8_000, 100)
|
2026-05-26 11:13:59 -04:00
|
|
|
|
? hmrUiUrl
|
2026-04-20 15:41:15 +03:00
|
|
|
|
: localUrl;
|
|
|
|
|
|
|
|
|
|
|
|
state.sidecarUrl = localUrl;
|
|
|
|
|
|
const localAvailable = Boolean(localUrl);
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const localOrigin = new URL(localUrl).origin;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
let initialUrl = localUiUrl;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
let apiBaseUrl = localUrl;
|
|
|
|
|
|
let clientToken = readDesktopLocalClientToken();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
let remoteProbe = null;
|
|
|
|
|
|
|
|
|
|
|
|
const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || '');
|
|
|
|
|
|
const config = readDesktopHostsConfig();
|
|
|
|
|
|
if (envTarget) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
apiBaseUrl = envTarget;
|
|
|
|
|
|
clientToken = '';
|
|
|
|
|
|
initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
} else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) {
|
|
|
|
|
|
const host = config.hosts.find((entry) => entry.id === config.defaultHostId);
|
|
|
|
|
|
if (host?.url) {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
apiBaseUrl = host.apiUrl || host.url;
|
|
|
|
|
|
clientToken = host.clientToken || '';
|
|
|
|
|
|
initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (apiBaseUrl && apiBaseUrl !== localUrl) {
|
|
|
|
|
|
remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (remoteProbe.status === 'unreachable') {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
if (remoteProbe.status === 'unreachable') {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
state.unreachableHosts.add(apiBaseUrl);
|
|
|
|
|
|
apiBaseUrl = localUrl;
|
|
|
|
|
|
clientToken = readDesktopLocalClientToken();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
initialUrl = localUiUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bootOutcome = computeBootOutcome({
|
|
|
|
|
|
envTargetUrl: envTarget || null,
|
|
|
|
|
|
probe: remoteProbe,
|
|
|
|
|
|
config,
|
|
|
|
|
|
localAvailable,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
return { initialUrl, localOrigin, localUiUrl, bootOutcome, apiBaseUrl, clientToken };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
2026-05-12 03:59:40 -04:00
|
|
|
|
return { owner: 'openchamber', repo: 'openchamber' };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const total = Number(progress.total || 0);
|
|
|
|
|
|
const transferred = Number(progress.transferred || 0);
|
|
|
|
|
|
setTaskbarProgress(total > 0 ? Math.max(0, Math.min(1, transferred / total)) : 0.01);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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),
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
});
|
2026-04-22 00:57:58 +03:00
|
|
|
|
|
|
|
|
|
|
autoUpdater.on('update-downloaded', (info) => {
|
|
|
|
|
|
log.info(`[electron] update-downloaded version=${info?.version || 'unknown'}`);
|
2026-05-26 11:13:59 -04:00
|
|
|
|
setTaskbarProgress(-1);
|
2026-04-22 00:57:58 +03:00
|
|
|
|
if (state.pendingUpdate) {
|
|
|
|
|
|
state.pendingUpdate.downloaded = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
autoUpdater.on('error', (err) => {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
setTaskbarProgress(-1);
|
2026-04-22 00:57:58 +03:00
|
|
|
|
log.error('[electron] autoUpdater error', err);
|
|
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-04-20 22:28:32 +03:00
|
|
|
|
const nextInstances = Array.isArray(config?.instances) ? config.instances : [];
|
|
|
|
|
|
await mutateSettingsRoot((root) => {
|
|
|
|
|
|
root.desktopSshInstances = nextInstances;
|
|
|
|
|
|
});
|
|
|
|
|
|
return { instances: nextInstances };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const WINDOWS_CLI_BY_APP_ID = {
|
|
|
|
|
|
vscode: 'code.cmd',
|
|
|
|
|
|
cursor: 'cursor.cmd',
|
|
|
|
|
|
vscodium: 'codium.cmd',
|
|
|
|
|
|
windsurf: 'windsurf.cmd',
|
|
|
|
|
|
zed: 'zed.cmd',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const WINDOWS_APP_EXECUTABLES = {
|
|
|
|
|
|
terminal: ['wt.exe', 'WindowsTerminal.exe'],
|
2026-06-04 18:11:51 +03:00
|
|
|
|
vscode: ['code.exe', 'code.cmd'],
|
|
|
|
|
|
cursor: ['cursor.exe', 'cursor.cmd'],
|
|
|
|
|
|
vscodium: ['codium.exe', 'codium.cmd'],
|
|
|
|
|
|
windsurf: ['windsurf.exe', 'windsurf.cmd'],
|
|
|
|
|
|
zed: ['zed.exe', 'zed.cmd'],
|
2026-05-26 11:13:59 -04:00
|
|
|
|
'visual-studio': ['devenv.exe'],
|
|
|
|
|
|
'sublime-text': ['subl.exe', 'sublime_text.exe'],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const WINDOWS_APP_ID_BY_NAME = new Map([
|
|
|
|
|
|
['finder', 'finder'],
|
|
|
|
|
|
['file explorer', 'finder'],
|
|
|
|
|
|
['terminal', 'terminal'],
|
|
|
|
|
|
['windows terminal', 'terminal'],
|
|
|
|
|
|
['visual studio code', 'vscode'],
|
|
|
|
|
|
['cursor', 'cursor'],
|
|
|
|
|
|
['vscodium', 'vscodium'],
|
|
|
|
|
|
['windsurf', 'windsurf'],
|
|
|
|
|
|
['zed', 'zed'],
|
|
|
|
|
|
['visual studio', 'visual-studio'],
|
|
|
|
|
|
['sublime text', 'sublime-text'],
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const getWindowsAppIdForName = (appName) => WINDOWS_APP_ID_BY_NAME.get(String(appName || '').trim().toLowerCase()) || '';
|
|
|
|
|
|
|
|
|
|
|
|
const runWhere = (program) => {
|
|
|
|
|
|
const result = spawnSync('where.exe', [program], { encoding: 'utf8', windowsHide: true });
|
|
|
|
|
|
if (result.error || result.status !== 0) return null;
|
|
|
|
|
|
const first = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
|
|
|
|
return first || null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const findWindowsExecutable = (appId) => {
|
|
|
|
|
|
for (const program of WINDOWS_APP_EXECUTABLES[appId] || []) {
|
|
|
|
|
|
const resolved = runWhere(program);
|
|
|
|
|
|
if (resolved) return resolved;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 18:11:51 +03:00
|
|
|
|
const resolveWindowsScriptIconExecutable = (scriptPath) => {
|
|
|
|
|
|
if (!scriptPath || !/\.(?:cmd|bat)$/i.test(scriptPath)) return null;
|
|
|
|
|
|
let source = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
source = fs.readFileSync(scriptPath, 'utf8');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const scriptDir = path.dirname(scriptPath);
|
|
|
|
|
|
const matches = [...source.matchAll(/(?:(?:%~dp0|%~dp0\\|%~dp0\/|\.\.\\|\.\.\/|[A-Za-z]:\\|[A-Za-z]:\/)[^"'\r\n]*?\.exe)/gi)];
|
|
|
|
|
|
for (const match of matches) {
|
|
|
|
|
|
const raw = String(match[0] || '').replace(/^%~dp0[\\/]?/i, '').trim();
|
|
|
|
|
|
const candidate = path.isAbsolute(raw) ? raw : path.resolve(scriptDir, raw);
|
|
|
|
|
|
if (fs.existsSync(candidate)) return candidate;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let windowsTerminalPackagePathCache;
|
|
|
|
|
|
|
|
|
|
|
|
const resolveWindowsTerminalPackagePath = () => {
|
|
|
|
|
|
if (windowsTerminalPackagePathCache !== undefined) return windowsTerminalPackagePathCache;
|
|
|
|
|
|
|
|
|
|
|
|
const powershell = runWhere('powershell.exe') || runWhere('pwsh.exe');
|
|
|
|
|
|
if (powershell) {
|
|
|
|
|
|
const command = '$packages = @(' +
|
|
|
|
|
|
'Get-AppxPackage -Name Microsoft.WindowsTerminal -ErrorAction SilentlyContinue;' +
|
|
|
|
|
|
'Get-AppxPackage -Name Microsoft.WindowsTerminalPreview -ErrorAction SilentlyContinue' +
|
|
|
|
|
|
') | Where-Object { $_.InstallLocation } | Sort-Object Version -Descending; ' +
|
|
|
|
|
|
'if ($packages) { $packages[0].InstallLocation }';
|
|
|
|
|
|
const result = spawnSync(powershell, ['-NoProfile', '-NonInteractive', '-Command', command], {
|
|
|
|
|
|
encoding: 'utf8',
|
|
|
|
|
|
windowsHide: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!result.error && result.status === 0) {
|
|
|
|
|
|
const packagePath = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
|
|
|
|
if (packagePath && fs.existsSync(packagePath)) {
|
|
|
|
|
|
windowsTerminalPackagePathCache = packagePath;
|
|
|
|
|
|
return windowsTerminalPackagePathCache;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const programFilesRoots = [process.env.ProgramW6432, process.env.ProgramFiles, 'C:\\Program Files']
|
|
|
|
|
|
.filter((value, index, values) => typeof value === 'string' && value && values.indexOf(value) === index);
|
|
|
|
|
|
for (const root of programFilesRoots) {
|
|
|
|
|
|
const windowsAppsPath = path.join(root, 'WindowsApps');
|
|
|
|
|
|
let entries = [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
entries = fs.readdirSync(windowsAppsPath, { withFileTypes: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const packageNames = entries
|
|
|
|
|
|
.filter((entry) => entry.isDirectory())
|
|
|
|
|
|
.map((entry) => entry.name)
|
|
|
|
|
|
.filter((name) => /^Microsoft\.WindowsTerminal(?:Preview)?_.*__8wekyb3d8bbwe$/i.test(name))
|
|
|
|
|
|
.sort()
|
|
|
|
|
|
.reverse();
|
|
|
|
|
|
const stable = packageNames.find((name) => /^Microsoft\.WindowsTerminal_/i.test(name));
|
|
|
|
|
|
const selected = stable || packageNames[0];
|
|
|
|
|
|
if (selected) {
|
|
|
|
|
|
windowsTerminalPackagePathCache = path.join(windowsAppsPath, selected);
|
|
|
|
|
|
return windowsTerminalPackagePathCache;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
windowsTerminalPackagePathCache = null;
|
|
|
|
|
|
return windowsTerminalPackagePathCache;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resolveWindowsTerminalIconPath = () => {
|
|
|
|
|
|
const packagePath = resolveWindowsTerminalPackagePath();
|
|
|
|
|
|
if (!packagePath) return null;
|
|
|
|
|
|
const candidates = [
|
|
|
|
|
|
path.join(packagePath, 'Images', 'Square44x44Logo.targetsize-96_altform-unplated.png'),
|
|
|
|
|
|
path.join(packagePath, 'Images', 'Square44x44Logo.targetsize-96.png'),
|
|
|
|
|
|
path.join(packagePath, 'Images', 'StoreLogo.scale-200.png'),
|
|
|
|
|
|
path.join(packagePath, 'Images', 'StoreLogo.scale-100.png'),
|
|
|
|
|
|
];
|
|
|
|
|
|
return candidates.find((candidate) => fs.existsSync(candidate)) || null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resolveWindowsTerminalExecutable = () => {
|
|
|
|
|
|
const packagePath = resolveWindowsTerminalPackagePath();
|
|
|
|
|
|
if (packagePath) {
|
|
|
|
|
|
const executable = path.join(packagePath, 'WindowsTerminal.exe');
|
|
|
|
|
|
if (fs.existsSync(executable)) return executable;
|
|
|
|
|
|
}
|
|
|
|
|
|
return findWindowsExecutable('terminal');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const imageFileToDataUrl = (filePath) => {
|
|
|
|
|
|
if (!filePath) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return `data:image/png;base64,${fs.readFileSync(filePath).toString('base64')}`;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const resolveWindowsAppIconExecutable = ({ appId, appName }) => {
|
|
|
|
|
|
if (appId === 'finder') {
|
|
|
|
|
|
const explorerPath = path.join(process.env.SystemRoot || 'C:\\Windows', 'explorer.exe');
|
|
|
|
|
|
return fs.existsSync(explorerPath) ? explorerPath : 'explorer.exe';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (appId === 'terminal') {
|
|
|
|
|
|
return resolveWindowsTerminalExecutable();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const executable = findWindowsExecutable(appId) || findWindowsAppNameExecutable(appName);
|
|
|
|
|
|
if (!executable) return null;
|
|
|
|
|
|
if (/\.exe$/i.test(executable)) return executable;
|
|
|
|
|
|
return resolveWindowsScriptIconExecutable(executable) || executable;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const windowsIconToDataUrl = async (executablePath) => {
|
|
|
|
|
|
if (!executablePath) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const image = await app.getFileIcon(executablePath, { size: 'normal' });
|
|
|
|
|
|
if (image.isEmpty()) return null;
|
|
|
|
|
|
return image.toDataURL();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const findWindowsAppNameExecutable = (appName) => {
|
|
|
|
|
|
const program = `${String(appName || '').trim()}.exe`.replace(/\s+/g, '');
|
|
|
|
|
|
return program === '.exe' ? null : runWhere(program);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const isWindowsAppInstalled = ({ appId, appName }) => {
|
|
|
|
|
|
if (appId === 'finder') return true;
|
|
|
|
|
|
if (appId === 'terminal') return Boolean(findWindowsExecutable('terminal'));
|
|
|
|
|
|
if (findWindowsExecutable(appId)) return true;
|
|
|
|
|
|
return Boolean(findWindowsAppNameExecutable(appName));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 18:11:51 +03:00
|
|
|
|
const buildWindowsInstalledApps = async (apps) => {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const seen = new Set();
|
2026-06-04 18:11:51 +03:00
|
|
|
|
const names = (Array.isArray(apps) ? apps : [])
|
2026-05-26 11:13:59 -04:00
|
|
|
|
.map((appName) => String(appName || '').trim())
|
|
|
|
|
|
.filter((appName) => appName && !seen.has(appName) && seen.add(appName))
|
2026-06-04 18:11:51 +03:00
|
|
|
|
.filter((appName) => isWindowsAppInstalled({ appId: getWindowsAppIdForName(appName), appName }));
|
|
|
|
|
|
const results = [];
|
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
|
const appId = getWindowsAppIdForName(name);
|
|
|
|
|
|
const executablePath = resolveWindowsAppIconExecutable({ appId, appName: name });
|
|
|
|
|
|
const iconDataUrl = appId === 'terminal'
|
|
|
|
|
|
? imageFileToDataUrl(resolveWindowsTerminalIconPath()) || await windowsIconToDataUrl(executablePath)
|
|
|
|
|
|
: await windowsIconToDataUrl(executablePath);
|
|
|
|
|
|
results.push({ name, iconDataUrl });
|
|
|
|
|
|
}
|
|
|
|
|
|
return results;
|
2026-05-26 11:13:59 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildWindowsOpenProjectSpecs = ({ projectPath, appId, appName }) => {
|
|
|
|
|
|
if (appId === 'finder') {
|
|
|
|
|
|
return [{ program: 'explorer.exe', args: [projectPath] }];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (appId === 'terminal') {
|
|
|
|
|
|
const specs = [];
|
|
|
|
|
|
const terminal = findWindowsExecutable('terminal');
|
|
|
|
|
|
if (terminal) {
|
|
|
|
|
|
specs.push({ program: terminal, args: ['-d', projectPath] });
|
|
|
|
|
|
}
|
|
|
|
|
|
const shell = runWhere('pwsh.exe') || runWhere('powershell.exe');
|
|
|
|
|
|
if (shell) {
|
2026-06-04 18:11:51 +03:00
|
|
|
|
specs.push({ program: shell, args: ['-NoExit', '-Command', `Set-Location -LiteralPath ${JSON.stringify(projectPath)}`], shellStart: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
const commandPrompt = process.env.ComSpec || runWhere('cmd.exe');
|
|
|
|
|
|
if (commandPrompt) {
|
|
|
|
|
|
specs.push({ program: commandPrompt, args: ['/k', 'cd', '/d', projectPath], shellStart: true });
|
2026-05-26 11:13:59 -04:00
|
|
|
|
}
|
|
|
|
|
|
return specs;
|
|
|
|
|
|
}
|
|
|
|
|
|
const specs = [];
|
|
|
|
|
|
const cli = WINDOWS_CLI_BY_APP_ID[appId];
|
|
|
|
|
|
if (cli) {
|
|
|
|
|
|
const resolvedCli = runWhere(cli);
|
|
|
|
|
|
if (resolvedCli) {
|
|
|
|
|
|
specs.push({ program: resolvedCli, args: [projectPath] });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const exe = findWindowsExecutable(appId);
|
|
|
|
|
|
if (exe) {
|
|
|
|
|
|
specs.push({ program: exe, args: [projectPath] });
|
|
|
|
|
|
}
|
|
|
|
|
|
const namedExe = findWindowsAppNameExecutable(appName);
|
|
|
|
|
|
if (namedExe && !specs.some((spec) => spec.program === namedExe)) {
|
|
|
|
|
|
specs.push({ program: namedExe, args: [projectPath] });
|
|
|
|
|
|
}
|
|
|
|
|
|
return specs;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildWindowsOpenFileSpecs = ({ filePath, appId, appName }) => {
|
|
|
|
|
|
if (appId === 'finder') {
|
|
|
|
|
|
return [{ program: 'explorer.exe', args: ['/select,', filePath] }];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (appId === 'terminal') {
|
|
|
|
|
|
return buildWindowsOpenProjectSpecs({ projectPath: path.dirname(filePath), appId, appName });
|
|
|
|
|
|
}
|
|
|
|
|
|
const specs = [];
|
|
|
|
|
|
const cli = WINDOWS_CLI_BY_APP_ID[appId];
|
|
|
|
|
|
if (cli) {
|
|
|
|
|
|
const resolvedCli = runWhere(cli);
|
|
|
|
|
|
if (resolvedCli) {
|
|
|
|
|
|
specs.push({ program: resolvedCli, args: [filePath] });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const exe = findWindowsExecutable(appId);
|
|
|
|
|
|
if (exe) {
|
|
|
|
|
|
specs.push({ program: exe, args: [filePath] });
|
|
|
|
|
|
}
|
|
|
|
|
|
const namedExe = findWindowsAppNameExecutable(appName);
|
|
|
|
|
|
if (namedExe && !specs.some((spec) => spec.program === namedExe)) {
|
|
|
|
|
|
specs.push({ program: namedExe, args: [filePath] });
|
|
|
|
|
|
}
|
|
|
|
|
|
return specs;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
|
|
|
|
|
|
|
|
|
|
|
|
const resolveWindowsLaunchProgram = (program) => {
|
|
|
|
|
|
if (path.isAbsolute(program)) {
|
|
|
|
|
|
return fs.existsSync(program) ? program : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return runWhere(program);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const launchWindowsCommandScript = (spec, program) => {
|
|
|
|
|
|
const commandLine = ['call', quoteWindowsCommandArg(program), ...spec.args.map(quoteWindowsCommandArg)].join(' ');
|
|
|
|
|
|
const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commandLine], {
|
|
|
|
|
|
detached: true,
|
|
|
|
|
|
stdio: 'ignore',
|
|
|
|
|
|
windowsHide: false,
|
|
|
|
|
|
windowsVerbatimArguments: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
child.unref();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const launchWindowsSpec = (spec) => {
|
|
|
|
|
|
const program = resolveWindowsLaunchProgram(spec.program);
|
|
|
|
|
|
if (!program) {
|
|
|
|
|
|
throw new Error('program not found');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-04 18:11:51 +03:00
|
|
|
|
if (spec.shellStart) {
|
|
|
|
|
|
const commandLine = ['start', '""', quoteWindowsCommandArg(program), ...spec.args.map(quoteWindowsCommandArg)].join(' ');
|
|
|
|
|
|
const child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', commandLine], {
|
|
|
|
|
|
detached: true,
|
|
|
|
|
|
stdio: 'ignore',
|
|
|
|
|
|
windowsHide: false,
|
|
|
|
|
|
windowsVerbatimArguments: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
child.unref();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (/\.(cmd|bat)$/i.test(program)) {
|
|
|
|
|
|
launchWindowsCommandScript(spec, program);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const child = spawn(program, spec.args, {
|
|
|
|
|
|
detached: true,
|
|
|
|
|
|
stdio: 'ignore',
|
|
|
|
|
|
windowsHide: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
child.unref();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const runSpecChain = (specs, appName) => {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (!Array.isArray(specs) || specs.length === 0) {
|
|
|
|
|
|
throw new Error(`Failed to open in ${appName}: no launch candidates`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
|
const failures = [];
|
|
|
|
|
|
for (const spec of specs) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
launchWindowsSpec(spec);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
failures.push(`${spec.program}: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const failures = [];
|
|
|
|
|
|
for (const spec of specs) {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const result = spawnSync(spec.program, spec.args, { stdio: 'ignore', windowsHide: true });
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-26 01:36:11 +03:00
|
|
|
|
case 'desktop_get_launch_at_login': {
|
|
|
|
|
|
if (process.platform !== 'darwin') return { supported: false, enabled: false };
|
|
|
|
|
|
const settings = app.getLoginItemSettings();
|
|
|
|
|
|
return { supported: true, enabled: settings.openAtLogin === true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_set_launch_at_login': {
|
|
|
|
|
|
if (process.platform !== 'darwin') return { supported: false, enabled: false };
|
|
|
|
|
|
const enabled = args.enabled === true;
|
|
|
|
|
|
app.setLoginItemSettings({
|
|
|
|
|
|
openAtLogin: enabled,
|
|
|
|
|
|
openAsHidden: enabled,
|
|
|
|
|
|
args: enabled ? [BACKGROUND_START_ARG] : [],
|
|
|
|
|
|
});
|
|
|
|
|
|
const settings = app.getLoginItemSettings();
|
|
|
|
|
|
return { supported: true, enabled: settings.openAtLogin === true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 14:45:04 +03:00
|
|
|
|
case 'desktop_browser_capture_page': {
|
|
|
|
|
|
const wcId = Number.isFinite(args.webContentsId) ? Math.trunc(args.webContentsId) : null;
|
|
|
|
|
|
if (wcId === null || wcId < 0) throw new Error('webContentsId is required');
|
|
|
|
|
|
const wc = webContents.fromId(wcId);
|
|
|
|
|
|
if (!wc || wc.isDestroyed()) throw new Error('WebContents not found');
|
|
|
|
|
|
const image = await wc.capturePage();
|
|
|
|
|
|
const buffer = image.toJPEG(82);
|
|
|
|
|
|
return {
|
|
|
|
|
|
mime: 'image/jpeg',
|
|
|
|
|
|
base64: buffer.toString('base64'),
|
|
|
|
|
|
width: image.getSize().width,
|
|
|
|
|
|
height: image.getSize().height,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
|
case 'desktop_capture_page_rect': {
|
|
|
|
|
|
if (!browserWindow || browserWindow.isDestroyed()) {
|
|
|
|
|
|
throw new Error('Window is not available');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bounds = browserWindow.getContentBounds();
|
|
|
|
|
|
const x = Number.isFinite(args.x) ? Math.max(0, Math.floor(args.x)) : 0;
|
|
|
|
|
|
const y = Number.isFinite(args.y) ? Math.max(0, Math.floor(args.y)) : 0;
|
|
|
|
|
|
const width = Number.isFinite(args.width) ? Math.max(1, Math.floor(args.width)) : 1;
|
|
|
|
|
|
const height = Number.isFinite(args.height) ? Math.max(1, Math.floor(args.height)) : 1;
|
|
|
|
|
|
const clampedX = Math.min(x, Math.max(0, bounds.width - 1));
|
|
|
|
|
|
const clampedY = Math.min(y, Math.max(0, bounds.height - 1));
|
|
|
|
|
|
const rect = {
|
|
|
|
|
|
x: clampedX,
|
|
|
|
|
|
y: clampedY,
|
|
|
|
|
|
width: Math.min(width, Math.max(1, bounds.width - clampedX)),
|
|
|
|
|
|
height: Math.min(height, Math.max(1, bounds.height - clampedY)),
|
|
|
|
|
|
};
|
|
|
|
|
|
if (rect.width * rect.height > MAX_CAPTURE_PAGE_RECT_AREA) {
|
|
|
|
|
|
throw new Error('Capture area is too large');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const image = await browserWindow.webContents.capturePage(rect);
|
|
|
|
|
|
const buffer = image.toJPEG(82);
|
|
|
|
|
|
return {
|
|
|
|
|
|
mime: 'image/jpeg',
|
|
|
|
|
|
base64: buffer.toString('base64'),
|
|
|
|
|
|
width: image.getSize().width,
|
|
|
|
|
|
height: image.getSize().height,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-06-10 00:20:23 +03:00
|
|
|
|
case 'desktop_tray_update':
|
|
|
|
|
|
if (state.trayController) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.trayController.update(args || {});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log.warn('[electron] tray update failed', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 14:24:20 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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': {
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (process.platform === 'win32') {
|
2026-06-04 18:11:51 +03:00
|
|
|
|
if (appId === 'finder') {
|
|
|
|
|
|
const error = await shell.openPath(projectPath);
|
|
|
|
|
|
if (error) throw new Error(error);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-05-26 11:13:59 -04:00
|
|
|
|
runSpecChain(buildWindowsOpenProjectSpecs({ projectPath, appId, appName }), appName);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (process.platform !== 'darwin') {
|
|
|
|
|
|
throw new Error('desktop_open_in_app is only supported on macOS and Windows');
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_open_file_in_app': {
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (process.platform === 'win32') {
|
|
|
|
|
|
runSpecChain(buildWindowsOpenFileSpecs({ filePath, appId, appName }), appName);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (process.platform !== 'darwin') {
|
|
|
|
|
|
throw new Error('desktop_open_file_in_app is only supported on macOS and Windows');
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_filter_installed_apps': {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (process.platform === 'win32') {
|
2026-06-04 18:11:51 +03:00
|
|
|
|
return (await buildWindowsInstalledApps(args.apps)).map((app) => app.name);
|
2026-05-26 11:13:59 -04:00
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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': {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (process.platform === 'win32') {
|
2026-06-04 18:11:51 +03:00
|
|
|
|
const names = Array.isArray(args.apps) ? args.apps : [];
|
|
|
|
|
|
const results = [];
|
|
|
|
|
|
for (const name of names) {
|
|
|
|
|
|
const appName = String(name || '').trim();
|
|
|
|
|
|
if (!appName) continue;
|
|
|
|
|
|
const appId = getWindowsAppIdForName(appName);
|
|
|
|
|
|
const dataUrl = appId === 'terminal'
|
|
|
|
|
|
? imageFileToDataUrl(resolveWindowsTerminalIconPath()) || await windowsIconToDataUrl(resolveWindowsAppIconExecutable({ appId, appName }))
|
|
|
|
|
|
: await windowsIconToDataUrl(resolveWindowsAppIconExecutable({ appId, appName }));
|
|
|
|
|
|
if (dataUrl) results.push({ app: appName, data_url: dataUrl });
|
|
|
|
|
|
}
|
|
|
|
|
|
return results;
|
2026-05-26 11:13:59 -04:00
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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': {
|
|
|
|
|
|
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 () => {
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const apps = process.platform === 'win32'
|
2026-06-04 18:11:51 +03:00
|
|
|
|
? await buildWindowsInstalledApps(args.apps)
|
2026-05-26 11:13:59 -04:00
|
|
|
|
: await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (process.platform !== 'darwin' && process.platform !== 'win32') {
|
|
|
|
|
|
throw new Error('desktop_get_installed_apps is only supported on macOS and Windows');
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (!hasCache || isCacheStale || args.force === true) {
|
|
|
|
|
|
void refresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
return { apps: cachedApps, hasCache, isCacheStale };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_hosts_get':
|
2026-06-02 00:43:05 +03:00
|
|
|
|
return {
|
|
|
|
|
|
...readDesktopHostsConfig(),
|
|
|
|
|
|
localOrigin: state.localOrigin || state.sidecarUrl || null,
|
|
|
|
|
|
};
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
case 'desktop_hosts_set': {
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const nextConfigInput = args.input || args.config || {};
|
|
|
|
|
|
await writeDesktopHostsConfig(nextConfigInput);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const updatedConfig = readDesktopHostsConfig();
|
|
|
|
|
|
const envTarget = normalizeHostUrl(process.env.OPENCHAMBER_SERVER_URL || '');
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (Object.prototype.hasOwnProperty.call(nextConfigInput, 'localClientToken') && isLocalRuntimeUrl(state.apiBaseUrl || state.sidecarUrl || state.localOrigin || '')) {
|
|
|
|
|
|
state.clientToken = readDesktopLocalClientToken();
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
state.bootOutcome = computeBootOutcome({
|
|
|
|
|
|
envTargetUrl: envTarget || null,
|
|
|
|
|
|
probe: null,
|
|
|
|
|
|
config: updatedConfig,
|
|
|
|
|
|
localAvailable: Boolean(state.sidecarUrl || state.localOrigin),
|
|
|
|
|
|
});
|
2026-06-02 00:43:05 +03:00
|
|
|
|
state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
case 'desktop_local_client_token_get':
|
|
|
|
|
|
return readDesktopLocalClientToken();
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
case 'desktop_host_probe':
|
2026-06-02 00:43:05 +03:00
|
|
|
|
return probeHostWithTimeout(String(args.url || ''), 2_000, String(args.clientToken || ''));
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_remote_password_login':
|
|
|
|
|
|
return loginRemoteAndIssueClientToken({
|
|
|
|
|
|
url: args.url,
|
|
|
|
|
|
password: args.password,
|
|
|
|
|
|
trustDevice: args.trustDevice === true,
|
|
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
case 'desktop_set_window_theme': {
|
|
|
|
|
|
const mode = typeof args.themeMode === 'string' ? args.themeMode : '';
|
|
|
|
|
|
const variant = typeof args.themeVariant === 'string' ? args.themeVariant : '';
|
2026-04-22 15:53:57 +03:00
|
|
|
|
// 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';
|
|
|
|
|
|
}
|
2026-05-26 11:13:59 -04:00
|
|
|
|
if (canUseTitleBarOverlay(browserWindow)) {
|
|
|
|
|
|
const useDark = nativeTheme.shouldUseDarkColors;
|
|
|
|
|
|
browserWindow.setTitleBarOverlay({
|
|
|
|
|
|
color: useDark ? '#151313' : '#f5f5f4',
|
|
|
|
|
|
symbolColor: useDark ? '#fafaf9' : '#1c1917',
|
|
|
|
|
|
height: 48,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_set_vibrancy': {
|
2026-06-09 18:38:01 +03:00
|
|
|
|
// Vibrancy + transparent backing are window-creation options, so the
|
|
|
|
|
|
// change only takes effect on a fresh launch. Persist the preference,
|
|
|
|
|
|
// then relaunch the app.
|
|
|
|
|
|
const enabled = args.enabled === true;
|
2026-04-20 22:28:32 +03:00
|
|
|
|
await mutateSettingsRoot((root) => {
|
2026-06-09 18:38:01 +03:00
|
|
|
|
root.desktopVibrancy = enabled;
|
|
|
|
|
|
});
|
|
|
|
|
|
setImmediate(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
prepareForQuit();
|
|
|
|
|
|
app.relaunch();
|
|
|
|
|
|
app.exit(0);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('[electron] desktop_set_vibrancy relaunch failed', err);
|
|
|
|
|
|
}
|
2026-04-20 22:28:32 +03:00
|
|
|
|
});
|
2026-06-09 18:38:01 +03:00
|
|
|
|
return { enabled, requiresRestart: true };
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_check_for_updates': {
|
|
|
|
|
|
const currentVersion = APP_VERSION;
|
|
|
|
|
|
let updateResult = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
updateResult = await autoUpdater.checkForUpdates();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateInfo = updateResult?.updateInfo;
|
|
|
|
|
|
const nextVersion =
|
|
|
|
|
|
(typeof updateInfo?.version === 'string' && updateInfo.version) ||
|
|
|
|
|
|
currentVersion;
|
|
|
|
|
|
const available = compareSemver(nextVersion, currentVersion) > 0;
|
|
|
|
|
|
const body =
|
|
|
|
|
|
(typeof updateInfo?.releaseNotes === 'string' && updateInfo.releaseNotes.trim() ? updateInfo.releaseNotes : null) ||
|
|
|
|
|
|
await parseRelevantChangelogNotes(currentVersion, nextVersion);
|
2026-06-03 02:42:00 +03:00
|
|
|
|
state.pendingUpdate = available ? { version: nextVersion, electronUpdate: updateResult } : null;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return {
|
|
|
|
|
|
available,
|
|
|
|
|
|
currentVersion,
|
|
|
|
|
|
version: available ? nextVersion : null,
|
|
|
|
|
|
body: body || null,
|
|
|
|
|
|
date:
|
|
|
|
|
|
(typeof updateInfo?.releaseDate === 'string' && updateInfo.releaseDate) ||
|
2026-06-03 02:42:00 +03:00
|
|
|
|
null,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_download_and_install_update':
|
|
|
|
|
|
if (!state.pendingUpdate) {
|
|
|
|
|
|
throw new Error('No pending update');
|
|
|
|
|
|
}
|
2026-05-26 11:13:59 -04:00
|
|
|
|
setTaskbarProgress(0.01);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
|
|
|
|
|
|
event: 'Started',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
contentLength: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
2026-05-26 11:13:59 -04:00
|
|
|
|
try {
|
|
|
|
|
|
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;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setTaskbarProgress(-1);
|
2026-04-22 23:46:38 +03:00
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
2026-04-22 00:57:58 +03:00
|
|
|
|
case 'desktop_restart': {
|
|
|
|
|
|
const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged);
|
|
|
|
|
|
log.info(`[electron] desktop_restart applyUpdate=${applyUpdate} packaged=${app.isPackaged}`);
|
2026-04-22 23:46:38 +03:00
|
|
|
|
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 {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-22 00:57:58 +03:00
|
|
|
|
// 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) {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
killSidecar();
|
2026-04-22 23:46:38 +03:00
|
|
|
|
autoUpdater.quitAndInstall();
|
2026-04-22 00:57:58 +03:00
|
|
|
|
} else {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
prepareForQuit();
|
2026-04-22 00:57:58 +03:00
|
|
|
|
app.relaunch();
|
|
|
|
|
|
app.exit(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log.error('[electron] desktop_restart failed', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return null;
|
2026-04-22 00:57:58 +03:00
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
|
|
|
|
|
|
case 'desktop_get_lan_address':
|
|
|
|
|
|
return await detectLanIPv4Address();
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_new_window': {
|
|
|
|
|
|
const config = readDesktopHostsConfig();
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const localUiUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
let targetUrl = localUiUrl;
|
2026-06-02 00:43:05 +03:00
|
|
|
|
let runtimeConfig = {
|
|
|
|
|
|
apiBaseUrl: state.sidecarUrl || state.localOrigin || '',
|
|
|
|
|
|
clientToken: readDesktopLocalClientToken(),
|
|
|
|
|
|
};
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) {
|
|
|
|
|
|
const host = config.hosts.find((entry) => entry.id === config.defaultHostId);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const apiUrl = host?.apiUrl || host?.url;
|
|
|
|
|
|
if (host?.url && apiUrl && !state.unreachableHosts.has(apiUrl)) {
|
|
|
|
|
|
targetUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : host.url;
|
|
|
|
|
|
runtimeConfig = {
|
|
|
|
|
|
apiBaseUrl: normalizeHostUrl(apiUrl),
|
|
|
|
|
|
clientToken: sanitizeClientTokenForStorage(host.clientToken),
|
|
|
|
|
|
};
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
await createAdditionalWindow(targetUrl, runtimeConfig);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_new_window_at_url': {
|
|
|
|
|
|
const targetUrl = normalizeHostUrl(String(args.url || ''));
|
|
|
|
|
|
if (!targetUrl) {
|
|
|
|
|
|
throw new Error('Invalid URL');
|
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const config = readDesktopHostsConfig();
|
|
|
|
|
|
const providedToken = typeof args.clientToken === 'string' ? args.clientToken : '';
|
|
|
|
|
|
const clientToken = sanitizeClientTokenForStorage(providedToken) || resolveStoredClientTokenForUrl(targetUrl, config);
|
|
|
|
|
|
let windowUrl = targetUrl;
|
|
|
|
|
|
const runtimeConfig = { apiBaseUrl: targetUrl, clientToken };
|
|
|
|
|
|
if (shouldUsePackagedUi()) {
|
|
|
|
|
|
windowUrl = buildPackagedUiUrl('/index.html');
|
|
|
|
|
|
}
|
|
|
|
|
|
await createAdditionalWindow(windowUrl, runtimeConfig);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 12:22:59 +03:00
|
|
|
|
case 'desktop_open_session_mini_chat_window': {
|
|
|
|
|
|
const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
|
|
|
|
|
|
if (!sessionId) throw new Error('Session id is required');
|
|
|
|
|
|
const directory = typeof args.directory === 'string' ? args.directory.trim() : '';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
await createMiniChatWindow({ mode: 'session', sessionId, directory, runtimeConfig: resolveMiniChatRuntimeConfig(browserWindow, args) });
|
2026-05-08 12:22:59 +03:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_open_draft_mini_chat_window': {
|
|
|
|
|
|
const directory = typeof args.directory === 'string' ? args.directory.trim() : '';
|
|
|
|
|
|
const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : '';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
await createMiniChatWindow({ mode: 'draft', directory, projectId, runtimeConfig: resolveMiniChatRuntimeConfig(browserWindow, args) });
|
2026-05-08 12:22:59 +03:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_set_window_pinned':
|
|
|
|
|
|
return setMiniChatPinned(browserWindow, args.pinned === true);
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_get_window_pinned':
|
|
|
|
|
|
return { pinned: Boolean(browserWindow?.__ocPinned) };
|
|
|
|
|
|
|
2026-06-10 00:20:23 +03:00
|
|
|
|
case 'desktop_focus_main_window': {
|
|
|
|
|
|
const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : '';
|
|
|
|
|
|
const directory = typeof args.directory === 'string' ? args.directory.trim() : '';
|
|
|
|
|
|
const mode = typeof args.mode === 'string' ? args.mode.trim() : '';
|
|
|
|
|
|
const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : '';
|
|
|
|
|
|
const hasMainWindow = state.mainWindow && !state.mainWindow.isDestroyed();
|
|
|
|
|
|
|
|
|
|
|
|
// No live main window (e.g. "Open in main window" from a mini-chat after
|
|
|
|
|
|
// the main window was closed): create one and open the session in it. A
|
|
|
|
|
|
// fresh window can't take an immediate emit, so queue the session as a
|
|
|
|
|
|
// pending deep-link and let did-finish-load flush it once ready.
|
|
|
|
|
|
if (!hasMainWindow) {
|
|
|
|
|
|
if (sessionId) pendingDeepLinks.push({ type: 'session', value: sessionId });
|
|
|
|
|
|
await openMainWindow();
|
2026-05-08 12:22:59 +03:00
|
|
|
|
return { focused: true };
|
|
|
|
|
|
}
|
2026-06-10 00:20:23 +03:00
|
|
|
|
|
|
|
|
|
|
if (state.mainWindow.isMinimized()) state.mainWindow.restore();
|
|
|
|
|
|
state.mainWindow.show();
|
|
|
|
|
|
state.mainWindow.focus();
|
|
|
|
|
|
if (sessionId) {
|
|
|
|
|
|
emitToWindow(state.mainWindow, 'openchamber:open-session', { sessionId, directory });
|
|
|
|
|
|
} else if (mode === 'draft') {
|
|
|
|
|
|
emitToWindow(state.mainWindow, 'openchamber:open-draft-session', { directory, projectId });
|
|
|
|
|
|
}
|
|
|
|
|
|
return { focused: true };
|
|
|
|
|
|
}
|
2026-05-08 12:22:59 +03:00
|
|
|
|
|
|
|
|
|
|
case 'desktop_close_current_window':
|
|
|
|
|
|
if (browserWindow && !browserWindow.isDestroyed()) {
|
|
|
|
|
|
browserWindow.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
case 'desktop_minimize_current_window':
|
|
|
|
|
|
if (browserWindow && !browserWindow.isDestroyed()) {
|
|
|
|
|
|
browserWindow.minimize();
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_toggle_current_window_maximized':
|
|
|
|
|
|
if (browserWindow && !browserWindow.isDestroyed()) {
|
|
|
|
|
|
if (browserWindow.isMaximized()) {
|
|
|
|
|
|
browserWindow.unmaximize();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
browserWindow.maximize();
|
|
|
|
|
|
}
|
|
|
|
|
|
return { maximized: browserWindow.isMaximized() };
|
|
|
|
|
|
}
|
|
|
|
|
|
return { maximized: false };
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_get_current_window_state':
|
|
|
|
|
|
return { maximized: Boolean(browserWindow && !browserWindow.isDestroyed() && browserWindow.isMaximized()) };
|
|
|
|
|
|
|
|
|
|
|
|
case 'desktop_show_app_menu': {
|
|
|
|
|
|
if (!browserWindow || browserWindow.isDestroyed()) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const menu = Menu.getApplicationMenu() || buildAutoHiddenMenu();
|
|
|
|
|
|
const x = Number.isFinite(Number(args.x)) ? Math.max(0, Math.round(Number(args.x))) : undefined;
|
|
|
|
|
|
const y = Number.isFinite(Number(args.y)) ? Math.max(0, Math.round(Number(args.y))) : undefined;
|
|
|
|
|
|
menu.popup({ window: browserWindow, x, y });
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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: [
|
2026-05-20 17:29:00 +03:00
|
|
|
|
{ label: 'About OpenChamber', click: () => dispatchAction('about') },
|
2026-04-20 15:41:15 +03:00
|
|
|
|
{
|
|
|
|
|
|
label: 'Check for Updates',
|
|
|
|
|
|
click: () => dispatchCheckForUpdates(),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Settings', accelerator: 'Cmd+,', click: () => dispatchAction('settings') },
|
2026-05-13 15:57:24 +03:00
|
|
|
|
{ label: 'Reload Webview', click: () => reloadMenuTargetWindow() },
|
|
|
|
|
|
{ label: 'Restart', click: () => relaunchFromMenu() },
|
2026-04-30 18:37:37 +03:00
|
|
|
|
{ label: 'Command Palette', accelerator: 'Cmd+P', click: () => dispatchAction('command-palette') },
|
2026-04-20 15:41:15 +03:00
|
|
|
|
{ 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') },
|
2026-06-10 00:20:23 +03:00
|
|
|
|
// registerAccelerator:false → show the shortcut hint but let the
|
|
|
|
|
|
// renderer own the (customizable) key binding, avoiding a double open.
|
|
|
|
|
|
{ label: 'New Mini Chat', accelerator: 'Cmd+Alt+N', registerAccelerator: false, click: () => dispatchOpenMiniChat() },
|
2026-04-20 15:41:15 +03:00
|
|
|
|
{ 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: [
|
2026-05-20 17:29:00 +03:00
|
|
|
|
{ label: 'Toggle Right Sidebar', accelerator: 'Cmd+B', click: () => dispatchAction('toggle-right-sidebar') },
|
|
|
|
|
|
{ label: 'Open Git Sidebar', accelerator: 'Cmd+Shift+G', click: () => dispatchAction('open-right-sidebar-git') },
|
|
|
|
|
|
{ label: 'Open Files Sidebar', accelerator: 'Cmd+Shift+F', click: () => dispatchAction('open-right-sidebar-files') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Toggle Terminal Dock', accelerator: 'Cmd+J', click: () => dispatchAction('toggle-terminal') },
|
|
|
|
|
|
{ label: 'Toggle Terminal Expanded', accelerator: 'Cmd+Shift+J', click: () => dispatchAction('toggle-terminal-expanded') },
|
2026-04-20 15:41:15 +03:00
|
|
|
|
{ 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) },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 11:13:59 -04:00
|
|
|
|
const buildAutoHiddenMenu = () => {
|
|
|
|
|
|
const dispatchAction = (action) => dispatchMenuAction(action);
|
|
|
|
|
|
const handleCopyAction = () => {
|
|
|
|
|
|
BrowserWindow.getFocusedWindow()?.webContents.copy();
|
|
|
|
|
|
dispatchAction('copy');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return Menu.buildFromTemplate([
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'OpenChamber',
|
|
|
|
|
|
submenu: [
|
|
|
|
|
|
{ label: 'About OpenChamber', click: () => dispatchAction('about') },
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Check for Updates',
|
|
|
|
|
|
click: () => dispatchCheckForUpdates(),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Settings', accelerator: 'Ctrl+,', click: () => dispatchAction('settings') },
|
|
|
|
|
|
{ label: 'Reload Webview', click: () => reloadMenuTargetWindow() },
|
|
|
|
|
|
{ label: 'Restart', click: () => relaunchFromMenu() },
|
|
|
|
|
|
{ label: 'Command Palette', accelerator: 'Ctrl+P', click: () => dispatchAction('command-palette') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ role: 'quit' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'File',
|
|
|
|
|
|
submenu: [
|
|
|
|
|
|
{ label: 'New Window', accelerator: 'Ctrl+Shift+Alt+N', click: () => void handleInvoke(null, 'desktop_new_window') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'New Session', accelerator: 'Ctrl+N', click: () => dispatchAction('new-session') },
|
|
|
|
|
|
{ label: 'New Worktree', accelerator: 'Ctrl+Shift+N', click: () => dispatchAction('new-worktree-session') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Add Workspace', click: () => dispatchAction('change-workspace') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ role: 'quit' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Edit',
|
|
|
|
|
|
submenu: [
|
|
|
|
|
|
{ role: 'undo' },
|
|
|
|
|
|
{ role: 'redo' },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ role: 'cut' },
|
|
|
|
|
|
{ label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() },
|
|
|
|
|
|
{ role: 'paste' },
|
|
|
|
|
|
{ role: 'selectAll' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'View',
|
|
|
|
|
|
submenu: [
|
|
|
|
|
|
{ role: 'reload' },
|
|
|
|
|
|
{ role: 'forceReload' },
|
|
|
|
|
|
...(isDev ? [{ role: 'toggleDevTools' }] : []),
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Toggle Right Sidebar', accelerator: 'Ctrl+B', click: () => dispatchAction('toggle-right-sidebar') },
|
|
|
|
|
|
{ label: 'Open Git Sidebar', accelerator: 'Ctrl+Shift+G', click: () => dispatchAction('open-right-sidebar-git') },
|
|
|
|
|
|
{ label: 'Open Files Sidebar', accelerator: 'Ctrl+Shift+F', click: () => dispatchAction('open-right-sidebar-files') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Toggle Terminal Dock', accelerator: 'Ctrl+J', click: () => dispatchAction('toggle-terminal') },
|
|
|
|
|
|
{ label: 'Toggle Terminal Expanded', accelerator: 'Ctrl+Shift+J', click: () => dispatchAction('toggle-terminal-expanded') },
|
|
|
|
|
|
{ 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: 'Ctrl+L', click: () => dispatchAction('toggle-sidebar') },
|
|
|
|
|
|
{ label: 'Toggle Memory Debug', accelerator: 'Ctrl+Shift+D', click: () => dispatchAction('toggle-memory-debug') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ role: 'togglefullscreen' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Go',
|
|
|
|
|
|
submenu: [
|
|
|
|
|
|
{ label: 'Back', accelerator: 'Ctrl+[', click: () => dispatchAction('go-back') },
|
|
|
|
|
|
{ label: 'Forward', accelerator: 'Ctrl+]', click: () => dispatchAction('go-forward') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Previous Session', accelerator: 'Alt+Up', click: () => dispatchAction('previous-session') },
|
|
|
|
|
|
{ label: 'Next Session', accelerator: 'Alt+Down', click: () => dispatchAction('next-session') },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ label: 'Previous Project', accelerator: 'Ctrl+Alt+Up', click: () => dispatchAction('previous-project') },
|
|
|
|
|
|
{ label: 'Next Project', accelerator: 'Ctrl+Alt+Down', click: () => dispatchAction('next-project') },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Window',
|
|
|
|
|
|
submenu: [
|
|
|
|
|
|
{ role: 'minimize' },
|
|
|
|
|
|
{ role: 'togglefullscreen' },
|
|
|
|
|
|
{ type: 'separator' },
|
|
|
|
|
|
{ role: 'close' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: 'Help',
|
|
|
|
|
|
submenu: [
|
|
|
|
|
|
{ label: 'Keyboard Shortcuts', accelerator: 'Ctrl+.', click: () => dispatchAction('help-dialog') },
|
|
|
|
|
|
{ label: 'Show Diagnostics', accelerator: 'Ctrl+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) },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
contextMenu({
|
|
|
|
|
|
showInspectElement: isDev,
|
|
|
|
|
|
showSaveImageAs: true,
|
|
|
|
|
|
showCopyImage: true,
|
|
|
|
|
|
showCopyLink: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-28 01:56:43 +03:00
|
|
|
|
const loadUrlInsideWebContents = (contents, rawUrl) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(rawUrl);
|
|
|
|
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
|
|
|
|
if (contents.isDestroyed()) return false;
|
|
|
|
|
|
void contents.loadURL(url.toString()).catch((error) => {
|
|
|
|
|
|
log.warn('[webview] failed to load popup URL in place:', error);
|
|
|
|
|
|
});
|
|
|
|
|
|
return true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
app.on('web-contents-created', (_event, contents) => {
|
|
|
|
|
|
if (contents.getType() !== 'webview') return;
|
|
|
|
|
|
|
|
|
|
|
|
contents.setWindowOpenHandler(({ url }) => {
|
|
|
|
|
|
loadUrlInsideWebContents(contents, url);
|
|
|
|
|
|
return { action: 'deny' };
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
// 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;
|
|
|
|
|
|
const url = new URL(raw);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (url.protocol === `${UI_PROTOCOL}:` && url.hostname === 'app') return true;
|
2026-04-20 15:41:15 +03:00
|
|
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
|
|
|
|
if (state.localOrigin) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const allowed = new URL(state.localOrigin);
|
|
|
|
|
|
if (allowed.origin === url.origin) return true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
if (state.sidecarUrl) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const allowed = new URL(state.sidecarUrl);
|
|
|
|
|
|
if (allowed.origin === url.origin) return true;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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',
|
2026-05-26 11:13:59 -04:00
|
|
|
|
'desktop_minimize_current_window',
|
|
|
|
|
|
'desktop_toggle_current_window_maximized',
|
|
|
|
|
|
'desktop_close_current_window',
|
|
|
|
|
|
'desktop_get_current_window_state',
|
2026-04-20 15:41:15 +03:00
|
|
|
|
'desktop_get_app_version',
|
|
|
|
|
|
'desktop_get_lan_address',
|
2026-04-29 17:03:38 -04:00
|
|
|
|
'desktop_capture_page_rect',
|
2026-04-20 15:41:15 +03:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-17 23:26:26 +03:00
|
|
|
|
defaultPath: typeof options?.defaultPath === 'string' && options.defaultPath.trim().length > 0
|
|
|
|
|
|
? options.defaultPath.trim()
|
|
|
|
|
|
: undefined,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-10 00:20:23 +03:00
|
|
|
|
// --- macOS menu bar (status bar) ---------------------------------------------
|
|
|
|
|
|
// Tray lives only on macOS; the renderer streams a compact state snapshot via
|
|
|
|
|
|
// the `desktop_tray_update` IPC command (see the command switch). Tray clicks
|
|
|
|
|
|
// flow back through dispatchTrayAction → renderer (focus/respond) or native
|
|
|
|
|
|
// handlers (show window / quit).
|
|
|
|
|
|
|
|
|
|
|
|
// Icon assets: a calm outline (idle), a statically filled cube (a finished
|
|
|
|
|
|
// session left unread), and an eased sequence the busy state breathes through.
|
|
|
|
|
|
const TRAY_BREATH_FRAME_COUNT = 16;
|
|
|
|
|
|
// Track the most recently focused window (main or mini-chat) so tray actions
|
|
|
|
|
|
// can target the surface the user was last using, even when the tray menu is
|
|
|
|
|
|
// open and nothing is focused right now.
|
|
|
|
|
|
app.on('browser-window-focus', (_event, browserWindow) => {
|
|
|
|
|
|
if (browserWindow && !browserWindow.isDestroyed()) {
|
|
|
|
|
|
state.lastFocusedWindowId = browserWindow.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// The window the user is "on" for tray routing: the focused one, else the last
|
|
|
|
|
|
// focused that is still alive.
|
|
|
|
|
|
const resolveTraySurface = () => {
|
|
|
|
|
|
const focused = BrowserWindow.getFocusedWindow();
|
|
|
|
|
|
if (focused && !focused.isDestroyed()) return focused;
|
|
|
|
|
|
if (state.lastFocusedWindowId != null) {
|
|
|
|
|
|
const remembered = BrowserWindow.fromId(state.lastFocusedWindowId);
|
|
|
|
|
|
if (remembered && !remembered.isDestroyed()) return remembered;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const trayIconAssets = () => {
|
|
|
|
|
|
const dir = path.join(resourceRoot(), 'icons', 'tray');
|
|
|
|
|
|
return {
|
|
|
|
|
|
idleIconPath: path.join(dir, 'trayTemplate-idle.png'),
|
|
|
|
|
|
unseenIconPath: path.join(dir, 'trayTemplate-unseen.png'),
|
|
|
|
|
|
breathIconPaths: Array.from({ length: TRAY_BREATH_FRAME_COUNT }, (_, i) =>
|
|
|
|
|
|
path.join(dir, `trayTemplate-breath-${String(i).padStart(2, '0')}.png`)),
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const setupTray = () => {
|
|
|
|
|
|
if (process.platform !== 'darwin' || state.trayController) return;
|
|
|
|
|
|
const assets = trayIconAssets();
|
|
|
|
|
|
if (!fs.existsSync(assets.idleIconPath)) {
|
|
|
|
|
|
log.warn('[electron] tray icon missing, skipping tray setup', { iconPath: assets.idleIconPath });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
state.trayController = createTrayController({
|
|
|
|
|
|
...assets,
|
|
|
|
|
|
onAction: (action) => { void dispatchTrayAction(action); },
|
|
|
|
|
|
});
|
|
|
|
|
|
// Seed an empty snapshot so the icon appears immediately; the renderer
|
|
|
|
|
|
// pushes the real state once the sync stores are mounted.
|
|
|
|
|
|
state.trayController.update({ sessions: [], approvals: [] });
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
log.warn('[electron] failed to set up tray', error);
|
|
|
|
|
|
state.trayController = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Bring the existing main window forward WITHOUT re-navigating it. Only when
|
|
|
|
|
|
// no live window exists (truly closed) do we recreate one — recreation reloads,
|
|
|
|
|
|
// but showing an existing window must not. This mirrors desktop_focus_main_window
|
|
|
|
|
|
// and the notification "open session" path; calling openMainWindow on a live
|
|
|
|
|
|
// window navigates it (full reload), which is the bug we're avoiding here.
|
|
|
|
|
|
const revealMainWindow = async () => {
|
|
|
|
|
|
let target = state.mainWindow;
|
|
|
|
|
|
if (!target || target.isDestroyed()) {
|
|
|
|
|
|
target = await openMainWindow().catch(() => null) || state.mainWindow;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (target && !target.isDestroyed()) {
|
|
|
|
|
|
if (target.isMinimized()) target.restore();
|
|
|
|
|
|
target.show();
|
|
|
|
|
|
target.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
return target;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Open a session in the main window, creating one first if none is alive. A
|
|
|
|
|
|
// freshly created window can't receive an immediate emit (its renderer hasn't
|
|
|
|
|
|
// mounted its listeners yet), so we queue the session as a pending deep-link —
|
|
|
|
|
|
// the did-finish-load handler flushes it once the window is ready.
|
|
|
|
|
|
const focusMainWindowWithSession = async (sessionId, directory) => {
|
|
|
|
|
|
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
|
|
|
|
|
|
if (state.mainWindow.isMinimized()) state.mainWindow.restore();
|
|
|
|
|
|
state.mainWindow.show();
|
|
|
|
|
|
state.mainWindow.focus();
|
|
|
|
|
|
if (sessionId) {
|
|
|
|
|
|
emitToWindow(state.mainWindow, 'openchamber:open-session', { sessionId, directory: directory || '' });
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sessionId) pendingDeepLinks.push({ type: 'session', value: sessionId });
|
|
|
|
|
|
await openMainWindow();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const dispatchTrayAction = async (action) => {
|
|
|
|
|
|
if (!action || typeof action !== 'object') return;
|
|
|
|
|
|
|
|
|
|
|
|
if (action.type === 'quit') {
|
|
|
|
|
|
app.quit();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Responding to a permission doesn't need to steal focus — just deliver it.
|
|
|
|
|
|
if (action.type === 'respond-permission') {
|
|
|
|
|
|
const target = (state.mainWindow && !state.mainWindow.isDestroyed())
|
|
|
|
|
|
? state.mainWindow
|
|
|
|
|
|
: await revealMainWindow();
|
|
|
|
|
|
emitToWindow(target, 'openchamber:tray-action', action);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Mini chat opens its own small window; we only need a renderer with context,
|
|
|
|
|
|
// not to surface the main window.
|
|
|
|
|
|
if (action.type === 'new-mini-chat') {
|
|
|
|
|
|
let target = getMenuTargetWindow();
|
|
|
|
|
|
if (!target) target = await revealMainWindow();
|
|
|
|
|
|
dispatchOpenMiniChat(target);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Open a session on the surface the user was last on: if that's a mini-chat,
|
|
|
|
|
|
// switch THAT window to the session in place (no new window); otherwise use
|
|
|
|
|
|
// the main window.
|
|
|
|
|
|
if (action.type === 'focus-session') {
|
|
|
|
|
|
const surface = resolveTraySurface();
|
|
|
|
|
|
if (surface && surface.__ocMiniChat === true && action.sessionId) {
|
|
|
|
|
|
if (surface.isMinimized()) surface.restore();
|
|
|
|
|
|
surface.show();
|
|
|
|
|
|
surface.focus();
|
|
|
|
|
|
emitToWindow(surface, 'openchamber:open-session', {
|
|
|
|
|
|
sessionId: action.sessionId,
|
|
|
|
|
|
directory: action.directory || '',
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await focusMainWindowWithSession(action.sessionId, action.directory || '');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const target = await revealMainWindow();
|
|
|
|
|
|
if (!target || target.isDestroyed()) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (action.type === 'new-session') {
|
|
|
|
|
|
emitToWindow(target, 'openchamber:open-draft-session', { directory: '', projectId: '' });
|
|
|
|
|
|
}
|
|
|
|
|
|
// show-main-window: revealing the window above is the whole action.
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
app.on('window-all-closed', () => {
|
|
|
|
|
|
if (process.platform === 'darwin' && !state.quitRequested) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (process.platform !== 'darwin') {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
if (state.installingUpdate) {
|
|
|
|
|
|
app.quit();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
performConfirmedQuit();
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.on('before-quit', (event) => {
|
2026-06-03 14:51:10 +03:00
|
|
|
|
state.quitRequested = true;
|
|
|
|
|
|
|
|
|
|
|
|
if (state.installingUpdate) {
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-03 14:51:10 +03:00
|
|
|
|
|
|
|
|
|
|
if (process.platform === 'darwin' && !state.quitConfirmed) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
void requestQuitWithConfirmation();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!state.backgroundShutdownComplete) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
performConfirmedQuit();
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-26 01:36:11 +03:00
|
|
|
|
if (BrowserWindow.getAllWindows().length > 0) {
|
|
|
|
|
|
focusForegroundWindow();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
void openMainWindow();
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.on('open-url', (event, url) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
handleDeepLinks([url]);
|
2026-05-26 01:36:11 +03:00
|
|
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
|
|
|
|
void openMainWindow();
|
|
|
|
|
|
}
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.on('activate', async () => {
|
|
|
|
|
|
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
|
2026-06-10 00:20:23 +03:00
|
|
|
|
// Only spawn a main window when there is genuinely nothing to come back to.
|
|
|
|
|
|
if (windows.length === 0) {
|
|
|
|
|
|
await openMainWindow();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 00:20:23 +03:00
|
|
|
|
// Otherwise bring back the surface the user was last on — restoring it if
|
|
|
|
|
|
// minimized — instead of surfacing a hidden window or creating a new one.
|
|
|
|
|
|
// This covers e.g. "only a minimized mini-chat remains": it should un-minimize
|
|
|
|
|
|
// rather than open the main window.
|
|
|
|
|
|
const remembered = resolveTraySurface();
|
|
|
|
|
|
const targetWindow = (remembered && !remembered.isDestroyed())
|
|
|
|
|
|
? remembered
|
|
|
|
|
|
: (windows.find((window) => window.isVisible() && !window.isMinimized()) || windows[0]);
|
|
|
|
|
|
if (targetWindow.isMinimized()) targetWindow.restore();
|
|
|
|
|
|
targetWindow.show();
|
|
|
|
|
|
targetWindow.focus();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
app.whenReady().then(async () => {
|
2026-05-26 01:36:11 +03:00
|
|
|
|
const loginItemSettings = readLoginItemSettings();
|
|
|
|
|
|
const isBackgroundStart = shouldStartInBackground(loginItemSettings);
|
2026-04-20 15:41:15 +03:00
|
|
|
|
log.info('[electron] app starting', {
|
|
|
|
|
|
version: APP_VERSION,
|
|
|
|
|
|
packaged: app.isPackaged,
|
|
|
|
|
|
platform: process.platform,
|
|
|
|
|
|
arch: process.arch,
|
2026-05-26 01:36:11 +03:00
|
|
|
|
argv: process.argv,
|
|
|
|
|
|
isBackgroundStart,
|
|
|
|
|
|
loginItemSettings,
|
2026-04-20 15:41:15 +03:00
|
|
|
|
});
|
|
|
|
|
|
nativeTheme.themeSource = readThemeSource();
|
2026-06-02 00:43:05 +03:00
|
|
|
|
registerPackagedUiProtocol();
|
2026-04-20 15:41:15 +03:00
|
|
|
|
setupAutoUpdater();
|
|
|
|
|
|
|
|
|
|
|
|
if (process.platform === 'darwin') {
|
|
|
|
|
|
Menu.setApplicationMenu(buildMacMenu());
|
2026-06-10 00:20:23 +03:00
|
|
|
|
setupTray();
|
2026-05-26 11:13:59 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
Menu.setApplicationMenu(buildAutoHiddenMenu());
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 01:36:11 +03:00
|
|
|
|
if (process.platform === 'darwin' && app.isPackaged) {
|
|
|
|
|
|
const openAtLogin = loginItemSettings?.openAtLogin === true;
|
|
|
|
|
|
app.setLoginItemSettings({
|
|
|
|
|
|
openAtLogin,
|
|
|
|
|
|
openAsHidden: openAtLogin,
|
|
|
|
|
|
args: openAtLogin ? [BACKGROUND_START_ARG] : [],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isBackgroundStart) {
|
|
|
|
|
|
const { localOrigin, bootOutcome } = await resolveInitialUrl();
|
|
|
|
|
|
state.localOrigin = localOrigin;
|
|
|
|
|
|
state.bootOutcome = bootOutcome ?? null;
|
|
|
|
|
|
state.initScript = buildInitScript(localOrigin, state.bootOutcome);
|
|
|
|
|
|
log.info('[electron] started in background without window');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 00:17:32 +03:00
|
|
|
|
state.mainWindow = createBrowserWindow({
|
|
|
|
|
|
label: 'main',
|
|
|
|
|
|
restoreGeometry: true,
|
|
|
|
|
|
url: null,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
|
const initial = extractInitialDeepLinks();
|
|
|
|
|
|
if (initial.length > 0) handleDeepLinks(initial);
|
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
|
const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken } = await resolveInitialUrl();
|
|
|
|
|
|
await activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken });
|
2026-04-29 17:19:31 +08:00
|
|
|
|
|
|
|
|
|
|
// Notify renderer on OS wake-from-sleep so the SSE event pipeline can
|
|
|
|
|
|
// reconnect immediately instead of waiting for the heartbeat watchdog.
|
|
|
|
|
|
powerMonitor.on('resume', () => {
|
|
|
|
|
|
emitToAllWindows('openchamber:system-resume', { timestamp: Date.now() });
|
|
|
|
|
|
});
|
2026-04-20 15:41:15 +03:00
|
|
|
|
}).catch((error) => {
|
|
|
|
|
|
log.error('[electron] startup failed:', error);
|
|
|
|
|
|
app.exit(1);
|
|
|
|
|
|
});
|