refactor(desktop): make Tauri thin shell running web sidecar (#273)

## 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
This commit is contained in:
Bohdan Triapitsyn
2026-02-05 01:59:49 +02:00
committed by GitHub
parent b733f26aed
commit 83ffb1af34
130 changed files with 4230 additions and 23488 deletions
+145 -9
View File
@@ -1,6 +1,9 @@
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;
@@ -53,6 +56,127 @@ function resolvePortFromUrl(url: string): number | 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 };
@@ -99,7 +223,7 @@ async function waitForReady(serverUrl: string, timeoutMs = 15000): Promise<Ready
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
// Keep using /config since the UI proxies to it (via /api -> strip prefix).
// OpenCode readiness check.
const url = new URL(`${baseUrl}/global/health`);
const res = await fetch(url.toString(), {
method: 'GET',
@@ -156,6 +280,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
let detectedPort: number | null = null;
let cliMissing = false;
let cliPath: string | null = null;
let pendingOperation: Promise<void> | null = null;
@@ -230,12 +355,20 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
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.
@@ -280,13 +413,16 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
}
} 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;
setStatus('error', 'OpenCode CLI not found. Install it or ensure it\'s in PATH.');
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 or ensure it\'s in PATH.',
'OpenCode CLI not found. Please install it and ensure it\'s in PATH.',
'More Info'
).then(selection => {
if (selection === 'More Info') {
@@ -301,7 +437,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
async function stopInternal(): Promise<void> {
const portToKill = detectedPort;
if (server) {
try {
server.close();
@@ -315,9 +451,9 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
// 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`, {
const lsofOutput = execSync(`lsof -ti:${portToKill} 2>/dev/null || true`, {
encoding: 'utf8',
timeout: 5000
timeout: 5000
});
const myPid = process.pid;
for (const pidStr of lsofOutput.split(/\s+/)) {
@@ -426,7 +562,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
lastError,
workingDirectory,
cliAvailable: !cliMissing,
cliPath: null,
cliPath,
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
configuredPort,
detectedPort,