Files
openchamber/scripts/dev-web-hmr.mjs
T
Dave OteroandBohdan Triapitsyn becd240168 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>
2026-05-26 18:13:59 +03:00

224 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const useDetachedChildren = process.platform === 'darwin';
const webRoot = path.join(repoRoot, 'packages/web');
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 run(label, command, args, env = {}, options = {}) {
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: options.cwd || repoRoot,
stdio: 'inherit',
env: { ...process.env, ...env },
detached: useDetachedChildren,
windowsVerbatimArguments: isWindowsCommandScript,
}).on('error', (error) => {
console.error(`[dev:web:hmr] Failed to start ${label}:`, error);
});
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
if (!child || child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
const onExit = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
child.off('exit', onExit);
resolve();
}, timeoutMs);
child.once('exit', onExit);
});
}
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;
}
try {
if (useDetachedChildren && process.platform !== 'win32') {
process.kill(-child.pid, signal);
return;
}
} catch {
}
try {
child.kill(signal);
} catch {
}
}
async function stopChildTree(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return;
}
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);
}
if (child.exitCode === null && child.signalCode === null) {
signalChild(child, 'SIGKILL');
await waitForExit(child, 1000);
}
}
const uiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180';
const backendPort = process.env.OPENCHAMBER_HMR_API_PORT || '3902';
const hmrHost = process.env.OPENCHAMBER_HMR_HOST || '127.0.0.1';
function getLanAddresses() {
const addresses = [];
for (const networkAddresses of Object.values(os.networkInterfaces())) {
for (const address of networkAddresses || []) {
if (address.family !== 'IPv4' || address.internal) continue;
addresses.push(address.address);
}
}
return addresses;
}
function clearViteCache() {
const cacheDirs = [
path.join(webRoot, 'node_modules/.vite'),
path.join(webRoot, 'node_modules/.vite-temp'),
];
for (const cacheDir of cacheDirs) {
if (!existsSync(cacheDir)) continue;
rmSync(cacheDir, { recursive: true, force: true });
}
}
clearViteCache();
const api = run(
'api',
'bun',
['x', 'nodemon', '--watch', 'server', '--ext', 'js', '--exec', `bun server/index.js --port ${backendPort}`],
{
OPENCHAMBER_PORT: backendPort,
},
{ cwd: webRoot },
);
const vite = run(
'vite',
'bun',
['x', 'vite', '--force', '--host', hmrHost, '--port', uiPort, '--strictPort'],
{
OPENCHAMBER_PORT: backendPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
{ cwd: webRoot },
);
console.log(`[dev:web:hmr] UI with HMR: http://127.0.0.1:${uiPort}`);
if (hmrHost === '0.0.0.0' || hmrHost === '::') {
const lanAddresses = getLanAddresses();
if (lanAddresses.length > 0) {
for (const address of lanAddresses) {
console.log(`[dev:web:hmr] LAN/mobile UI: http://${address}:${uiPort}`);
}
} else {
console.log('[dev:web:hmr] LAN/mobile UI: no LAN IPv4 address found');
}
}
console.log(`[dev:web:hmr] API: http://127.0.0.1:${backendPort}`);
console.log('[dev:web:hmr] IMPORTANT: open UI URL above for HMR; backend URL has no HMR');
let shuttingDown = false;
async function shutdown(exitCode = 0) {
if (shuttingDown) return;
shuttingDown = true;
await Promise.all([stopChildTree(api), stopChildTree(vite)]);
process.exit(exitCode);
}
function onChildExit(label) {
return (code, signal) => {
if (shuttingDown) return;
if (code !== 0 || signal) {
console.error(`[dev:web:hmr] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
shutdown(typeof code === 'number' ? code : 1).catch(() => process.exit(1));
return;
}
shutdown(0).catch(() => process.exit(1));
};
}
api.on('exit', onChildExit('api'));
vite.on('exit', onChildExit('vite'));
process.on('SIGINT', () => {
shutdown(130).catch(() => process.exit(130));
});
process.on('SIGTERM', () => {
shutdown(143).catch(() => process.exit(143));
});
process.on('SIGHUP', () => {
shutdown(129).catch(() => process.exit(129));
});