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
@@ -0,0 +1,110 @@
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
|
||||
type TauriInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
type TauriGlobal = {
|
||||
core?: {
|
||||
invoke?: TauriInvoke;
|
||||
};
|
||||
};
|
||||
|
||||
export type DesktopHost = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type DesktopHostsConfig = {
|
||||
hosts: DesktopHost[];
|
||||
defaultHostId: string | null;
|
||||
};
|
||||
|
||||
export type HostProbeResult = {
|
||||
status: 'ok' | 'auth' | 'unreachable';
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null;
|
||||
};
|
||||
|
||||
const readString = (obj: Record<string, unknown>, key: string): string | null => {
|
||||
const val = obj[key];
|
||||
return typeof val === 'string' ? val : null;
|
||||
};
|
||||
|
||||
const readNumber = (obj: Record<string, unknown>, key: string): number | null => {
|
||||
const val = obj[key];
|
||||
return typeof val === 'number' && Number.isFinite(val) ? val : null;
|
||||
};
|
||||
|
||||
const parseHost = (value: unknown): DesktopHost | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = readString(value, 'id');
|
||||
const label = readString(value, 'label');
|
||||
const url = readString(value, 'url');
|
||||
if (!id || !label || !url) return null;
|
||||
return { id, label, url };
|
||||
};
|
||||
|
||||
const getInvoke = (): TauriInvoke | null => {
|
||||
if (!isTauriShell()) return null;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
|
||||
};
|
||||
|
||||
export const desktopHostsGet = async (): Promise<DesktopHostsConfig> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
return { hosts: [], defaultHostId: 'local' };
|
||||
}
|
||||
|
||||
const raw = await invoke('desktop_hosts_get');
|
||||
if (!isRecord(raw)) {
|
||||
return { hosts: [], defaultHostId: null };
|
||||
}
|
||||
|
||||
const hostsRaw = raw.hosts;
|
||||
const hosts = Array.isArray(hostsRaw)
|
||||
? hostsRaw.map(parseHost).filter((h): h is DesktopHost => Boolean(h))
|
||||
: [];
|
||||
|
||||
const defaultHostId =
|
||||
readString(raw, 'defaultHostId') ||
|
||||
readString(raw, 'default_host_id') ||
|
||||
readString(raw, 'defaultHostID');
|
||||
|
||||
return { hosts, defaultHostId };
|
||||
};
|
||||
|
||||
export const desktopHostsSet = async (config: DesktopHostsConfig): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_hosts_set', {
|
||||
config: {
|
||||
hosts: config.hosts,
|
||||
defaultHostId: config.defaultHostId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const desktopHostProbe = async (url: string): Promise<HostProbeResult> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
return { status: 'unreachable', latencyMs: 0 };
|
||||
}
|
||||
|
||||
const raw = await invoke('desktop_host_probe', { url });
|
||||
if (!isRecord(raw)) {
|
||||
return { status: 'unreachable', latencyMs: 0 };
|
||||
}
|
||||
|
||||
const rawStatus = raw.status;
|
||||
const status: HostProbeResult['status'] =
|
||||
rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'unreachable'
|
||||
? rawStatus
|
||||
: 'unreachable';
|
||||
|
||||
const latencyMs = readNumber(raw, 'latencyMs') ?? readNumber(raw, 'latency_ms') ?? 0;
|
||||
return { status, latencyMs };
|
||||
};
|
||||
Reference in New Issue
Block a user