mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-18 22:57:31 +08:00
484 lines
14 KiB
JavaScript
484 lines
14 KiB
JavaScript
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs/promises';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const DEFAULT_PORT = 3001;
|
|
const HOST = '127.0.0.1';
|
|
const DISPLAY_HOST = 'localhost';
|
|
const HEALTH_TIMEOUT_MS = 1000;
|
|
const SERVER_START_TIMEOUT_MS = 30000;
|
|
const MAX_STARTUP_LOG_LINES = 300;
|
|
const SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');
|
|
const LOCAL_SERVER_URL_ENV_KEYS = [
|
|
'CLOUDCLI_DESKTOP_LOCAL_SERVER_URL',
|
|
'CLOUDCLI_LOCAL_SERVER_URL',
|
|
'ELECTRON_LOCAL_SERVER_URL',
|
|
];
|
|
const LOCAL_SERVER_PORT_ENV_KEYS = [
|
|
'CLOUDCLI_DESKTOP_LOCAL_SERVER_PORT',
|
|
'CLOUDCLI_SERVER_PORT',
|
|
'SERVER_PORT',
|
|
'PORT',
|
|
];
|
|
|
|
function requestJson(url, timeoutMs = HEALTH_TIMEOUT_MS) {
|
|
return new Promise((resolve) => {
|
|
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
let body = '';
|
|
|
|
res.setEncoding('utf8');
|
|
res.on('data', (chunk) => {
|
|
body += chunk;
|
|
});
|
|
res.on('end', () => {
|
|
try {
|
|
resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
json: JSON.parse(body),
|
|
});
|
|
} catch {
|
|
resolve({ ok: false, json: null });
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
resolve({ ok: false, json: null });
|
|
});
|
|
req.on('error', () => resolve({ ok: false, json: null }));
|
|
});
|
|
}
|
|
|
|
async function isCloudCliServer(baseUrl) {
|
|
const response = await requestJson(`${baseUrl}/health`);
|
|
return response.ok
|
|
&& response.json?.status === 'ok'
|
|
&& typeof response.json?.installMode === 'string';
|
|
}
|
|
|
|
function isPortAvailable(port, host = HOST) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
|
|
server.once('error', () => resolve(false));
|
|
server.once('listening', () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
server.listen(port, host);
|
|
});
|
|
}
|
|
|
|
function getFreePort() {
|
|
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 : DEFAULT_PORT;
|
|
server.close(() => resolve(port));
|
|
});
|
|
server.listen(0, HOST);
|
|
});
|
|
}
|
|
|
|
async function chooseServerPort(host) {
|
|
if (await isPortAvailable(DEFAULT_PORT, host)) {
|
|
return DEFAULT_PORT;
|
|
}
|
|
|
|
return getFreePort();
|
|
}
|
|
|
|
function getDesktopPath() {
|
|
const currentPath = process.env.PATH || '';
|
|
const commonPaths = process.platform === 'win32'
|
|
? []
|
|
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
|
|
return [...commonPaths, currentPath].filter(Boolean).join(path.delimiter);
|
|
}
|
|
|
|
function getNodeRuntime(usePackagedElectronRuntime) {
|
|
if (process.env.ELECTRON_NODE_PATH) {
|
|
return { command: process.env.ELECTRON_NODE_PATH, env: {}, label: 'ELECTRON_NODE_PATH' };
|
|
}
|
|
|
|
if (usePackagedElectronRuntime && process.versions.electron) {
|
|
return {
|
|
command: process.execPath,
|
|
env: { ELECTRON_RUN_AS_NODE: '1' },
|
|
label: `Electron ${process.versions.electron} Node ${process.versions.node}`,
|
|
};
|
|
}
|
|
|
|
if (process.env.npm_node_execpath) {
|
|
return { command: process.env.npm_node_execpath, env: {}, label: 'npm_node_execpath' };
|
|
}
|
|
|
|
return { command: 'node', env: {}, label: 'PATH node' };
|
|
}
|
|
|
|
function stripTrailingSlash(value) {
|
|
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
}
|
|
|
|
function addCandidateUrl(urls, rawUrl) {
|
|
if (!rawUrl) return;
|
|
try {
|
|
const parsed = new URL(String(rawUrl));
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
|
parsed.hash = '';
|
|
parsed.search = '';
|
|
const normalized = stripTrailingSlash(parsed.toString());
|
|
if (!urls.includes(normalized)) urls.push(normalized);
|
|
} catch {
|
|
// Ignore invalid user-provided discovery values.
|
|
}
|
|
}
|
|
|
|
function addCandidatePort(urls, rawPort) {
|
|
const port = Number.parseInt(String(rawPort || ''), 10);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
addCandidateUrl(urls, `http://${HOST}:${port}`);
|
|
}
|
|
|
|
function getPortFromUrl(baseUrl) {
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
if (parsed.port) return Number.parseInt(parsed.port, 10);
|
|
return parsed.protocol === 'https:' ? 443 : 80;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getDisplayUrl(baseUrl) {
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
if (parsed.hostname === HOST) {
|
|
parsed.hostname = DISPLAY_HOST;
|
|
}
|
|
return stripTrailingSlash(parsed.toString());
|
|
} catch {
|
|
return baseUrl;
|
|
}
|
|
}
|
|
|
|
async function readServerMarkerUrl() {
|
|
try {
|
|
const raw = await fs.readFile(SERVER_MARKER_PATH, 'utf8');
|
|
const marker = JSON.parse(raw);
|
|
return marker.url || (marker.port ? `http://${marker.host || HOST}:${marker.port}` : null);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getExistingServerCandidateUrls(defaultUrl) {
|
|
const urls = [];
|
|
|
|
for (const key of LOCAL_SERVER_URL_ENV_KEYS) {
|
|
addCandidateUrl(urls, process.env[key]);
|
|
}
|
|
|
|
addCandidateUrl(urls, await readServerMarkerUrl());
|
|
|
|
for (const key of LOCAL_SERVER_PORT_ENV_KEYS) {
|
|
addCandidatePort(urls, process.env[key]);
|
|
}
|
|
|
|
addCandidateUrl(urls, defaultUrl);
|
|
return urls;
|
|
}
|
|
|
|
async function waitForCloudCliServer(baseUrl, timeoutMs) {
|
|
const startedAt = Date.now();
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
if (await isCloudCliServer(baseUrl)) {
|
|
return true;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export class LocalServerController {
|
|
constructor({ appRoot, settingsPath, isPackaged = false, onChange }) {
|
|
this.appRoot = appRoot;
|
|
this.settingsPath = settingsPath;
|
|
this.isPackaged = isPackaged;
|
|
this.onChange = onChange;
|
|
this.localServerUrl = null;
|
|
this.localServerPort = null;
|
|
this.ownedServerProcess = null;
|
|
this.startupLogs = [];
|
|
this.desktopSettings = {
|
|
keepLocalServerRunning: false,
|
|
exposeLocalServerOnNetwork: false,
|
|
};
|
|
}
|
|
|
|
getSettings() {
|
|
return this.desktopSettings;
|
|
}
|
|
|
|
getLocalServerUrl() {
|
|
return this.localServerUrl;
|
|
}
|
|
|
|
getHealthCheckUrl() {
|
|
if (!this.localServerPort) return this.localServerUrl;
|
|
return `http://${HOST}:${this.localServerPort}`;
|
|
}
|
|
|
|
appendStartupLog(line) {
|
|
const text = String(line || '').trimEnd();
|
|
if (!text) return;
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
this.startupLogs.push(`[${timestamp}] ${text}`);
|
|
if (this.startupLogs.length > MAX_STARTUP_LOG_LINES) {
|
|
this.startupLogs.splice(0, this.startupLogs.length - MAX_STARTUP_LOG_LINES);
|
|
}
|
|
this.onChange?.();
|
|
}
|
|
|
|
getStartupLogs() {
|
|
return [...this.startupLogs];
|
|
}
|
|
|
|
getPendingTarget() {
|
|
return {
|
|
kind: 'local',
|
|
name: 'Local CloudCLI',
|
|
url: this.localServerUrl || `http://${DISPLAY_HOST}:${this.localServerPort || DEFAULT_PORT}`,
|
|
};
|
|
}
|
|
|
|
getLanAddress() {
|
|
const interfaces = os.networkInterfaces();
|
|
for (const entries of Object.values(interfaces)) {
|
|
for (const entry of entries || []) {
|
|
if (entry.family === 'IPv4' && !entry.internal) {
|
|
return entry.address;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getShareableWebUrl() {
|
|
if (!this.localServerUrl || !this.localServerPort) return null;
|
|
if (this.desktopSettings.exposeLocalServerOnNetwork) {
|
|
const lanAddress = this.getLanAddress();
|
|
if (lanAddress) {
|
|
return `http://${lanAddress}:${this.localServerPort}`;
|
|
}
|
|
}
|
|
return this.getLocalServerUrl();
|
|
}
|
|
|
|
getServerBindHost() {
|
|
return this.desktopSettings.exposeLocalServerOnNetwork ? '0.0.0.0' : HOST;
|
|
}
|
|
|
|
async loadDesktopSettings() {
|
|
try {
|
|
const raw = await fs.readFile(this.settingsPath, 'utf8');
|
|
const stored = JSON.parse(raw);
|
|
this.desktopSettings = {
|
|
keepLocalServerRunning: Boolean(stored.keepLocalServerRunning),
|
|
exposeLocalServerOnNetwork: Boolean(stored.exposeLocalServerOnNetwork),
|
|
};
|
|
} catch {
|
|
this.desktopSettings = {
|
|
keepLocalServerRunning: false,
|
|
exposeLocalServerOnNetwork: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
async saveDesktopSettings(nextSettings = this.desktopSettings) {
|
|
this.desktopSettings = {
|
|
keepLocalServerRunning: Boolean(nextSettings.keepLocalServerRunning),
|
|
exposeLocalServerOnNetwork: Boolean(nextSettings.exposeLocalServerOnNetwork),
|
|
};
|
|
await fs.mkdir(path.dirname(this.settingsPath), { recursive: true });
|
|
await fs.writeFile(this.settingsPath, JSON.stringify(this.desktopSettings, null, 2), 'utf8');
|
|
this.onChange?.();
|
|
}
|
|
|
|
async updateDesktopSetting(key, value) {
|
|
if (!Object.prototype.hasOwnProperty.call(this.desktopSettings, key)) {
|
|
throw new Error(`Unknown desktop setting: ${key}`);
|
|
}
|
|
|
|
const wasExposeSetting = key === 'exposeLocalServerOnNetwork';
|
|
const wasLocalRunning = Boolean(this.localServerUrl);
|
|
await this.saveDesktopSettings({ ...this.desktopSettings, [key]: Boolean(value) });
|
|
|
|
return {
|
|
desktopSettings: this.desktopSettings,
|
|
requiresRestartNotice: wasExposeSetting && wasLocalRunning,
|
|
};
|
|
}
|
|
|
|
startBundledServer(port) {
|
|
const serverEntry = process.env.ELECTRON_SERVER_ENTRY
|
|
|| path.join(this.appRoot, 'dist-server', 'server', 'index.js');
|
|
const bindHost = this.getServerBindHost();
|
|
const runtime = getNodeRuntime(this.isPackaged);
|
|
|
|
const command = `${runtime.command} ${serverEntry}`;
|
|
this.appendStartupLog(`$ ${command}`);
|
|
this.appendStartupLog(`runtime: ${runtime.label}`);
|
|
this.appendStartupLog(`cwd: ${this.appRoot}`);
|
|
this.appendStartupLog(`HOST=${bindHost} SERVER_PORT=${port} NODE_ENV=production`);
|
|
|
|
this.ownedServerProcess = spawn(runtime.command, [serverEntry], {
|
|
cwd: this.appRoot,
|
|
detached: true,
|
|
env: {
|
|
...process.env,
|
|
...runtime.env,
|
|
HOST: bindHost,
|
|
SERVER_PORT: String(port),
|
|
NODE_ENV: 'production',
|
|
PATH: getDesktopPath(),
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
});
|
|
|
|
this.ownedServerProcess.once('error', (error) => {
|
|
this.appendStartupLog(`failed to start process: ${error.message}`);
|
|
this.ownedServerProcess = null;
|
|
});
|
|
|
|
this.ownedServerProcess.stdout?.on('data', (chunk) => {
|
|
for (const line of String(chunk).split(/\r?\n/)) {
|
|
this.appendStartupLog(line);
|
|
}
|
|
});
|
|
|
|
this.ownedServerProcess.stderr?.on('data', (chunk) => {
|
|
for (const line of String(chunk).split(/\r?\n/)) {
|
|
this.appendStartupLog(`stderr: ${line}`);
|
|
}
|
|
});
|
|
|
|
this.ownedServerProcess.once('exit', (code, signal) => {
|
|
this.appendStartupLog(`process exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
|
if (this.ownedServerProcess) {
|
|
console.error(`CloudCLI desktop server exited with code ${code ?? 'null'} and signal ${signal ?? 'null'}`);
|
|
}
|
|
this.ownedServerProcess = null;
|
|
});
|
|
}
|
|
|
|
async resolveLocalServerUrl() {
|
|
const defaultUrl = `http://${HOST}:${DEFAULT_PORT}`;
|
|
const defaultDisplayUrl = `http://${DISPLAY_HOST}:${DEFAULT_PORT}`;
|
|
const devUrl = process.env.ELECTRON_DEV_URL;
|
|
const forceOwnServer = process.env.ELECTRON_FORCE_OWN_SERVER === '1';
|
|
|
|
if (devUrl) {
|
|
const ready = await waitForCloudCliServer(defaultUrl, SERVER_START_TIMEOUT_MS);
|
|
if (!ready) {
|
|
throw new Error(`Development backend did not become ready at ${defaultDisplayUrl}`);
|
|
}
|
|
this.localServerPort = DEFAULT_PORT;
|
|
return devUrl;
|
|
}
|
|
|
|
if (!forceOwnServer) {
|
|
const candidateUrls = await getExistingServerCandidateUrls(defaultUrl);
|
|
for (const candidateUrl of candidateUrls) {
|
|
if (await isCloudCliServer(candidateUrl)) {
|
|
const displayUrl = getDisplayUrl(candidateUrl);
|
|
this.localServerPort = getPortFromUrl(candidateUrl);
|
|
this.appendStartupLog(`Using existing Local CloudCLI at ${displayUrl}`);
|
|
return displayUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
const port = await chooseServerPort(this.getServerBindHost());
|
|
const serverUrl = `http://${HOST}:${port}`;
|
|
const displayUrl = `http://${DISPLAY_HOST}:${port}`;
|
|
this.localServerPort = port;
|
|
this.startBundledServer(port);
|
|
|
|
const ready = await waitForCloudCliServer(serverUrl, SERVER_START_TIMEOUT_MS);
|
|
if (!ready) {
|
|
const recentLogs = this.getStartupLogs().slice(-20).join('\n');
|
|
this.localServerPort = null;
|
|
throw new Error([
|
|
`Bundled backend did not become ready at ${displayUrl}.`,
|
|
recentLogs ? `Recent startup output:\n${recentLogs}` : 'No startup output was captured.',
|
|
].join('\n\n'));
|
|
}
|
|
|
|
this.appendStartupLog(`Local CloudCLI ready at ${displayUrl}`);
|
|
this.localServerUrl = displayUrl;
|
|
return displayUrl;
|
|
}
|
|
|
|
async ensureLocalServer() {
|
|
if (!this.localServerUrl) {
|
|
this.localServerUrl = await this.resolveLocalServerUrl();
|
|
}
|
|
return this.localServerUrl;
|
|
}
|
|
|
|
async getResolvedTarget() {
|
|
await this.ensureLocalServer();
|
|
return {
|
|
kind: 'local',
|
|
name: 'Local CloudCLI',
|
|
url: this.localServerUrl,
|
|
};
|
|
}
|
|
|
|
async loadLocalTarget() {
|
|
return {
|
|
pendingTarget: this.getPendingTarget(),
|
|
target: await this.getResolvedTarget(),
|
|
};
|
|
}
|
|
|
|
hasOwnedServer() {
|
|
return Boolean(this.ownedServerProcess);
|
|
}
|
|
|
|
detachOwnedServer() {
|
|
if (!this.ownedServerProcess) return;
|
|
this.ownedServerProcess.unref();
|
|
this.ownedServerProcess = null;
|
|
}
|
|
|
|
async shutdownOwnedServer() {
|
|
if (!this.ownedServerProcess) return;
|
|
|
|
const child = this.ownedServerProcess;
|
|
this.ownedServerProcess = null;
|
|
child.kill('SIGTERM');
|
|
|
|
await new Promise((resolve) => {
|
|
const timeout = setTimeout(resolve, 3000);
|
|
child.once('exit', () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
export { DEFAULT_PORT, HOST };
|