Add Windows Electron desktop support (#1093)

* fix: make upstream sync actions target the selected remote

Ensure fetch and pull actually honor upstream selection so fork maintenance works from the Git sidebar, and surface upstream branch status alongside the primary origin-tracking indicators.

* feat: add Windows Electron desktop foundation

* fix(electron): stabilize Windows desktop packaging

* fix(electron): stabilize Windows desktop chrome

Use native Windows titlebar behavior with an Alt-accessible hidden menu, and harden Windows dev command launching so the desktop app follows platform conventions.

* fix(electron): stabilize Windows dev startup

* fix(electron): clarify desktop artifact names

* fix(electron): harden Windows desktop release and launch

* fix(electron): address Windows release review

* fix(electron): point updater and release links to org repo

* Fix Windows settings persistence fallback

* Fix Windows Electron dev startup

* Add Windows Electron window controls

* Fix Windows Electron install and opencode launch

* fix: resolve git status for repositories without upstream

Fixes repository detection stuck on Checking repository
Handles git status when no upstream is configured
Adds regression coverage for git status loading

* Add Windows app menu button

* fix: preserve file editor line endings

* ci: add desktop release smoke workflow

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Dave Otero
2026-05-26 18:13:59 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent cc7969ac00
commit becd240168
59 changed files with 2260 additions and 246 deletions
+555 -60
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, session, shell, webContents } from 'electron';
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, screen, session, shell, webContents } from 'electron';
import contextMenu from 'electron-context-menu';
import log from 'electron-log/main.js';
import dgram from 'node:dgram';
@@ -19,7 +19,9 @@ const __dirname = path.dirname(__filename);
const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged;
const DEEP_LINK_PROTOCOL = 'openchamber';
const APP_USER_MODEL_ID = 'dev.openchamber.desktop';
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;
const BACKGROUND_START_ARG = '--background';
const readLoginItemSettings = () => {
@@ -39,17 +41,17 @@ const shouldStartInBackground = (loginItemSettings = readLoginItemSettings()) =>
);
};
if (!app.requestSingleInstanceLock()) {
app.exit(0);
process.exit(0);
}
// Set the product name early so electron-log derives its log directory as
// ~/Library/Logs/OpenChamber/ (not ~/Library/Logs/@openchamber/electron/).
app.setName('OpenChamber');
app.setAppUserModelId(APP_USER_MODEL_ID);
app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>');
if (!app.requestSingleInstanceLock()) {
app.exit(0);
process.exit(0);
}
try {
process.chdir(os.homedir());
} catch {
@@ -447,6 +449,35 @@ const readWindowState = () => {
return stateValue && typeof stateValue === 'object' ? stateValue : null;
};
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 };
}
};
const writeWindowState = async (browserWindow) => {
if (!browserWindow || browserWindow.isDestroyed()) return;
if (!state.mainWindow || browserWindow.id !== state.mainWindow.id) return;
@@ -718,12 +749,54 @@ const probeShellEnv = (shell, mode) => {
return Object.keys(env).length > 0 ? env : null;
};
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),
};
};
// Finder-launched apps on macOS inherit a minimal PATH (no /opt/homebrew, mise, asdf, etc.).
// Probe the user's login shell once so the sidecar sees the same PATH / tool env as `$SHELL -il`.
const loadShellEnv = () => {
if (shellEnvProbed) return cachedShellEnv;
shellEnvProbed = true;
if (process.platform === 'win32') return null;
if (process.platform === 'win32') {
cachedShellEnv = loadWindowsEnv();
return cachedShellEnv;
}
const shell = process.env.SHELL || '/bin/sh';
if (isNushell(shell)) return null;
cachedShellEnv = probeShellEnv(shell, '-il') || probeShellEnv(shell, '-l');
@@ -742,7 +815,8 @@ const inheritUserShellEnv = () => {
const homeDir = os.homedir();
const currentPath = process.env.PATH || '';
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, ':');
const delimiter = process.platform === 'win32' ? ';' : ':';
const currentPathLooksUserConfigured = pathLooksUserConfigured(currentPath, homeDir, delimiter);
for (const [key, value] of Object.entries(shellEnv)) {
if (key === 'PATH') continue;
@@ -752,8 +826,8 @@ const inheritUserShellEnv = () => {
}
const shellPath = typeof shellEnv.PATH === 'string' ? shellEnv.PATH : '';
if (!currentPathLooksUserConfigured && shellPath) {
process.env.PATH = mergePathValues(shellPath, currentPath, ':');
if ((process.platform === 'win32' || !currentPathLooksUserConfigured) && shellPath) {
process.env.PATH = mergePathValues(shellPath, currentPath, delimiter);
}
};
@@ -1021,6 +1095,15 @@ const emitToAllWindows = (event, detail) => {
}
};
const setTaskbarProgress = (value) => {
if (process.platform !== 'win32') return;
for (const browserWindow of BrowserWindow.getAllWindows()) {
if (!browserWindow.isDestroyed()) {
browserWindow.setProgressBar(value);
}
}
};
const pendingDeepLinks = [];
const parseDeepLink = (raw) => {
@@ -1176,24 +1259,53 @@ const readThemeSource = () => {
return 'system';
};
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()
);
const createBrowserWindow = ({ label, restoreGeometry, url }) => {
const saved = restoreGeometry ? readWindowState() : null;
const useSaved = saved && typeof saved.width === 'number' && typeof saved.height === 'number';
const restoredBounds = useSaved ? clampWindowBoundsToVisibleWorkArea(saved) : null;
const desktopLocalOrigin = state.localOrigin || '';
const desktopHome = os.homedir() || '';
const desktopMacosMajor = String(macosMajorVersion());
const usesCustomTitleBar = process.platform === 'darwin' || process.platform === 'win32';
const titleBarOverlayEnabled = false;
const autoHidesNativeMenuBar = process.platform !== 'darwin';
const windowIconPath = getWindowIconPath();
const options = {
title: 'OpenChamber',
width: useSaved ? Math.max(saved.width, MIN_RESTORE_WINDOW_WIDTH) : 1280,
height: useSaved ? Math.max(saved.height, MIN_RESTORE_WINDOW_HEIGHT) : 800,
...(Number.isFinite(restoredBounds?.x) && Number.isFinite(restoredBounds?.y)
? { x: restoredBounds.x, y: restoredBounds.y }
: {}),
width: restoredBounds?.width ?? 1280,
height: restoredBounds?.height ?? 800,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
icon: windowIconPath,
show: false,
backgroundColor: '#151313',
frame: process.platform === 'win32' ? false : undefined,
autoHideMenuBar: autoHidesNativeMenuBar,
// Tauri used an overlay title bar with explicit traffic-light placement.
// Electron's hiddenInset adds its own extra inset, which leaves the controls
// visibly lower than the app header. Use a plain hidden title bar instead.
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
titleBarStyle: usesCustomTitleBar ? 'hidden' : 'default',
titleBarOverlay: titleBarOverlayEnabled,
trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined,
webPreferences: {
additionalArguments: [
@@ -1217,10 +1329,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => {
const browserWindow = new BrowserWindow(options);
browserWindow.__ocLabel = label || nextWindowLabel();
if (useSaved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
browserWindow.setPosition(saved.x, saved.y);
}
browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled;
if (useSaved && saved.maximized) {
browserWindow.maximize();
@@ -1260,6 +1369,14 @@ const createBrowserWindow = ({ label, restoreGeometry, url }) => {
emitToWindow(browserWindow, 'openchamber:window-resized');
debounceWindowStatePersist(browserWindow, false);
});
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);
});
browserWindow.on('move', () => {
debounceWindowStatePersist(browserWindow, false);
});
@@ -1458,6 +1575,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
height: MINI_CHAT_WINDOW_HEIGHT,
minWidth: MINI_CHAT_MIN_WINDOW_WIDTH,
minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT,
icon: getWindowIconPath(),
show: false,
backgroundColor: '#151313',
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
@@ -1557,12 +1675,16 @@ const setMiniChatPinned = (browserWindow, pinned) => {
};
const resolveInitialUrl = async () => {
const localUrl = isDev && await waitForHealth('http://127.0.0.1:3901', 5_000, 100)
? 'http://127.0.0.1:3901'
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
: await spawnLocalServer();
const localUiUrl = isDev && await waitForHealth('http://127.0.0.1:5173', 8_000, 100)
? 'http://127.0.0.1:5173'
const localUiUrl = isDev && await waitForHealth(hmrUiUrl, 8_000, 100)
? hmrUiUrl
: localUrl;
state.sidecarUrl = localUrl;
@@ -1638,6 +1760,9 @@ const setupAutoUpdater = () => {
});
autoUpdater.on('download-progress', (progress) => {
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);
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Progress',
data: {
@@ -1650,12 +1775,14 @@ const setupAutoUpdater = () => {
autoUpdater.on('update-downloaded', (info) => {
log.info(`[electron] update-downloaded version=${info?.version || 'unknown'}`);
setTaskbarProgress(-1);
if (state.pendingUpdate) {
state.pendingUpdate.downloaded = true;
}
});
autoUpdater.on('error', (err) => {
setTaskbarProgress(-1);
log.error('[electron] autoUpdater error', err);
});
};
@@ -1824,6 +1951,138 @@ const CLI_BY_APP_ID = {
zed: 'zed',
};
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'],
vscode: ['code.cmd', 'code.exe'],
cursor: ['cursor.cmd', 'cursor.exe'],
vscodium: ['codium.cmd', 'codium.exe'],
windsurf: ['windsurf.cmd', 'windsurf.exe'],
zed: ['zed.exe'],
'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;
};
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));
};
const buildWindowsInstalledApps = (apps) => {
const seen = new Set();
return (Array.isArray(apps) ? apps : [])
.map((appName) => String(appName || '').trim())
.filter((appName) => appName && !seen.has(appName) && seen.add(appName))
.filter((appName) => isWindowsAppInstalled({ appId: getWindowsAppIdForName(appName), appName }))
.map((name) => ({ name, iconDataUrl: null }));
};
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) {
specs.push({ program: shell, args: ['-NoExit', '-Command', `Set-Location -LiteralPath ${JSON.stringify(projectPath)}`] });
}
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;
};
const buildOpenProjectSpecs = ({ projectPath, appId, appName }) => {
if (appId === 'finder') {
return [{ program: 'open', args: [projectPath] }];
@@ -1869,10 +2128,66 @@ const buildOpenFileSpecs = ({ filePath, appId, appName }) => {
return specs;
};
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');
}
if (/\.(cmd|bat)$/i.test(program)) {
launchWindowsCommandScript(spec, program);
return;
}
const child = spawn(program, spec.args, {
detached: true,
stdio: 'ignore',
windowsHide: false,
});
child.unref();
};
const runSpecChain = (specs, appName) => {
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('; ')}`);
}
const failures = [];
for (const spec of specs) {
const result = spawnSync(spec.program, spec.args, { stdio: 'ignore' });
const result = spawnSync(spec.program, spec.args, { stdio: 'ignore', windowsHide: true });
if (result.error) {
failures.push(`${spec.program}: ${result.error.message}`);
continue;
@@ -2095,34 +2410,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_open_in_app': {
if (process.platform !== 'darwin') {
throw new Error('desktop_open_in_app is only supported on macOS');
}
const projectPath = typeof args.projectPath === 'string' ? args.projectPath.trim() : '';
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
if (!projectPath || !appId || !appName) {
throw new Error('Project path, app id, and app name are required');
}
if (process.platform === 'win32') {
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');
}
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName);
return null;
}
case 'desktop_open_file_in_app': {
if (process.platform !== 'darwin') {
throw new Error('desktop_open_file_in_app is only supported on macOS');
}
const filePath = typeof args.filePath === 'string' ? args.filePath.trim() : '';
const appId = typeof args.appId === 'string' ? args.appId.trim().toLowerCase() : '';
const appName = typeof args.appName === 'string' ? args.appName.trim() : '';
if (!filePath || !appId || !appName) {
throw new Error('File path, app id, and app name are required');
}
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');
}
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName);
return null;
}
case 'desktop_filter_installed_apps': {
if (process.platform === 'win32') {
return buildWindowsInstalledApps(args.apps).map((app) => app.name);
}
if (process.platform !== 'darwin') {
throw new Error('desktop_filter_installed_apps is only supported on macOS');
}
@@ -2134,6 +2460,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_fetch_app_icons': {
if (process.platform === 'win32') {
return [];
}
if (process.platform !== 'darwin') {
throw new Error('desktop_fetch_app_icons is only supported on macOS');
}
@@ -2149,9 +2478,6 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
case 'desktop_get_installed_apps': {
if (process.platform !== 'darwin') {
throw new Error('desktop_get_installed_apps is only supported on macOS');
}
const cachePath = buildInstalledAppsCachePath();
const now = Math.floor(Date.now() / 1000);
let cache = null;
@@ -2163,11 +2489,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
const hasCache = Boolean(cache);
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
const refresh = async () => {
const apps = await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
const apps = process.platform === 'win32'
? buildWindowsInstalledApps(args.apps)
: await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2));
emitToAllWindows('openchamber:installed-apps-updated', apps);
};
if (process.platform !== 'darwin' && process.platform !== 'win32') {
throw new Error('desktop_get_installed_apps is only supported on macOS and Windows');
}
if (!hasCache || isCacheStale || args.force === true) {
void refresh();
}
@@ -2216,6 +2547,14 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
} else {
nativeTheme.themeSource = 'system';
}
if (canUseTitleBarOverlay(browserWindow)) {
const useDark = nativeTheme.shouldUseDarkColors;
browserWindow.setTitleBarOverlay({
color: useDark ? '#151313' : '#f5f5f4',
symbolColor: useDark ? '#fafaf9' : '#1c1917',
height: 48,
});
}
return null;
}
@@ -2271,40 +2610,45 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
if (!state.pendingUpdate) {
throw new Error('No pending update');
}
setTaskbarProgress(0.01);
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Started',
data: {
contentLength: null,
},
}));
if (!state.pendingUpdate.electronUpdate) {
throw new Error('Electron updater metadata is not available for this build');
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);
}
if (!state.pendingUpdate.downloaded) {
await new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
autoUpdater.off('update-downloaded', onDownloaded);
autoUpdater.off('error', onError);
};
const finish = (callback, value) => {
if (settled) return;
settled = true;
cleanup();
callback(value);
};
const onDownloaded = () => finish(resolve, null);
const onError = (error) => finish(reject, error);
autoUpdater.on('update-downloaded', onDownloaded);
autoUpdater.on('error', onError);
Promise.resolve(autoUpdater.downloadUpdate()).catch((error) => finish(reject, error));
});
}
emitToAllWindows('openchamber:update-progress', mapUpdaterProgressEvent({
event: 'Finished',
data: {},
}));
return null;
case 'desktop_restart': {
const applyUpdate = Boolean(state.pendingUpdate?.downloaded && app.isPackaged);
@@ -2422,6 +2766,38 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
}
return null;
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;
}
case 'desktop_ssh_instances_get':
return sshManager.readInstances();
@@ -2562,6 +2938,119 @@ const buildMacMenu = () => {
]);
};
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) },
],
},
]);
};
contextMenu({
showInspectElement: isDev,
showSaveImageAs: true,
@@ -2612,6 +3101,10 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([
'desktop_set_window_theme',
'desktop_is_window_fullscreen',
'desktop_start_window_drag',
'desktop_minimize_current_window',
'desktop_toggle_current_window_maximized',
'desktop_close_current_window',
'desktop_get_current_window_state',
'desktop_get_app_version',
'desktop_get_lan_address',
'desktop_capture_page_rect',
@@ -2733,6 +3226,8 @@ app.whenReady().then(async () => {
if (process.platform === 'darwin') {
Menu.setApplicationMenu(buildMacMenu());
} else {
Menu.setApplicationMenu(buildAutoHiddenMenu());
}
if (process.platform === 'darwin' && app.isPackaged) {
+23 -2
View File
@@ -20,7 +20,8 @@
"desktopPrerequisites": [
"Electron runtime dependencies installed via bun install",
"Bun available for sidecar compilation",
"macOS build tools installed for notarized packaging"
"macOS: Xcode + build tools for notarized packaging",
"Windows: NSIS installed for installer creation"
],
"scripts": {
"dev": "node ./scripts/electron-dev.mjs",
@@ -29,7 +30,7 @@
"bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && electron-builder",
"package": "bun run build:web-assets && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
"finalize:latest-yml": "node ./scripts/finalize-latest-yml.mjs",
"type-check": "node --check ./main.mjs && node --check ./preload.mjs",
"lint": "node -e \"process.exit(0)\""
@@ -45,6 +46,10 @@
{
"from": "resources/web-dist",
"to": "web-dist"
},
{
"from": "resources/icons/icon.ico",
"to": "icons/icon.ico"
}
],
"afterPack": "scripts/after-pack.cjs",
@@ -57,6 +62,7 @@
"mac": {
"category": "public.app-category.developer-tools",
"icon": "resources/icons/icon.icns",
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
"extendInfo": {
"CFBundleIconName": "AppIcon"
},
@@ -70,6 +76,21 @@
"zip"
]
},
"win": {
"icon": "resources/icons/icon.ico",
"target": [
"nsis"
],
"verifyUpdateCodeSignature": false,
"artifactName": "${productName}-${version}-win-${arch}.${ext}"
},
"nsis": {
"oneClick": true,
"perMachine": false,
"installerIcon": "resources/icons/icon.ico",
"uninstallerIcon": "resources/icons/icon.ico",
"installerHeaderIcon": "resources/icons/icon.ico"
},
"dmg": {
"sign": true,
"title": "${productName} ${version}",
+2
View File
@@ -66,6 +66,8 @@ contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', {
runtime: 'electron',
});
contextBridge.exposeInMainWorld('__OPENCHAMBER_PLATFORM__', process.platform);
// Note: bootOutcome must stay writable from the main world's initScript so
// re-navigations (host switch via deep link) can refresh it. contextBridge-
// exposed globals are read-only, which blocks that update — rely solely on
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+16 -1
View File
@@ -14,8 +14,17 @@ const resourcesDir = path.join(electronDir, 'resources');
const resourcesWebDistDir = path.join(resourcesDir, 'web-dist');
const webDistDir = path.join(webDir, 'dist');
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
const run = (cmd, args, cwd) => {
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(cmd);
const result = isWindowsCommandScript
? spawnSync(
process.env.ComSpec || 'cmd.exe',
['/d', '/s', '/c', ['call', quoteWindowsCommandArg(cmd), ...args.map(quoteWindowsCommandArg)].join(' ')],
{ cwd, stdio: 'inherit', windowsVerbatimArguments: true },
)
: spawnSync(cmd, args, { cwd, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
@@ -26,6 +35,12 @@ const resolveBun = () => {
if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) {
return process.env.BUN.trim();
}
if (process.platform === 'win32') {
const result = spawnSync('where.exe', ['bun'], { encoding: 'utf8' });
const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const resolved = candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0];
return resolved || 'bun';
}
const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' });
const resolved = (result.stdout || '').trim();
return resolved || 'bun';
+98 -5
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -7,13 +8,39 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../..');
const electronDir = path.join(repoRoot, 'packages/electron');
const preferredHmrUiPort = Number(process.env.OPENCHAMBER_HMR_UI_PORT || '5173');
const preferredHmrApiPort = Number(process.env.OPENCHAMBER_HMR_API_PORT || '3901');
const quoteWindowsCommandArg = (value) => `"${String(value).replace(/"/g, '""')}"`;
function resolveWindowsCommand(command) {
if (process.platform !== 'win32' || path.isAbsolute(command)) {
return command;
}
const result = spawnSync('where.exe', [command], { encoding: 'utf8', windowsHide: true });
if (result.error || result.status !== 0) {
return command;
}
const candidates = String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return candidates.find((entry) => /\.(exe|cmd|bat)$/i.test(entry)) || candidates[0] || command;
}
function spawnProcess(command, args, options = {}) {
return spawn(command, args, {
const resolvedCommand = resolveWindowsCommand(command);
const isWindowsCommandScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand);
const spawnCommand = isWindowsCommandScript ? (process.env.ComSpec || 'cmd.exe') : resolvedCommand;
const spawnArgs = isWindowsCommandScript
? ['/d', '/s', '/c', ['call', quoteWindowsCommandArg(resolvedCommand), ...args.map(quoteWindowsCommandArg)].join(' ')]
: args;
return spawn(spawnCommand, spawnArgs, {
cwd: repoRoot,
env: { ...process.env, OPENCHAMBER_ELECTRON_DEV: '1' },
stdio: 'inherit',
detached: process.platform !== 'win32',
windowsVerbatimArguments: isWindowsCommandScript,
...options,
});
}
@@ -39,6 +66,55 @@ function waitForExit(child, timeoutMs) {
});
}
function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}
async function findAvailablePort(preferredPort) {
const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : 0;
if (start === 0) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.once('listening', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
server.listen(0, '127.0.0.1');
});
}
for (let port = start; port < start + 50; port += 1) {
if (await isPortAvailable(port)) {
if (port !== start) {
console.warn(`[electron:dev] port ${start} is unavailable, using ${port} instead.`);
}
return port;
}
}
throw new Error(`No available port found near ${start}`);
}
function killWindowsProcessTree(pid) {
if (!pid) return;
try {
spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {
}
}
function signalChild(child, signal) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
@@ -66,6 +142,11 @@ async function stopChildTree(child) {
signalChild(child, 'SIGINT');
await waitForExit(child, 2500);
if (process.platform === 'win32' && child.exitCode === null && child.signalCode === null) {
killWindowsProcessTree(child.pid);
await waitForExit(child, 1000);
}
if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGTERM');
await waitForExit(child, 2500);
@@ -78,16 +159,28 @@ async function stopChildTree(child) {
}
async function main() {
const hmrApiPort = String(await findAvailablePort(preferredHmrApiPort));
const hmrUiPort = String(await findAvailablePort(preferredHmrUiPort));
const devServer = spawnProcess('node', ['./scripts/dev-web-hmr.mjs'], {
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: '5173',
OPENCHAMBER_HMR_API_PORT: '3901',
OPENCHAMBER_HMR_UI_PORT: hmrUiPort,
OPENCHAMBER_HMR_API_PORT: hmrApiPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
});
const electron = spawnProcess('npx', ['electron', './main.mjs'], {
cwd: electronDir,
env: {
...process.env,
OPENCHAMBER_ELECTRON_DEV: '1',
OPENCHAMBER_HMR_UI_PORT: hmrUiPort,
OPENCHAMBER_HMR_API_PORT: hmrApiPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
});
const electron = spawnProcess('npx', ['electron', './main.mjs'], { cwd: electronDir });
let cleaning = false;
const teardown = async (code) => {
+41
View File
@@ -0,0 +1,41 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const env = { ...process.env };
if (process.platform === 'win32' && !env.CSC_LINK && !env.WINDOWS_CSC_LINK) {
env.CSC_IDENTITY_AUTO_DISCOVERY = 'false';
console.log('[electron] Windows code signing disabled; building unsigned installer.');
}
const bunBinaryCandidates = [
process.env.npm_execpath,
process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin', process.platform === 'win32' ? 'bun.exe' : 'bun') : null,
process.platform === 'win32' ? 'bun.exe' : 'bun',
].filter(Boolean);
const bunBinary = bunBinaryCandidates.find((candidate) => {
if (path.basename(candidate).toLowerCase().startsWith('bun')) {
return candidate === 'bun' || candidate === 'bun.exe' || fs.existsSync(candidate);
}
return false;
}) || (process.platform === 'win32' ? 'bun.exe' : 'bun');
const child = spawn(bunBinary, ['x', 'electron-builder', ...process.argv.slice(2)], {
env,
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on('error', (error) => {
console.error('[electron] failed to start electron-builder:', error);
process.exit(1);
});
+133 -7
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env node
import path from 'node:path';
import { existsSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { rebuild } from '@electron/rebuild';
@@ -14,17 +17,140 @@ const require = createRequire(import.meta.url);
const electronPkg = require('electron/package.json');
const electronVersion = electronPkg.version;
const copyDirectory = async (src, dst) => {
await fsp.mkdir(dst, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const from = path.join(src, entry.name);
const to = path.join(dst, entry.name);
if (entry.isDirectory()) {
await copyDirectory(from, to);
} else {
await fsp.copyFile(from, to);
}
}
};
const getWindowsShortPath = (target) => {
if (process.platform !== 'win32') return target;
try {
const escaped = target.replace(/'/g, "''");
const output = execFileSync(
'powershell.exe',
['-NoProfile', '-Command', `$fso = New-Object -ComObject Scripting.FileSystemObject; $fso.GetFolder('${escaped}').ShortPath`],
{ encoding: 'utf8' },
).trim();
return output || target;
} catch {
return target;
}
};
const createWindowsRebuildPath = (target) => {
if (process.platform !== 'win32') {
return { buildPath: target, cleanup: () => {} };
}
for (const letter of 'ZYXWVUTSRQPONMLKJIHGFED') {
const drive = `${letter}:`;
if (existsSync(`${drive}\\`)) continue;
try {
execFileSync('subst.exe', [drive, target], { stdio: 'ignore' });
return {
buildPath: `${drive}\\`,
cleanup: () => {
try {
execFileSync('subst.exe', [drive, '/d'], { stdio: 'ignore' });
} catch {
// Best-effort cleanup. The build result should not depend on this.
}
},
};
} catch {
// Try the next drive letter.
}
}
const shortPath = getWindowsShortPath(target);
if (shortPath === target && /\s/.test(target)) {
throw new Error(
`Unable to create a space-free Windows rebuild path for ${target}. `
+ 'All subst drive letters are unavailable and the volume did not return an 8.3 short path.',
);
}
return { buildPath: shortPath, cleanup: () => {} };
};
const writeWindowsNodeAddonApiIndex = async (nodeAddonApiDir, exportedNodeAddonApiDir) => {
if (process.platform !== 'win32') return;
const shortDir = getWindowsShortPath(exportedNodeAddonApiDir);
await fsp.writeFile(
path.join(nodeAddonApiDir, 'index.js'),
`const path = require('path');
const includeDir = ${JSON.stringify(shortDir)};
module.exports = {
include: \`"${shortDir}"\`,
include_dir: includeDir,
gyp: path.join(includeDir, 'node_api.gyp:nothing'),
targets: path.join(includeDir, 'node_addon_api.gyp'),
isNodeApiBuiltin: true,
needsFlag: false
};
`,
);
};
const ensureWindowsNodeAddonApiForNodePty = async (rebuildRootPath) => {
if (process.platform !== 'win32') return async () => {};
const nodePtyPackagePath = require.resolve('node-pty/package.json');
const nodePtyDir = path.dirname(nodePtyPackagePath);
const rootNodeAddonApiDir = path.dirname(require.resolve('node-addon-api/package.json'));
const tempNodeAddonApiDir = path.join(repoRoot, 'node_modules', '.openchamber-node-addon-api-7.1.1');
const exportedTempNodeAddonApiDir = path.join(rebuildRootPath, 'node_modules', '.openchamber-node-addon-api-7.1.1');
const localNodeAddonApiDir = path.join(nodePtyDir, 'node_modules', 'node-addon-api');
await fsp.rm(tempNodeAddonApiDir, { recursive: true, force: true });
await copyDirectory(rootNodeAddonApiDir, tempNodeAddonApiDir);
await fsp.access(path.join(tempNodeAddonApiDir, 'package.json'));
await fsp.rm(localNodeAddonApiDir, { recursive: true, force: true });
await copyDirectory(rootNodeAddonApiDir, localNodeAddonApiDir);
await writeWindowsNodeAddonApiIndex(localNodeAddonApiDir, exportedTempNodeAddonApiDir);
await fsp.access(path.join(localNodeAddonApiDir, 'package.json'));
return async () => {
await fsp.rm(localNodeAddonApiDir, { recursive: true, force: true });
await fsp.rm(tempNodeAddonApiDir, { recursive: true, force: true });
};
};
console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`);
// Rebuild against the hoisted root node_modules (bun workspace layout).
// force=true re-links regardless of cached state; prebuild-install lookup is
// bypassed by @electron/rebuild in favor of direct node-gyp builds.
await rebuild({
buildPath: repoRoot,
electronVersion,
force: true,
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
});
const rebuildPath = createWindowsRebuildPath(repoRoot);
let cleanupNodeAddonApi = async () => {};
try {
cleanupNodeAddonApi = await ensureWindowsNodeAddonApiForNodePty(rebuildPath.buildPath);
await rebuild({
buildPath: rebuildPath.buildPath,
electronVersion,
force: true,
arch: process.env.ELECTRON_BUILDER_ARCH || process.arch,
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
});
} finally {
try {
await cleanupNodeAddonApi();
} finally {
rebuildPath.cleanup();
}
}
console.log('[electron] native modules rebuilt successfully');