Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
320 lines
11 KiB
JavaScript
320 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}'`;
|
|
}
|
|
}
|
|
if (storedOptions.apiOnly === true) {
|
|
restartCmdPrimary += ' --api-only';
|
|
restartCmdFallback += ' --api-only';
|
|
}
|
|
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' });
|
|
}
|
|
}
|
|
});
|
|
};
|