## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models
589 lines
17 KiB
TypeScript
589 lines
17 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import { execSync } from 'child_process';
|
|
import { spawnSync } from 'child_process';
|
|
import { createOpencodeServer } from '@opencode-ai/sdk/v2/server';
|
|
|
|
const READY_CHECK_TIMEOUT_MS = 30000;
|
|
|
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
|
|
|
export type OpenCodeDebugInfo = {
|
|
mode: 'managed' | 'external';
|
|
status: ConnectionStatus;
|
|
lastError?: string;
|
|
workingDirectory: string;
|
|
cliAvailable: boolean;
|
|
cliPath: string | null;
|
|
configuredApiUrl: string | null;
|
|
configuredPort: number | null;
|
|
detectedPort: number | null;
|
|
apiPrefix: string;
|
|
apiPrefixDetected: boolean;
|
|
startCount: number;
|
|
restartCount: number;
|
|
lastStartAt: number | null;
|
|
lastConnectedAt: number | null;
|
|
lastExitCode: number | null;
|
|
serverUrl: string | null;
|
|
lastReadyElapsedMs: number | null;
|
|
lastReadyAttempts: number | null;
|
|
lastStartAttempts: number | null;
|
|
version: string | null;
|
|
};
|
|
|
|
export interface OpenCodeManager {
|
|
start(workdir?: string): Promise<void>;
|
|
stop(): Promise<void>;
|
|
restart(): Promise<void>;
|
|
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
|
getStatus(): ConnectionStatus;
|
|
getApiUrl(): string | null;
|
|
getWorkingDirectory(): string;
|
|
isCliAvailable(): boolean;
|
|
getDebugInfo(): OpenCodeDebugInfo;
|
|
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
|
}
|
|
|
|
function resolvePortFromUrl(url: string): number | null {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.port ? parseInt(parsed.port, 10) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isExecutable(filePath: string): boolean {
|
|
if (!filePath) return false;
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (!stat.isFile()) return false;
|
|
// Windows executability is extension-based.
|
|
if (process.platform === 'win32') {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (!ext) return true;
|
|
return ['.exe', '.cmd', '.bat', '.com'].includes(ext);
|
|
}
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function appendToPath(dir: string) {
|
|
const trimmed = (dir || '').trim();
|
|
if (!trimmed) return;
|
|
const current = process.env.PATH || '';
|
|
const parts = current.split(path.delimiter).filter(Boolean);
|
|
if (parts.includes(trimmed)) return;
|
|
process.env.PATH = [trimmed, ...parts].join(path.delimiter);
|
|
}
|
|
|
|
function resolveOpencodeCliPath(): string | null {
|
|
const explicit = [
|
|
process.env.OPENCODE_BINARY,
|
|
process.env.OPENCODE_PATH,
|
|
process.env.OPENCHAMBER_OPENCODE_PATH,
|
|
process.env.OPENCHAMBER_OPENCODE_BIN,
|
|
]
|
|
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|
.filter(Boolean);
|
|
|
|
for (const candidate of explicit) {
|
|
if (isExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
const home = os.homedir();
|
|
const unixFallbacks = [
|
|
path.join(home, '.opencode', 'bin', 'opencode'),
|
|
path.join(home, '.local', 'bin', 'opencode'),
|
|
path.join(home, 'bin', 'opencode'),
|
|
];
|
|
|
|
const winFallbacks = (() => {
|
|
const userProfile = process.env.USERPROFILE || home;
|
|
const appData = process.env.APPDATA || '';
|
|
const localAppData = process.env.LOCALAPPDATA || '';
|
|
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
|
|
|
return [
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
|
|
path.join(appData, 'npm', 'opencode.cmd'),
|
|
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
|
|
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
|
|
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
|
|
// Bun global install
|
|
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
|
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
|
// Some installers use LocalAppData
|
|
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
|
|
].filter(Boolean);
|
|
})();
|
|
|
|
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
|
for (const candidate of fallbacks) {
|
|
if (isExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
try {
|
|
const result = spawnSync('where', ['opencode'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
if (result.status === 0) {
|
|
const lines = (result.stdout || '')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
const found = lines.find((line) => isExecutable(line));
|
|
if (found) return found;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Non-Windows: try a login shell PATH lookup.
|
|
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean) as string[];
|
|
for (const shell of shells) {
|
|
if (!isExecutable(shell)) continue;
|
|
try {
|
|
const result = spawnSync(shell, ['-lic', 'command -v opencode'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
if (result.status === 0) {
|
|
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
|
if (found && isExecutable(found)) {
|
|
return found;
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
type ReadyResult =
|
|
| { ok: true; baseUrl: string; elapsedMs: number; attempts: number; version: string | null }
|
|
| { ok: false; elapsedMs: number; attempts: number; version: null };
|
|
|
|
function normalizeBaseUrl(value: string): string {
|
|
return value.replace(/\/+$/, '');
|
|
}
|
|
|
|
function getCandidateBaseUrls(serverUrl: string): string[] {
|
|
const normalized = normalizeBaseUrl(serverUrl);
|
|
try {
|
|
const parsed = new URL(normalized);
|
|
const origin = parsed.origin;
|
|
|
|
const candidates: string[] = [];
|
|
const add = (url: string) => {
|
|
const v = normalizeBaseUrl(url);
|
|
if (!candidates.includes(v)) candidates.push(v);
|
|
};
|
|
|
|
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
|
// Prefer plain origin. Only keep SDK url when already root.
|
|
add(origin);
|
|
if (normalizedPath === '' || normalizedPath === '/') {
|
|
add(normalized);
|
|
}
|
|
|
|
return candidates;
|
|
} catch {
|
|
return [normalized];
|
|
}
|
|
}
|
|
|
|
async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<ReadyResult> {
|
|
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
|
|
const start = Date.now();
|
|
const candidates = getCandidateBaseUrls(serverUrl);
|
|
let attempts = 0;
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
for (const baseUrl of candidates) {
|
|
attempts += 1;
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
|
|
// OpenCode readiness check.
|
|
const url = new URL(`${baseUrl}/global/health`);
|
|
const res = await fetch(url.toString(), {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
signal: controller.signal,
|
|
});
|
|
|
|
let body: { healthy?: boolean, version?: string } | null = null;
|
|
try {
|
|
body = (await res.json()) as { healthy?: boolean, version?: string };
|
|
} catch {
|
|
body = null;
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
outputChannel?.appendLine(
|
|
`Health check to ${url.toString()} returned ${res.status} with body: ${JSON.stringify(body)}`
|
|
);
|
|
|
|
if (res.ok && body?.healthy === true) {
|
|
return { ok: true, baseUrl, elapsedMs: Date.now() - start, attempts, version: body?.version ?? null };
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
|
|
return { ok: false, elapsedMs: Date.now() - start, attempts, version: null };
|
|
}
|
|
|
|
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
|
// Discard unused parameter - reserved for future use (state persistence, subscriptions)
|
|
void _context;
|
|
let server: { url: string; close: () => void } | null = null;
|
|
let managedApiUrlOverride: string | null = null;
|
|
let status: ConnectionStatus = 'disconnected';
|
|
let lastError: string | undefined;
|
|
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
|
const workspaceDirectory = (): string =>
|
|
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
|
let workingDirectory: string = workspaceDirectory();
|
|
let startCount = 0;
|
|
let restartCount = 0;
|
|
let lastStartAt: number | null = null;
|
|
let lastConnectedAt: number | null = null;
|
|
let lastExitCode: number | null = null;
|
|
let lastReadyElapsedMs: number | null = null;
|
|
let lastReadyAttempts: number | null = null;
|
|
let lastStartAttempts: number | null = null;
|
|
let version: string | null = null;
|
|
|
|
let detectedPort: number | null = null;
|
|
let cliMissing = false;
|
|
let cliPath: string | null = null;
|
|
|
|
let pendingOperation: Promise<void> | null = null;
|
|
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
const configuredApiUrl = config.get<string>('apiUrl') || '';
|
|
const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0;
|
|
|
|
let configuredPort: number | null = null;
|
|
if (useConfiguredUrl) {
|
|
try {
|
|
const parsed = new URL(configuredApiUrl);
|
|
if (parsed.port) {
|
|
configuredPort = parseInt(parsed.port, 10);
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
}
|
|
|
|
const setStatus = (newStatus: ConnectionStatus, error?: string) => {
|
|
if (status !== newStatus || lastError !== error) {
|
|
status = newStatus;
|
|
lastError = error;
|
|
if (newStatus === 'connected') {
|
|
lastConnectedAt = Date.now();
|
|
}
|
|
listeners.forEach(cb => cb(status, error));
|
|
}
|
|
};
|
|
|
|
const getApiUrl = (): string | null => {
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
return configuredApiUrl.replace(/\/+$/, '');
|
|
}
|
|
if (managedApiUrlOverride) {
|
|
return managedApiUrlOverride.replace(/\/+$/, '');
|
|
}
|
|
if (server?.url) {
|
|
return server.url.replace(/\/+$/, '');
|
|
}
|
|
if (detectedPort) {
|
|
return `http://127.0.0.1:${detectedPort}`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
async function startInternal(workdir?: string): Promise<void> {
|
|
startCount += 1;
|
|
setStatus('connecting');
|
|
lastStartAt = Date.now();
|
|
lastStartAttempts = startCount;
|
|
|
|
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
|
workingDirectory = workdir.trim();
|
|
} else {
|
|
workingDirectory = workspaceDirectory();
|
|
}
|
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
setStatus('connecting');
|
|
setStatus('connected');
|
|
return;
|
|
}
|
|
|
|
// If server already running, don't spawn another
|
|
if (server) {
|
|
if (status !== 'connected') {
|
|
setStatus('connected');
|
|
}
|
|
return;
|
|
}
|
|
|
|
setStatus('connecting');
|
|
cliMissing = false;
|
|
cliPath = null;
|
|
|
|
detectedPort = null;
|
|
lastExitCode = null;
|
|
managedApiUrlOverride = null;
|
|
|
|
try {
|
|
// Best-effort: locate CLI even when VS Code PATH is stale.
|
|
const resolvedCli = resolveOpencodeCliPath();
|
|
if (resolvedCli) {
|
|
cliPath = resolvedCli;
|
|
appendToPath(path.dirname(resolvedCli));
|
|
}
|
|
|
|
// SDK spawns `opencode serve` in current process cwd.
|
|
// Some OpenCode endpoints behave differently based on server process cwd,
|
|
// so ensure we start it from the workspace directory.
|
|
const originalCwd = process.cwd();
|
|
try {
|
|
process.chdir(workingDirectory);
|
|
server = await createOpencodeServer({
|
|
hostname: '127.0.0.1',
|
|
port: 0,
|
|
timeout: READY_CHECK_TIMEOUT_MS,
|
|
signal: undefined,
|
|
});
|
|
} finally {
|
|
try {
|
|
process.chdir(originalCwd);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (server && server.url) {
|
|
// Validate readiness for the current workspace context.
|
|
const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS);
|
|
lastReadyElapsedMs = ready.elapsedMs;
|
|
lastReadyAttempts = ready.attempts;
|
|
if (ready.ok) {
|
|
managedApiUrlOverride = ready.baseUrl;
|
|
detectedPort = resolvePortFromUrl(ready.baseUrl);
|
|
version = ready.version;
|
|
setStatus('connected');
|
|
} else {
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
server = null;
|
|
throw new Error('Server started but health check failed');
|
|
}
|
|
} else {
|
|
throw new Error('Server started but URL is missing');
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
// Check for ENOENT or generic spawn failure which implies CLI missing
|
|
if (message.includes('ENOENT') || message.includes('spawn opencode')) {
|
|
cliMissing = true;
|
|
if (!cliPath) {
|
|
cliPath = resolveOpencodeCliPath();
|
|
}
|
|
setStatus('error', 'OpenCode CLI not found. Install it and ensure it\'s in PATH.');
|
|
vscode.window.showErrorMessage(
|
|
'OpenCode CLI not found. Please install it and ensure it\'s in PATH.',
|
|
'More Info'
|
|
).then(selection => {
|
|
if (selection === 'More Info') {
|
|
vscode.env.openExternal(vscode.Uri.parse('https://github.com/anomalyco/opencode'));
|
|
}
|
|
});
|
|
} else {
|
|
setStatus('error', `Failed to start OpenCode: ${message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function stopInternal(): Promise<void> {
|
|
const portToKill = detectedPort;
|
|
|
|
if (server) {
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// Ignore close errors
|
|
}
|
|
server = null;
|
|
}
|
|
|
|
// SDK's proc.kill() only kills the Node wrapper, not the actual opencode binary.
|
|
// Kill any process listening on our port to clean up orphaned children.
|
|
if (portToKill) {
|
|
try {
|
|
const lsofOutput = execSync(`lsof -ti:${portToKill} 2>/dev/null || true`, {
|
|
encoding: 'utf8',
|
|
timeout: 5000
|
|
});
|
|
const myPid = process.pid;
|
|
for (const pidStr of lsofOutput.split(/\s+/)) {
|
|
const pid = parseInt(pidStr.trim(), 10);
|
|
if (pid && pid !== myPid) {
|
|
try {
|
|
execSync(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'ignore', timeout: 2000 });
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore - process may already be dead
|
|
}
|
|
}
|
|
|
|
managedApiUrlOverride = null;
|
|
detectedPort = null;
|
|
version = null;
|
|
setStatus('disconnected');
|
|
}
|
|
|
|
async function restartInternal(): Promise<void> {
|
|
restartCount += 1;
|
|
await stopInternal();
|
|
await new Promise(r => setTimeout(r, 250));
|
|
await startInternal();
|
|
}
|
|
|
|
async function start(workdir?: string): Promise<void> {
|
|
if (pendingOperation) {
|
|
await pendingOperation;
|
|
if (server) {
|
|
return;
|
|
}
|
|
}
|
|
lastStartAttempts = 1;
|
|
pendingOperation = startInternal(workdir);
|
|
try {
|
|
await pendingOperation;
|
|
} finally {
|
|
pendingOperation = null;
|
|
}
|
|
}
|
|
|
|
async function stop(): Promise<void> {
|
|
if (pendingOperation) {
|
|
await pendingOperation;
|
|
}
|
|
// Check if already stopped
|
|
if (!server) {
|
|
return;
|
|
}
|
|
pendingOperation = stopInternal();
|
|
try {
|
|
await pendingOperation;
|
|
} finally {
|
|
pendingOperation = null;
|
|
}
|
|
}
|
|
|
|
async function restart(): Promise<void> {
|
|
if (pendingOperation) {
|
|
await pendingOperation;
|
|
}
|
|
lastStartAttempts = 1;
|
|
pendingOperation = restartInternal();
|
|
try {
|
|
await pendingOperation;
|
|
} finally {
|
|
pendingOperation = null;
|
|
}
|
|
}
|
|
|
|
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
|
|
void newPath;
|
|
const workspacePath = workspaceDirectory();
|
|
const nextDirectory = workspacePath;
|
|
|
|
if (workingDirectory === nextDirectory) {
|
|
return { success: true, restarted: false, path: nextDirectory };
|
|
}
|
|
|
|
workingDirectory = nextDirectory;
|
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
return { success: true, restarted: false, path: nextDirectory };
|
|
}
|
|
|
|
return { success: true, restarted: false, path: nextDirectory };
|
|
}
|
|
|
|
return {
|
|
start,
|
|
stop,
|
|
restart,
|
|
setWorkingDirectory,
|
|
getStatus: () => status,
|
|
getApiUrl,
|
|
getWorkingDirectory: () => workingDirectory,
|
|
isCliAvailable: () => !cliMissing,
|
|
getDebugInfo: () => ({
|
|
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
|
status,
|
|
lastError,
|
|
workingDirectory,
|
|
cliAvailable: !cliMissing,
|
|
cliPath,
|
|
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
|
configuredPort,
|
|
detectedPort,
|
|
apiPrefix: '',
|
|
apiPrefixDetected: true,
|
|
startCount,
|
|
restartCount,
|
|
lastStartAt,
|
|
lastConnectedAt,
|
|
lastExitCode,
|
|
serverUrl: getApiUrl(),
|
|
lastReadyElapsedMs,
|
|
lastReadyAttempts,
|
|
lastStartAttempts,
|
|
version,
|
|
}),
|
|
onStatusChange(callback) {
|
|
listeners.add(callback);
|
|
callback(status, lastError);
|
|
return new vscode.Disposable(() => listeners.delete(callback));
|
|
},
|
|
};
|
|
}
|