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:
committed by
GitHub
parent
b733f26aed
commit
83ffb1af34
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user