Files
openchamber/packages/web/server/lib/opencode/openchamber-routes.js
T
Bohdan Triapitsyn 2014303bc0 feat: add startup launch support (#1421)
Add launch-at-startup support across the Electron desktop app and the web CLI.

Electron now supports macOS launch-at-login through the native login item API. Login launches start OpenChamber in the background without opening a window, while Dock activation, deep links, and second-instance launches still open or focus the normal app window. The desktop Settings UI now exposes a localized launch-at-login toggle in Desktop Network Access.

The web CLI now includes `openchamber startup status|enable|disable`, backed by native user services:
- macOS: launchd LaunchAgent
- Linux: systemd --user service
- Windows: Task Scheduler

Startup services run `openchamber serve --foreground` so the OS service manager owns process lifetime and restarts. Foreground service updates now defer restarts to the service manager instead of spawning duplicate CLI restarts.

Startup services snapshot useful environment variables by default so provider tokens, PATH, SSH agent settings, and OpenCode configuration survive login/reboot starts. The snapshot avoids shell/session-only state, uses systemd-compatible env quoting on Linux, and avoids unused env artifacts on macOS.

Also adds localized docs for startup services and environment variables.
2026-05-26 01:36:11 +03:00

316 lines
11 KiB
JavaScript

export const registerOpenChamberRoutes = (app, dependencies) => {
const {
fs,
path,
process,
server,
__dirname,
openchamberDataDir,
modelsDevApiUrl,
modelsMetadataCacheTtl,
readSettingsFromDiskMigrated,
fetchFreeZenModels,
getCachedZenModels,
} = dependencies;
let cachedModelsMetadata = null;
let cachedModelsMetadataTimestamp = 0;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const { checkForUpdates } = await import('../package-manager.js');
const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
const parseReportUsage = (value) => {
if (typeof value !== 'string') return true;
const normalized = value.trim().toLowerCase();
if (normalized === 'false' || normalized === '0' || normalized === 'no') return false;
return true;
};
const inferDeviceClass = (ua) => {
const value = (ua || '').toLowerCase();
if (!value) return 'unknown';
if (value.includes('ipad') || value.includes('tablet')) return 'tablet';
if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile';
return 'desktop';
};
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '';
const updateInfo = await checkForUpdates({
appType: parseString(req.query.appType),
deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent),
platform: parseString(req.query.platform),
arch: parseString(req.query.arch),
instanceMode: parseString(req.query.instanceMode),
currentVersion: parseString(req.query.currentVersion),
reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
});
res.json(updateInfo);
} catch (error) {
console.error('Failed to check for updates:', error);
res.status(500).json({
available: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
});
}
});
app.post('/api/openchamber/update-install', async (_req, res) => {
try {
const { spawn: spawnChild } = await import('child_process');
const {
checkForUpdates,
getUpdateCommand,
detectPackageManagerDetails,
} = await import('../package-manager.js');
const updateInfo = await checkForUpdates();
if (!updateInfo.available) {
return res.status(400).json({ error: 'No update available' });
}
const pmDetails = detectPackageManagerDetails();
const pm = pmDetails.packageManager;
const updateCmd = getUpdateCommand(pm);
const isContainer =
fs.existsSync('/.dockerenv') ||
Boolean(process.env.CONTAINER) ||
process.env.container === 'docker';
if (isContainer) {
res.json({
success: true,
message: 'Update starting, server will stay online',
version: updateInfo.version,
packageManager: pm,
autoRestart: false,
});
setTimeout(() => {
console.log(`\nInstalling update using ${pm} (container mode)...`);
console.log(`Running: ${updateCmd}`);
const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh';
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
const child = spawnChild(shell, [shellFlag, updateCmd], {
detached: true,
stdio: 'ignore',
env: process.env,
});
child.unref();
}, 500);
return;
}
const currentPort = server.address()?.port || 3000;
const instanceFilePath = path.join(openchamberDataDir, 'run', `openchamber-${currentPort}.json`);
let storedOptions = { port: currentPort, daemon: true };
try {
const content = await fs.promises.readFile(instanceFilePath, 'utf8');
storedOptions = JSON.parse(content);
} catch {
}
const launchMode = storedOptions.launchMode === 'foreground' ? 'foreground' : 'daemon';
const isForegroundService = launchMode === 'foreground';
const isWindows = process.platform === 'win32';
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
const quoteCmd = (value) => {
const stringValue = String(value);
return `"${stringValue.replace(/"/g, '""')}"`;
};
const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js');
const restartParts = [
isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath),
isWindows ? quoteCmd(cliPath) : quotePosix(cliPath),
'serve',
'--port',
String(storedOptions.port),
];
let restartCmdPrimary = restartParts.join(' ');
let restartCmdFallback = `openchamber serve --port ${storedOptions.port}`;
if (storedOptions.host) {
if (isWindows) {
const escapedHost = storedOptions.host.replace(/"/g, '""');
restartCmdPrimary += ` --host "${escapedHost}"`;
restartCmdFallback += ` --host "${escapedHost}"`;
} else {
const escapedHost = storedOptions.host.replace(/'/g, "'\\''");
restartCmdPrimary += ` --host '${escapedHost}'`;
restartCmdFallback += ` --host '${escapedHost}'`;
}
}
if (storedOptions.uiPassword) {
if (isWindows) {
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
restartCmdPrimary += ` --ui-password "${escapedPw}"`;
restartCmdFallback += ` --ui-password "${escapedPw}"`;
} else {
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
restartCmdPrimary += ` --ui-password '${escapedPw}'`;
restartCmdFallback += ` --ui-password '${escapedPw}'`;
}
}
const restartCmd = isForegroundService ? '' : `(${restartCmdPrimary}) || (${restartCmdFallback})`;
const updateLogPath = path.join(openchamberDataDir, 'update-install.log');
const logPreamble = [
'',
`=== OpenChamber update ${new Date().toISOString()} ===`,
`currentVersion=${updateInfo.currentVersion || 'unknown'}`,
`targetVersion=${updateInfo.version || 'unknown'}`,
`packageManager=${pm}`,
`packageManagerReason=${pmDetails.reason || 'unknown'}`,
`packageManagerCommand=${pmDetails.packageManagerCommand || 'unknown'}`,
`packagePath=${pmDetails.packagePath || 'unknown'}`,
`globalNodeModulesRoot=${pmDetails.globalNodeModulesRoot || 'unknown'}`,
`mode=${isContainer ? 'container' : 'restart'}`,
`launchMode=${launchMode}`,
`updateCommand=${updateCmd}`,
`restartCommand=${restartCmd || 'service-manager'}`,
`logPath=${updateLogPath}`,
].join('\n');
res.json({
success: true,
message: 'Update starting, server will restart shortly',
version: updateInfo.version,
packageManager: pm,
autoRestart: true,
restartManager: isForegroundService ? 'service' : 'cli',
});
setTimeout(() => {
console.log(`\nInstalling update using ${pm}...`);
console.log(`Running: ${updateCmd}`);
console.log(logPreamble);
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
const shellFlag = isWindows ? '/c' : '-c';
const script = isWindows
? `
echo ${quoteCmd(logPreamble)}
timeout /t 2 /nobreak >nul
${updateCmd}
if %ERRORLEVEL% EQU 0 (
echo Update successful, restarting OpenChamber...
${restartCmd || 'echo Service manager will restart OpenChamber.'}
) else (
echo Update failed
exit /b 1
)
`
: `
printf '%s\n' ${quotePosix(logPreamble)}
sleep 2
${updateCmd}
if [ $? -eq 0 ]; then
echo "Update successful, restarting OpenChamber..."
${restartCmd || 'echo "Service manager will restart OpenChamber."'}
else
echo "Update failed"
exit 1
fi
`;
let logFd = null;
try {
fs.mkdirSync(path.dirname(updateLogPath), { recursive: true });
logFd = fs.openSync(updateLogPath, 'a');
} catch (logError) {
console.warn('Failed to open update log file, continuing without log capture:', logError);
}
const child = spawnChild(shell, [shellFlag, script], {
detached: true,
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
env: process.env,
});
child.unref();
if (logFd !== null) {
try {
fs.closeSync(logFd);
} catch {
}
}
console.log('Update process spawned, shutting down server...');
setTimeout(() => {
process.exit(0);
}, 500);
}, 500);
} catch (error) {
console.error('Failed to install update:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to install update',
});
}
});
app.get('/api/openchamber/models-metadata', async (_req, res) => {
const now = Date.now();
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) {
res.setHeader('Cache-Control', 'public, max-age=60');
return res.json(cachedModelsMetadata);
}
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
try {
const response = await fetch(modelsDevApiUrl, {
signal: controller?.signal,
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
throw new Error(`models.dev responded with status ${response.status}`);
}
const metadata = await response.json();
cachedModelsMetadata = metadata;
cachedModelsMetadataTimestamp = Date.now();
res.setHeader('Cache-Control', 'public, max-age=300');
res.json(metadata);
} catch (error) {
console.warn('Failed to fetch models.dev metadata via server:', error);
if (cachedModelsMetadata) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedModelsMetadata);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
}
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
});
app.get('/api/zen/models', async (_req, res) => {
try {
const models = await fetchFreeZenModels();
res.setHeader('Cache-Control', 'public, max-age=300');
res.json({ models });
} catch (error) {
console.warn('Failed to fetch zen models:', error);
const cachedZenModels = getCachedZenModels();
if (cachedZenModels) {
res.setHeader('Cache-Control', 'public, max-age=60');
res.json(cachedZenModels);
} else {
const statusCode = error?.name === 'AbortError' ? 504 : 502;
res.status(statusCode).json({ error: 'Failed to retrieve zen models' });
}
}
});
};