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
+7
View File
@@ -7,6 +7,13 @@ src-tauri/target/
# Tauri generated code
src-tauri/gen/
# Desktop sidecar + bundled web assets (generated)
src-tauri/resources/web-dist/
src-tauri/sidecars/openchamber-server-*
src-tauri/sidecars/*.exe
!src-tauri/resources/.gitkeep
!src-tauri/sidecars/.gitkeep
# OpenCode CLI state tracking
.opencode-cli-state.json
-176
View File
@@ -1,176 +0,0 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#151313" />
<link rel="preload" href="/ibm-plex-mono-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin />
<title>OpenChamber Desktop</title>
<script>
// Blocking script to detect and apply theme before first paint
(function() {
var themeMode = localStorage.getItem('themeMode');
var isDark;
if (themeMode === 'dark') {
isDark = true;
} else if (themeMode === 'light') {
isDark = false;
} else {
// 'system' or not set - use system preference
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
document.documentElement.classList.add(isDark ? 'dark' : 'light');
document.documentElement.style.setProperty('color-scheme', isDark ? 'dark' : 'light');
// Store for use in inline styles
window.__INITIAL_THEME_DARK__ = isDark;
// Splash colors persisted by the app theme system
var splashBgLight = localStorage.getItem('splashBgLight');
var splashFgLight = localStorage.getItem('splashFgLight');
var splashBgDark = localStorage.getItem('splashBgDark');
var splashFgDark = localStorage.getItem('splashFgDark');
if (splashBgLight) document.documentElement.style.setProperty('--splash-background-light', splashBgLight);
if (splashFgLight) document.documentElement.style.setProperty('--splash-stroke-light', splashFgLight);
if (splashBgDark) document.documentElement.style.setProperty('--splash-background-dark', splashBgDark);
if (splashFgDark) document.documentElement.style.setProperty('--splash-stroke-dark', splashFgDark);
})();
</script>
<style>
@font-face {
font-family: 'IBM Plex Mono';
font-weight: 600;
font-style: normal;
src: url('/ibm-plex-mono-latin-600-normal.woff2') format('woff2');
font-display: block;
}
/* Theme-aware color scheme */
html.dark {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
html,
body,
#root {
height: 100%;
overflow: hidden;
}
body {
margin: 0;
font-family: 'IBM Plex Mono', monospace;
background-color: transparent;
}
:root {
--splash-background-dark: #151313;
--splash-stroke-dark: white;
--splash-background-light: #F6F4EF;
--splash-stroke-light: black;
--splash-background: var(--splash-background-dark);
--splash-stroke: var(--splash-stroke-dark);
/* Fallback fills (overridden below when supported) */
--splash-face-fill: rgba(255, 255, 255, 0.15);
--splash-cell-fill: rgba(255, 255, 255, 0.35);
--splash-logo-fill: var(--splash-stroke);
}
html.light {
--splash-background: var(--splash-background-light);
--splash-stroke: var(--splash-stroke-light);
--splash-face-fill: rgba(0, 0, 0, 0.15);
--splash-cell-fill: rgba(0, 0, 0, 0.4);
--splash-logo-fill: var(--splash-stroke);
}
html.dark {
--splash-background: var(--splash-background-dark);
--splash-stroke: var(--splash-stroke-dark);
--splash-logo-fill: var(--splash-stroke);
}
body {
background-color: var(--splash-background);
}
@supports (color: color-mix(in srgb, white 50%, transparent)) {
:root {
--splash-face-fill: color-mix(in srgb, var(--splash-stroke) 15%, transparent);
--splash-cell-fill: color-mix(in srgb, var(--splash-stroke) 35%, transparent);
}
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
@keyframes logo-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.logo-pulse {
animation: logo-pulse 3s ease-in-out infinite;
}
</style>
</head>
<body class="h-full">
<div id="root" class="h-full">
<div class="loading">
<svg width="180" height="180" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OpenChamber loading">
<!-- Left face with stroke -->
<path d="M50 50 L8.432 26 L8.432 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
<!-- Left face grid cells -->
<path d="M50 50 L39.608 44 L39.608 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
<path d="M39.608 44 L29.216 38 L29.216 50 L39.608 56 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
<path d="M29.216 38 L18.824 32 L18.824 44 L29.216 50 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
<path d="M18.824 32 L8.432 26 L8.432 38 L18.824 44 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
<path d="M50 62 L39.608 56 L39.608 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
<path d="M39.608 56 L29.216 50 L29.216 62 L39.608 68 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
<path d="M29.216 50 L18.824 44 L18.824 56 L29.216 62 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
<path d="M18.824 44 L8.432 38 L8.432 50 L18.824 56 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
<path d="M50 74 L39.608 68 L39.608 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
<path d="M39.608 68 L29.216 62 L29.216 74 L39.608 80 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
<path d="M29.216 62 L18.824 56 L18.824 68 L29.216 74 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
<path d="M18.824 56 L8.432 50 L8.432 62 L18.824 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
<path d="M50 86 L39.608 80 L39.608 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
<path d="M39.608 80 L29.216 74 L29.216 86 L39.608 92 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
<path d="M29.216 74 L18.824 68 L18.824 80 L29.216 86 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
<path d="M18.824 68 L8.432 62 L8.432 74 L18.824 80 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
<!-- Right face with stroke -->
<path d="M50 50 L91.568 26 L91.568 74 L50 98 Z" fill="var(--splash-face-fill)" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
<!-- Right face grid cells -->
<path d="M50 50 L60.392 44 L60.392 56 L50 62 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
<path d="M60.392 44 L70.784 38 L70.784 50 L60.392 56 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
<path d="M70.784 38 L81.176 32 L81.176 44 L70.784 50 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
<path d="M81.176 32 L91.568 26 L91.568 38 L81.176 44 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
<path d="M50 62 L60.392 56 L60.392 68 L50 74 Z" fill="var(--splash-cell-fill)" opacity="0.5"/>
<path d="M60.392 56 L70.784 50 L70.784 62 L60.392 68 Z" fill="var(--splash-cell-fill)" opacity="0.35"/>
<path d="M70.784 50 L81.176 44 L81.176 56 L70.784 62 Z" fill="var(--splash-cell-fill)" opacity="0.1"/>
<path d="M81.176 44 L91.568 38 L91.568 50 L81.176 56 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
<path d="M50 74 L60.392 68 L60.392 80 L50 86 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
<path d="M60.392 68 L70.784 62 L70.784 74 L60.392 80 Z" fill="var(--splash-cell-fill)" opacity="0.55"/>
<path d="M70.784 62 L81.176 56 L81.176 68 L70.784 74 Z" fill="var(--splash-cell-fill)" opacity="0.3"/>
<path d="M81.176 56 L91.568 50 L91.568 62 L81.176 68 Z" fill="var(--splash-cell-fill)" opacity="0.15"/>
<path d="M50 86 L60.392 80 L60.392 92 L50 98 Z" fill="var(--splash-cell-fill)" opacity="0.45"/>
<path d="M60.392 80 L70.784 74 L70.784 86 L60.392 92 Z" fill="var(--splash-cell-fill)" opacity="0.25"/>
<path d="M70.784 74 L81.176 68 L81.176 80 L70.784 86 Z" fill="var(--splash-cell-fill)" opacity="0.4"/>
<path d="M81.176 68 L91.568 62 L91.568 74 L81.176 80 Z" fill="var(--splash-cell-fill)" opacity="0.2"/>
<!-- Top face - open -->
<path d="M50 2 L8.432 26 L50 50 L91.568 26 Z" fill="none" stroke="var(--splash-stroke)" stroke-width="2" stroke-linejoin="round"/>
<!-- OpenCode logo on top face -->
<g class="logo-pulse" transform="matrix(0.866, 0.5, -0.866, 0.5, 50, 26) scale(0.75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z" fill="var(--splash-logo-fill)"/>
<path d="M-8 -4 L8 -4 L8 12 L-8 12 Z" fill="var(--splash-logo-fill)" fill-opacity="0.4"/>
</g>
</svg>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenChamber</title>
</head>
<body>
<noscript>OpenChamber requires JavaScript.</noscript>
</body>
</html>
+6 -20
View File
@@ -12,29 +12,15 @@
"tauri": "tauri",
"tauri:dev": "tauri dev --features devtools",
"tauri:build": "tauri build",
"dev": "vite dev --host 127.0.0.1 --port 1421",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 5051",
"type-check": "tsc --noEmit",
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
},
"dependencies": {
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-updater": "^2",
"@openchamber/ui": "workspace:*",
"react": "^19.1.1",
"react-dom": "^19.1.1"
"build:sidecar": "node ./scripts/build-sidecar.mjs",
"build": "bun -e \"process.exit(0)\"",
"type-check": "bun -e \"process.exit(0)\"",
"lint": "bun -e \"process.exit(0)\""
},
"dependencies": {},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@types/node": "^24.3.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.3",
"vite": "^7.1.2"
"typescript": "~5.8.3"
}
}
+112
View File
@@ -0,0 +1,112 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const webDir = path.join(repoRoot, 'packages', 'web');
const desktopTauriDir = path.join(repoRoot, 'packages', 'desktop', 'src-tauri');
const resourcesDir = path.join(desktopTauriDir, 'resources');
const resourcesWebDistDir = path.join(resourcesDir, 'web-dist');
const webDistDir = path.join(webDir, 'dist');
const sidecarsDir = path.join(desktopTauriDir, 'sidecars');
const inferTargetTriple = () => {
if (typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' && process.env.TAURI_ENV_TARGET_TRIPLE.trim()) {
return process.env.TAURI_ENV_TARGET_TRIPLE.trim();
}
if (process.platform === 'darwin') {
return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
}
if (process.platform === 'win32') {
return 'x86_64-pc-windows-msvc';
}
if (process.platform === 'linux') {
return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
}
return `${process.arch}-${process.platform}`;
};
const targetTriple = inferTargetTriple();
const sidecarBaseName = process.platform === 'win32'
? `openchamber-server-${targetTriple}.exe`
: `openchamber-server-${targetTriple}`;
const sidecarOutPath = path.join(sidecarsDir, sidecarBaseName);
const run = (cmd, args, cwd) => {
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
}
};
const resolveBun = () => {
if (typeof process.env.BUN === 'string' && process.env.BUN.trim()) {
return process.env.BUN.trim();
}
const result = spawnSync('/bin/bash', ['-lc', 'command -v bun'], { encoding: 'utf8' });
const resolved = (result.stdout || '').trim();
if (resolved) {
return resolved;
}
return 'bun';
};
const bunExe = resolveBun();
const copyDir = async (src, dst) => {
await fs.mkdir(dst, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const from = path.join(src, entry.name);
const to = path.join(dst, entry.name);
if (entry.isDirectory()) {
await copyDir(from, to);
} else if (entry.isSymbolicLink()) {
const link = await fs.readlink(from);
await fs.symlink(link, to);
} else {
await fs.copyFile(from, to);
}
}
};
console.log('[desktop] building web UI dist...');
run(bunExe, ['run', 'build'], webDir);
console.log('[desktop] preparing tauri resources...');
await fs.mkdir(resourcesDir, { recursive: true });
await fs.rm(resourcesWebDistDir, { recursive: true, force: true });
await copyDir(webDistDir, resourcesWebDistDir);
console.log('[desktop] building openchamber-server sidecar...');
await fs.mkdir(sidecarsDir, { recursive: true });
run(bunExe, [
'build',
'--compile',
path.join(webDir, 'server', 'index.js'),
'--outfile',
sidecarOutPath,
], repoRoot);
if (process.platform !== 'win32') {
await fs.chmod(sidecarOutPath, 0o755);
}
console.log(`[desktop] sidecar ready: ${sidecarOutPath}`);
console.log(`[desktop] web assets ready: ${resourcesWebDistDir}`);
-7
View File
@@ -2,7 +2,6 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { startCli, stopCli } from './opencode-cli.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -19,8 +18,6 @@ function spawnProcess(command, args, opts = {}) {
}
async function main() {
await startCli();
const tauriProcess = spawnProcess('bun', ['--cwd', desktopDir, 'tauri', 'dev', '--features', 'devtools']);
let cleaning = false;
@@ -44,10 +41,6 @@ async function main() {
stopChild(tauriProcess, 'Tauri dev process');
await stopCli({ silent: true }).catch((error) => {
console.warn('[desktop:dev] Failed to stop OpenCode CLI:', error);
});
process.exit(typeof code === 'number' ? code : 0);
};
@@ -0,0 +1,74 @@
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const desktopDir = path.join(repoRoot, 'packages', 'desktop');
const tauriDir = path.join(desktopDir, 'src-tauri');
const inferTargetTriple = () => {
const fromEnv = typeof process.env.TAURI_ENV_TARGET_TRIPLE === 'string' ? process.env.TAURI_ENV_TARGET_TRIPLE.trim() : '';
if (fromEnv) return fromEnv;
if (process.platform === 'darwin') {
return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin';
}
if (process.platform === 'win32') {
return 'x86_64-pc-windows-msvc';
}
if (process.platform === 'linux') {
return process.arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu';
}
return `${process.arch}-${process.platform}`;
};
const targetTriple = inferTargetTriple();
const sidecarName = process.platform === 'win32'
? `openchamber-server-${targetTriple}.exe`
: `openchamber-server-${targetTriple}`;
const sidecarPath = path.join(tauriDir, 'sidecars', sidecarName);
const distDir = path.join(tauriDir, 'resources', 'web-dist');
const run = (cmd, args, cwd) => {
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
}
};
console.log('[desktop] ensuring sidecar + web-dist...');
run('node', ['./scripts/build-sidecar.mjs'], desktopDir);
console.log('[desktop] starting dev server on http://127.0.0.1:3001 ...');
const child = spawn(sidecarPath, ['--port', '3001'], {
cwd: repoRoot,
stdio: 'inherit',
env: {
...process.env,
OPENCHAMBER_HOST: '127.0.0.1',
OPENCHAMBER_DIST_DIR: distDir,
NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1',
no_proxy: process.env.no_proxy || 'localhost,127.0.0.1',
},
});
const shutdown = () => {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('exit', shutdown);
+17 -751
View File
File diff suppressed because it is too large Load Diff
+3 -35
View File
@@ -4,10 +4,6 @@ version = "1.6.3"
edition = "2021"
publish = false
[lib]
name = "openchamber_desktop"
path = "src/lib.rs"
[[bin]]
name = "openchamber-desktop"
path = "src/main.rs"
@@ -18,46 +14,18 @@ devtools = ["tauri/devtools"]
[dependencies]
anyhow = "1.0.86"
axum = { version = "0.8.4", features = ["macros"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "5.0"
fastrand = "2.0"
futures-util = "0.3"
log = "0.4.28"
nix = { version = "0.28", features = ["signal"] }
objc = "0.2.7"
objc2 = "0.6.3"
objc2-foundation = { version = "0.3.2", features = ["NSProcessInfo", "NSString", "NSObjCRuntime"] }
once_cell = "1.19"
parking_lot = "0.12.3"
portable-pty = "0.9.0"
portpicker = "0.1.1"
regex = "1.10.4"
reqwest = { version = "0.12.4", default-features = false, features = ["json", "stream", "rustls-tls", "gzip", "brotli", "deflate"] }
reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] }
serde = { version = "1.0.210", features = ["derive"] }
serde_json = "1.0.143"
serde_yaml = "0.9"
json5 = "0.4"
tauri = { version = "2.9.4", features = ["macos-private-api"] }
tauri-plugin-dialog = "2.4.2"
tauri-plugin-fs = "2.4.4"
tauri-plugin-log = "2.7.1"
tauri-plugin-shell = "2.3.3"
tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "process", "signal", "sync", "time", "fs"] }
tower-http = { version = "0.5.2", features = ["cors"] }
url = "2.5"
uuid = { version = "1.18.1", features = ["v4"] }
tokio-util = { version = "0.7", features = ["io"] }
tauri-plugin-notification = "2.3.3"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
base64 = "0.22.1"
urlencoding = "2.1"
zip = "2.1"
tokio = { version = "1.38", features = ["rt-multi-thread", "time"] }
url = "2.5"
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2-app-kit = { version = "0.3.2", features = ["NSView", "NSResponder"] }
objc2-quartz-core = { version = "0.3.2", features = ["CALayer"] }
@@ -2,6 +2,16 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for OpenChamber desktop runtime",
"remote": {
"urls": [
"http://127.0.0.1:*/*",
"http://localhost:*/*",
"http://*",
"http://*/*",
"https://*",
"https://*/*"
]
},
"windows": ["main"],
"permissions": [
"core:default",
@@ -20,24 +30,12 @@
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm",
"fs:allow-read-text-file",
"fs:allow-read-file",
"fs:allow-write-text-file",
"fs:allow-write-file",
"fs:allow-read-dir",
"fs:allow-exists",
"fs:allow-create",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:scope-app-index",
"fs:scope-home",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:allow-restart"
"updater:allow-download-and-install"
]
}
@@ -1,631 +0,0 @@
use std::{collections::{HashMap, HashSet}, path::PathBuf, time::Duration};
use anyhow::Result;
use futures_util::TryStreamExt;
use log::{debug, info, warn};
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use tauri::{AppHandle, Manager};
use tauri_plugin_notification::NotificationExt;
use tokio::{io::AsyncBufReadExt, sync::Mutex};
use tokio_util::io::StreamReader;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Deserialize)]
struct EventEnvelope {
#[serde(rename = "type")]
event_type: String,
#[serde(default)]
properties: Value,
}
#[derive(Deserialize)]
struct MultiplexedEventEnvelope {
#[serde(default)]
#[allow(dead_code)]
directory: Option<String>,
payload: EventEnvelope,
}
pub fn spawn_assistant_notifications(
app: AppHandle,
runtime: DesktopRuntime,
) -> tauri::async_runtime::JoinHandle<()> {
tauri::async_runtime::spawn(async move {
let client = Client::builder()
// Give SSE a very long overall timeout so idle periods don't abort the stream.
.timeout(Duration::from_secs(24 * 60 * 60))
.tcp_keepalive(Some(Duration::from_secs(30)))
.build()
.expect("failed to build reqwest client");
let mut shutdown_rx = runtime.subscribe_shutdown();
let notified_messages = Mutex::new(HashSet::<String>::new());
let notified_questions = Mutex::new(HashSet::<String>::new());
let session_parent_cache = Mutex::new(HashMap::<String, Option<String>>::new());
loop {
tokio::select! {
_ = shutdown_rx.recv() => {
info!("[desktop:notify] Shutdown received, stopping SSE listener");
break;
}
_ = async {
if let Err(err) = run_once(
&app,
&runtime,
&client,
&notified_messages,
&notified_questions,
&session_parent_cache,
).await {
warn!("[desktop:notify] SSE loop error: {err:?}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
} => {}
}
}
})
}
async fn run_once(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
notified_messages: &Mutex<HashSet<String>>,
notified_questions: &Mutex<HashSet<String>>,
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
) -> Result<()> {
let opencode = runtime.opencode_manager();
let port = match opencode.current_port() {
Some(port) => port,
None => {
warn!("[desktop:notify] OpenCode port unavailable; will retry");
tokio::time::sleep(Duration::from_secs(2)).await;
return Ok(());
}
};
let prefix = opencode.api_prefix();
let base = format!("http://127.0.0.1:{port}{prefix}");
let response = connect_notifications_sse(runtime, client, &base).await?;
let stream = response
.bytes_stream()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
let mut reader = StreamReader::new(stream);
let mut buf = Vec::new();
let mut data_lines: Vec<String> = Vec::new();
loop {
buf.clear();
let bytes_read = match reader.read_until(b'\n', &mut buf).await {
Ok(n) => n,
Err(err) => {
warn!("[desktop:notify] Read error in SSE stream: {err:?}");
return Err(err.into());
}
};
if bytes_read == 0 {
break;
}
let line = match std::str::from_utf8(&buf) {
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
Err(err) => {
warn!("[desktop:notify] Non-UTF8 SSE chunk: {err}");
continue;
}
};
if line.is_empty() {
if data_lines.is_empty() {
continue;
}
let raw = data_lines.join("\n");
data_lines.clear();
match parse_event_envelope(&raw) {
Ok(event) => {
handle_event(
app,
runtime,
client,
&base,
event,
notified_messages,
notified_questions,
session_parent_cache,
)
.await
}
Err(err) => {
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
}
}
continue;
}
if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start().to_string());
}
}
Ok(())
}
fn parse_event_envelope(raw: &str) -> Result<EventEnvelope> {
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
return Ok(event);
}
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
Ok(multiplexed.payload)
}
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
let settings = runtime.settings().load().await.ok()?;
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
if let Some(path) = projects.iter().find_map(|entry| {
let id = entry.get("id").and_then(Value::as_str)?;
if id != active_id {
return None;
}
entry.get("path").and_then(Value::as_str)
}) {
return Some(expand_tilde_path(path));
}
}
}
settings
.get("lastDirectory")
.and_then(Value::as_str)
.map(expand_tilde_path)
}
async fn connect_notifications_sse(
runtime: &DesktopRuntime,
client: &Client,
base: &str,
) -> Result<reqwest::Response> {
let global_url = format!("{base}/global/event");
match try_connect_sse(client, &global_url, "[desktop:notify]").await {
Ok(response) => {
debug!("[desktop:notify] Using SSE endpoint: {global_url}");
return Ok(response);
}
Err(err) => {
debug!(
"[desktop:notify] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
);
}
}
let event_url = format!("{base}/event");
match try_connect_sse(client, &event_url, "[desktop:notify]").await {
Ok(response) => {
debug!("[desktop:notify] Using SSE endpoint: {event_url}");
return Ok(response);
}
Err(err) => {
debug!(
"[desktop:notify] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
);
}
}
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
anyhow::bail!("No project directory available for SSE fallback");
};
let directory = working_dir.to_string_lossy().to_string();
let mut parsed = reqwest::Url::parse(&event_url)?;
parsed
.query_pairs_mut()
.append_pair("directory", &directory);
let directory_url = parsed.to_string();
let response = try_connect_sse(client, &directory_url, "[desktop:notify]").await?;
debug!("[desktop:notify] Using directory-scoped SSE endpoint: {directory_url}");
Ok(response)
}
async fn try_connect_sse(
client: &Client,
url: &str,
log_prefix: &str,
) -> Result<reqwest::Response> {
debug!("{log_prefix} Connecting SSE: {url}");
let response = client
.get(url)
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.send()
.await?;
debug!(
"{log_prefix} SSE response status={} headers={:?}",
response.status(),
response.headers()
);
if !response.status().is_success() {
anyhow::bail!("SSE connect failed with status {}", response.status());
}
Ok(response)
}
async fn handle_event(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
base: &str,
event: EventEnvelope,
notified_messages: &Mutex<HashSet<String>>,
notified_questions: &Mutex<HashSet<String>>,
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
) {
match event.event_type.as_str() {
"message.updated" => {
handle_message_updated(
app,
runtime,
client,
base,
&event.properties,
notified_messages,
session_parent_cache,
)
.await;
}
"question.asked" => {
handle_question_asked(app, &event.properties, notified_questions).await;
}
"permission.asked" => {
handle_permission_asked(app, &event.properties, notified_questions).await;
}
_ => {}
}
}
async fn resolve_session_parent_id(
client: &Client,
base: &str,
session_id: &str,
cache: &Mutex<HashMap<String, Option<String>>>,
) -> Option<Option<String>> {
{
let locked = cache.lock().await;
if let Some(existing) = locked.get(session_id) {
return Some(existing.clone());
}
}
// Fail open: on any error, return None (unknown)
let sessions_url = format!("{base}/session");
let response = match tokio::time::timeout(
Duration::from_secs(2),
client.get(&sessions_url).header("accept", "application/json").send(),
)
.await
{
Ok(Ok(resp)) => resp,
_ => return None,
};
if !response.status().is_success() {
return None;
}
let data: Value = match response.json().await {
Ok(v) => v,
Err(_) => return None,
};
let parent = data
.as_array()
.and_then(|arr| {
arr.iter().find_map(|entry| {
let id = entry.get("id").and_then(Value::as_str)?;
if id != session_id {
return None;
}
let parent = entry.get("parentID").and_then(Value::as_str);
Some(parent.filter(|s| !s.is_empty()).map(|s| s.to_string()))
})
})
.flatten();
{
let mut locked = cache.lock().await;
locked.insert(session_id.to_string(), parent.clone());
}
Some(parent)
}
async fn handle_question_asked(
app: &AppHandle,
properties: &Value,
notified_questions: &Mutex<HashSet<String>>,
) {
let session_id = properties.get("sessionID").and_then(Value::as_str);
let question_id = properties.get("id").and_then(Value::as_str);
let (session_id, question_id) = match (session_id, question_id) {
(Some(s), Some(q)) => (s, q),
_ => return,
};
let key = format!("{}:{}", session_id, question_id);
{
let mut notified = notified_questions.lock().await;
if notified.contains(&key) {
return;
}
notified.insert(key);
}
let should_notify = app
.get_webview_window("main")
.map(|window| {
let focused = window.is_focused().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
!focused || minimized
})
.unwrap_or(true);
if should_notify {
let (title, body) = properties
.get("questions")
.and_then(Value::as_array)
.and_then(|questions| questions.first())
.and_then(Value::as_object)
.map(|first| {
let header = first
.get("header")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
let question = first
.get("question")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
let title = if header.to_ascii_lowercase().contains("plan mode") {
"Switch to plan mode".to_string()
} else if header.to_ascii_lowercase().contains("build agent") {
"Switch to build mode".to_string()
} else if !header.is_empty() {
header.to_string()
} else {
"Input needed".to_string()
};
let body = if !question.is_empty() {
question.to_string()
} else {
"Agent is waiting for your response".to_string()
};
(title, body)
})
.unwrap_or_else(|| {
(
"Input needed".to_string(),
"Agent is waiting for your response".to_string(),
)
});
let _ = app
.notification()
.builder()
.title(title)
.body(body)
.sound("Glass")
.show();
}
}
async fn handle_permission_asked(
app: &AppHandle,
properties: &Value,
notified_requests: &Mutex<HashSet<String>>,
) {
let session_id = properties.get("sessionID").and_then(Value::as_str);
let request_id = properties.get("id").and_then(Value::as_str);
let (session_id, request_id) = match (session_id, request_id) {
(Some(s), Some(r)) => (s, r),
_ => return,
};
let key = format!("{}:{}", session_id, request_id);
{
let mut notified = notified_requests.lock().await;
if notified.contains(&key) {
return;
}
notified.insert(key);
}
let permission = properties
.get("permission")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("Agent requested permission");
let should_notify = app
.get_webview_window("main")
.map(|window| {
let focused = window.is_focused().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
!focused || minimized
})
.unwrap_or(true);
if should_notify {
let _ = app
.notification()
.builder()
.title("Permission required")
.body(permission)
.sound("Glass")
.show();
}
}
async fn handle_message_updated(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
base: &str,
properties: &Value,
notified_messages: &Mutex<HashSet<String>>,
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
) {
let Some(info) = properties.get("info") else {
return;
};
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
if role != "assistant" {
return;
}
let finish = info.get("finish").and_then(Value::as_str);
if finish != Some("stop") {
return;
}
let message_id = match info.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => return,
};
// Subtask filtering (fail open)
let notify_on_subtasks = runtime
.settings()
.load()
.await
.ok()
.and_then(|settings| settings.get("notifyOnSubtasks").and_then(Value::as_bool))
.unwrap_or(true);
if !notify_on_subtasks {
let session_id = info
.get("sessionID")
.and_then(Value::as_str)
.or_else(|| properties.get("sessionID").and_then(Value::as_str))
.or_else(|| properties.get("sessionId").and_then(Value::as_str));
if let Some(session_id) = session_id {
if let Some(parent) = resolve_session_parent_id(client, base, session_id, session_parent_cache).await {
if parent.is_some() {
return;
}
}
}
}
{
let mut notified = notified_messages.lock().await;
if notified.contains(&message_id) {
return;
}
notified.insert(message_id.clone());
}
let raw_mode = info
.get("mode")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("agent");
let raw_model = info
.get("modelID")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("assistant");
let title = format!("{} agent is ready", format_mode(raw_mode));
let body = format!("{} completed the task", format_model_id(raw_model));
let should_notify = app
.get_webview_window("main")
.map(|window| {
let focused = window.is_focused().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
// Only notify when the app is not in the foreground or is minimized
!focused || minimized
})
.unwrap_or(true);
if should_notify {
let _ = app
.notification()
.builder()
.title(title)
.body(body)
.sound("Glass")
.show();
}
}
fn format_mode(raw: &str) -> String {
if raw.is_empty() {
return "Agent".to_string();
}
raw.split(&['-', '_', ' '][..])
.filter(|s| !s.is_empty())
.map(capitalize)
.collect::<Vec<_>>()
.join(" ")
}
fn format_model_id(raw: &str) -> String {
if raw.is_empty() {
return "Assistant".to_string();
}
let tokens: Vec<&str> = raw.split(&['-', '_'][..]).collect();
let mut result: Vec<String> = Vec::new();
let mut i = 0;
while i < tokens.len() {
let current = tokens[i];
if current.chars().all(|c| c.is_ascii_digit()) {
if i + 1 < tokens.len() && tokens[i + 1].chars().all(|c| c.is_ascii_digit()) {
let combined = format!("{}.{}", current, tokens[i + 1]);
result.push(combined);
i += 2;
continue;
}
}
result.push(current.to_string());
i += 1;
}
result
.into_iter()
.map(|part| capitalize(&part))
.collect::<Vec<_>>()
.join(" ")
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,25 +0,0 @@
use crate::logging::log_file_path;
use serde::Serialize;
use tokio::fs;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopLogFile {
pub file_name: String,
pub content: String,
}
#[tauri::command]
pub async fn fetch_desktop_logs() -> Result<DesktopLogFile, String> {
let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?;
let content = fs::read_to_string(&path)
.await
.map_err(|err| format!("Failed to read log file: {err}"))?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("openchamber.log")
.to_string();
Ok(DesktopLogFile { file_name, content })
}
@@ -1,8 +0,0 @@
pub mod files;
pub mod git;
pub mod github;
pub mod logs;
pub mod notifications;
pub mod permissions;
pub mod settings;
pub mod terminal;
@@ -1,37 +0,0 @@
use serde::Deserialize;
use tauri::{AppHandle, Runtime};
use tauri_plugin_notification::NotificationExt;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPayload {
pub title: Option<String>,
pub body: Option<String>,
}
#[tauri::command]
pub async fn desktop_notify<R: Runtime>(
app: AppHandle<R>,
payload: Option<NotificationPayload>,
) -> Result<bool, String> {
let title = payload
.as_ref()
.and_then(|p| p.title.as_deref())
.unwrap_or("OpenChamber");
let body = payload
.as_ref()
.and_then(|p| p.body.as_deref())
.unwrap_or("Task completed");
match app
.notification()
.builder()
.title(title)
.body(body)
.sound("Glass")
.show()
{
Ok(_) => Ok(true),
Err(e) => Err(e.to_string()),
}
}
@@ -1,285 +0,0 @@
use chrono::Utc;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::AppHandle;
use tauri::State;
use uuid::Uuid;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryPermissionRequest {
path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryPermissionResult {
success: bool,
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartAccessingResult {
success: bool,
error: Option<String>,
}
/// Process directory selection from frontend.
/// Updates settings (projects, activeProjectId, lastDirectory).
#[tauri::command]
pub async fn process_directory_selection(
path: String,
state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
// Validate directory exists
let mut path_buf = expand_tilde_path(&path);
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
path_buf = canonicalized;
}
let normalized_path = path_buf.to_string_lossy().to_string();
if !path_buf.exists() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
project_id: None,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
project_id: None,
error: Some("Path is not a directory".to_string()),
});
}
// Update settings with projects + activeProjectId + lastDirectory
let now = Utc::now().timestamp_millis();
let normalized_path_for_update = normalized_path.clone();
let (_, project_id) = state
.settings()
.update_with(|mut settings| {
if !settings.is_object() {
settings = json!({});
}
let project_id = {
let obj = settings.as_object_mut().unwrap();
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
if !projects_value.is_array() {
*projects_value = json!([]);
}
let projects = projects_value.as_array_mut().unwrap();
let existing_index = projects.iter().position(|entry| {
entry
.get("path")
.and_then(|value| value.as_str())
.map(|value| value == normalized_path_for_update)
.unwrap_or(false)
});
if let Some(index) = existing_index {
let entry = projects
.get_mut(index)
.and_then(|value| value.as_object_mut());
if let Some(entry) = entry {
entry.insert("lastOpenedAt".to_string(), json!(now));
if let Some(id) = entry.get("id").and_then(|value| value.as_str()) {
id.to_string()
} else {
let id = Uuid::new_v4().to_string();
entry.insert("id".to_string(), json!(id));
id
}
} else {
let id = Uuid::new_v4().to_string();
projects[index] = json!({
"id": id,
"path": normalized_path_for_update,
"addedAt": now,
"lastOpenedAt": now
});
id
}
} else {
let id = Uuid::new_v4().to_string();
projects.push(json!({
"id": id,
"path": normalized_path_for_update,
"addedAt": now,
"lastOpenedAt": now
}));
id
}
};
if let Some(obj) = settings.as_object_mut() {
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
obj.insert(
"lastDirectory".to_string(),
json!(normalized_path_for_update),
);
}
(settings, project_id)
})
.await
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
info!(
"[permissions] Updated settings with active project {}: {}",
project_id, normalized_path
);
Ok(DirectoryPermissionResult {
success: true,
path: Some(normalized_path),
project_id: Some(project_id),
error: None,
})
}
/// Legacy directory picker command (frontend handles actual dialog)
#[tauri::command]
pub async fn pick_directory(
_app_handle: AppHandle,
_state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
Ok(DirectoryPermissionResult {
success: false,
path: None,
project_id: None,
error: Some(
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
),
})
}
/// Request directory access (desktop implementation)
/// For unsandboxed apps, just validates the path is accessible
#[tauri::command]
pub async fn request_directory_access(
request: DirectoryPermissionRequest,
_state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
let path = request.path;
let mut path_buf = expand_tilde_path(&path);
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
path_buf = canonicalized;
}
let normalized_path = path_buf.to_string_lossy().to_string();
if !path_buf.exists() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
project_id: None,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
project_id: None,
error: Some("Path is not a directory".to_string()),
});
}
// For unsandboxed apps, no bookmark needed - just verify access
match std::fs::read_dir(&path_buf) {
Ok(_) => Ok(DirectoryPermissionResult {
success: true,
path: Some(normalized_path),
project_id: None,
error: None,
}),
Err(e) => Ok(DirectoryPermissionResult {
success: false,
path: None,
project_id: None,
error: Some(format!("Cannot access directory: {}", e)),
}),
}
}
/// Start accessing directory (desktop implementation)
#[tauri::command]
pub async fn start_accessing_directory(
path: String,
_state: State<'_, DesktopRuntime>,
) -> Result<StartAccessingResult, String> {
// Check if directory exists and is accessible
let path_buf = std::path::PathBuf::from(&path);
if !path_buf.exists() {
return Ok(StartAccessingResult {
success: false,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(StartAccessingResult {
success: false,
error: Some("Path is not a directory".to_string()),
});
}
// Try to read the directory to verify access
match std::fs::read_dir(&path_buf) {
Ok(_) => {
info!("Successfully started accessing directory: {}", path);
Ok(StartAccessingResult {
success: true,
error: None,
})
}
Err(e) => {
warn!("Failed to access directory {}: {}", path, e);
Ok(StartAccessingResult {
success: false,
error: Some(format!("Failed to access directory: {}", e)),
})
}
}
}
/// Stop accessing directory (desktop implementation)
#[tauri::command]
pub async fn stop_accessing_directory(
_path: String,
_state: State<'_, DesktopRuntime>,
) -> Result<StartAccessingResult, String> {
// For Stage 1, just confirm the operation
// Full implementation would call stopAccessingSecurityScopedResource
info!("Stopped accessing directory");
Ok(StartAccessingResult {
success: true,
error: None,
})
}
/// Restore bookmarks on app startup (no-op for unsandboxed apps)
#[tauri::command]
pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> {
// For unsandboxed apps, no bookmarks needed
// Directory access is restored from settings.lastDirectory
info!("[permissions] Bookmark restore not needed for unsandboxed app");
Ok(())
}
@@ -1,926 +0,0 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashSet;
use tauri::State;
use uuid::Uuid;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingsLoadResult {
settings: Value,
source: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestartResult {
restarted: bool,
}
/// Load settings from disk.
#[tauri::command]
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
let (settings, _) = state
.settings()
.update_with(|mut settings| {
migrate_legacy_project_settings(&mut settings);
migrate_legacy_theme_settings(&mut settings);
normalize_project_selection(&mut settings);
(settings, ())
})
.await
.map_err(|e| format!("Failed to load settings: {}", e))?;
Ok(SettingsLoadResult {
settings: format_settings_response(&settings),
source: "desktop".to_string(),
})
}
/// Save settings to disk with merge logic.
#[tauri::command]
pub async fn save_settings(
changes: Value,
state: State<'_, DesktopRuntime>,
) -> Result<Value, String> {
let sanitized_changes = sanitize_settings_update(&changes);
let (merged, _) = state
.settings()
.update_with(|current| {
let mut merged = merge_persisted_settings(&current, &sanitized_changes);
migrate_legacy_theme_settings(&mut merged);
normalize_project_selection(&mut merged);
(merged, ())
})
.await
.map_err(|e| format!("Failed to save settings: {}", e))?;
Ok(format_settings_response(&merged))
}
/// Restart the backend process (config reload).
#[tauri::command]
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
state
.opencode
.restart()
.await
.map_err(|e| format!("Failed to restart OpenCode: {}", e))?;
Ok(RestartResult { restarted: true })
}
fn sanitize_projects(value: &Value) -> Option<Value> {
let arr = value.as_array()?;
let mut seen_ids = HashSet::new();
let mut seen_paths = HashSet::new();
let mut result = Vec::new();
for entry in arr {
let Some(obj) = entry.as_object() else {
continue;
};
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
let raw_path = obj
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if id.is_empty() || raw_path.is_empty() {
continue;
}
let expanded = expand_tilde_path(raw_path).to_string_lossy().to_string();
let normalized = if expanded == "/" {
expanded
} else {
expanded.trim_end_matches('/').replace('\\', "/")
};
if normalized.is_empty() {
continue;
}
if seen_ids.contains(id) || seen_paths.contains(&normalized) {
continue;
}
seen_ids.insert(id.to_string());
seen_paths.insert(normalized.clone());
let mut project = serde_json::Map::new();
project.insert("id".to_string(), json!(id));
project.insert("path".to_string(), json!(normalized));
if let Some(Value::String(label)) = obj.get("label") {
if !label.trim().is_empty() {
project.insert("label".to_string(), json!(label.trim()));
}
}
if let Some(Value::Number(num)) = obj.get("addedAt") {
if let Some(value) = num.as_i64() {
if value >= 0 {
project.insert("addedAt".to_string(), json!(value));
}
}
}
if let Some(Value::Number(num)) = obj.get("lastOpenedAt") {
if let Some(value) = num.as_i64() {
if value >= 0 {
project.insert("lastOpenedAt".to_string(), json!(value));
}
}
}
// Preserve worktreeDefaults
if let Some(Value::Object(wt)) = obj.get("worktreeDefaults") {
let mut defaults = serde_json::Map::new();
if let Some(Value::String(s)) = wt.get("branchPrefix") {
if !s.trim().is_empty() {
defaults.insert("branchPrefix".to_string(), json!(s.trim()));
}
}
if let Some(Value::String(s)) = wt.get("baseBranch") {
if !s.trim().is_empty() {
defaults.insert("baseBranch".to_string(), json!(s.trim()));
}
}
if let Some(Value::Bool(b)) = wt.get("autoCreateWorktree") {
defaults.insert("autoCreateWorktree".to_string(), json!(b));
}
if !defaults.is_empty() {
project.insert("worktreeDefaults".to_string(), Value::Object(defaults));
}
}
result.push(Value::Object(project));
}
if arr.is_empty() {
return Some(Value::Array(vec![]));
}
if result.is_empty() {
None
} else {
Some(Value::Array(result))
}
}
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
fn sanitize_settings_update(payload: &Value) -> Value {
let mut result = json!({});
if let Some(obj) = payload.as_object() {
let result_obj = result.as_object_mut().unwrap();
// String fields
if let Some(Value::String(s)) = obj.get("themeId") {
if !s.is_empty() {
result_obj.insert("themeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("themeVariant") {
if s == "light" || s == "dark" {
result_obj.insert("themeVariant".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("lightThemeId") {
if !s.is_empty() {
result_obj.insert("lightThemeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("darkThemeId") {
if !s.is_empty() {
result_obj.insert("darkThemeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("lastDirectory") {
if !s.is_empty() {
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
result_obj.insert("lastDirectory".to_string(), json!(expanded));
}
}
if let Some(Value::String(s)) = obj.get("homeDirectory") {
if !s.is_empty() {
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
result_obj.insert("homeDirectory".to_string(), json!(expanded));
}
}
if let Some(projects) = obj.get("projects").and_then(sanitize_projects) {
result_obj.insert("projects".to_string(), projects);
}
if let Some(Value::String(s)) = obj.get("activeProjectId") {
if !s.is_empty() {
result_obj.insert("activeProjectId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("uiFont") {
if !s.is_empty() {
result_obj.insert("uiFont".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("monoFont") {
if !s.is_empty() {
result_obj.insert("monoFont".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("markdownDisplayMode") {
if !s.is_empty() {
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
}
}
// GitHub OAuth config (non-secret)
if let Some(Value::String(s)) = obj.get("githubClientId") {
let trimmed = s.trim();
if !trimmed.is_empty() {
result_obj.insert("githubClientId".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("githubScopes") {
let trimmed = s.trim();
if !trimmed.is_empty() {
result_obj.insert("githubScopes".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("defaultModel") {
let trimmed = s.trim();
if trimmed.is_empty() {
result_obj.insert("defaultModel".to_string(), Value::Null);
} else {
result_obj.insert("defaultModel".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("defaultVariant") {
let trimmed = s.trim();
if trimmed.is_empty() {
result_obj.insert("defaultVariant".to_string(), Value::Null);
} else {
result_obj.insert("defaultVariant".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("defaultAgent") {
let trimmed = s.trim();
if trimmed.is_empty() {
result_obj.insert("defaultAgent".to_string(), Value::Null);
} else {
result_obj.insert("defaultAgent".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("defaultGitIdentityId") {
let trimmed = s.trim();
if trimmed.is_empty() {
result_obj.insert("defaultGitIdentityId".to_string(), Value::Null);
} else {
result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed));
}
}
// Boolean fields
if let Some(Value::Bool(b)) = obj.get("gitmojiEnabled") {
result_obj.insert("gitmojiEnabled".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
result_obj.insert("useSystemTheme".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
result_obj.insert("showReasoningTraces".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("showTextJustificationActivity") {
result_obj.insert("showTextJustificationActivity".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("nativeNotificationsEnabled") {
result_obj.insert("nativeNotificationsEnabled".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("notifyOnSubtasks") {
result_obj.insert("notifyOnSubtasks".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("usageAutoRefresh") {
result_obj.insert("usageAutoRefresh".to_string(), json!(b));
}
if let Some(Value::String(s)) = obj.get("notificationMode") {
let trimmed = s.trim();
if trimmed == "always" || trimmed == "hidden-only" {
result_obj.insert("notificationMode".to_string(), json!(trimmed));
}
}
if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") {
result_obj.insert("autoDeleteEnabled".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("queueModeEnabled") {
result_obj.insert("queueModeEnabled".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("autoCreateWorktree") {
result_obj.insert("autoCreateWorktree".to_string(), json!(b));
}
if let Some(Value::String(s)) = obj.get("toolCallExpansion") {
let trimmed = s.trim();
if trimmed == "collapsed" || trimmed == "activity" || trimmed == "detailed" {
result_obj.insert("toolCallExpansion".to_string(), json!(trimmed));
}
}
// Number fields
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
let parsed = n
.as_u64()
.or_else(|| {
n.as_i64()
.and_then(|value| if value >= 0 { Some(value as u64) } else { None })
})
.or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(1).min(365);
result_obj.insert("autoDeleteAfterDays".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("fontSize") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(50).min(200);
result_obj.insert("fontSize".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("padding") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(50).min(200);
result_obj.insert("padding".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("cornerRadius") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(0).min(32);
result_obj.insert("cornerRadius".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("inputBarOffset") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(0).min(100);
result_obj.insert("inputBarOffset".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("usageRefreshIntervalMs") {
let parsed = n
.as_u64()
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(30000).min(300000);
result_obj.insert("usageRefreshIntervalMs".to_string(), json!(clamped));
}
}
// Memory limit fields
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
let parsed = n
.as_u64()
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(10).min(500);
result_obj.insert("memoryLimitHistorical".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
let parsed = n
.as_u64()
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(20).min(500);
result_obj.insert("memoryLimitViewport".to_string(), json!(clamped));
}
}
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
let parsed = n
.as_u64()
.or_else(|| {
n.as_i64()
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
})
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
if let Some(value) = parsed {
let clamped = value.max(30).min(1000);
result_obj.insert("memoryLimitActiveSession".to_string(), json!(clamped));
}
}
if let Some(Value::String(s)) = obj.get("diffLayoutPreference") {
let trimmed = s.trim();
if trimmed == "dynamic" || trimmed == "inline" || trimmed == "side-by-side" {
result_obj.insert("diffLayoutPreference".to_string(), json!(trimmed));
}
}
if let Some(Value::String(s)) = obj.get("diffViewMode") {
let trimmed = s.trim();
if trimmed == "single" || trimmed == "stacked" {
result_obj.insert("diffViewMode".to_string(), json!(trimmed));
}
}
if let Some(Value::Bool(b)) = obj.get("directoryShowHidden") {
result_obj.insert("directoryShowHidden".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("filesViewShowGitignored") {
result_obj.insert("filesViewShowGitignored".to_string(), json!(b));
}
// Array fields
if let Some(arr) = obj.get("approvedDirectories") {
result_obj.insert(
"approvedDirectories".to_string(),
normalize_string_array(arr),
);
}
if let Some(arr) = obj.get("securityScopedBookmarks") {
result_obj.insert(
"securityScopedBookmarks".to_string(),
normalize_string_array(arr),
);
}
if let Some(arr) = obj.get("pinnedDirectories") {
result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr));
}
// Typography sizes object (partial)
if let Some(typo) = obj.get("typographySizes") {
if let Some(sanitized) = sanitize_typography_sizes_partial(typo) {
result_obj.insert("typographySizes".to_string(), sanitized);
}
}
// Skill catalogs (array of objects)
if let Some(Value::Array(arr)) = obj.get("skillCatalogs") {
let mut seen: HashSet<String> = HashSet::new();
let mut catalogs: Vec<Value> = vec![];
for entry in arr {
let Some(obj) = entry.as_object() else {
continue;
};
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
let label = obj
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let source = obj
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let subpath = obj
.get("subpath")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let git_identity_id = obj
.get("gitIdentityId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if id.is_empty() || label.is_empty() || source.is_empty() {
continue;
}
if seen.contains(id) {
continue;
}
seen.insert(id.to_string());
let mut catalog = serde_json::Map::new();
catalog.insert("id".to_string(), json!(id));
catalog.insert("label".to_string(), json!(label));
catalog.insert("source".to_string(), json!(source));
if !subpath.is_empty() {
catalog.insert("subpath".to_string(), json!(subpath));
}
if !git_identity_id.is_empty() {
catalog.insert("gitIdentityId".to_string(), json!(git_identity_id));
}
catalogs.push(Value::Object(catalog));
}
if !catalogs.is_empty() {
result_obj.insert("skillCatalogs".to_string(), Value::Array(catalogs));
}
}
}
result
}
fn migrate_legacy_project_settings(settings: &mut Value) {
if !settings.is_object() {
*settings = json!({});
}
let now = Utc::now().timestamp_millis();
let obj = settings.as_object_mut().unwrap();
let has_projects = obj
.get("projects")
.and_then(|value| value.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false);
if has_projects {
return;
}
let last_directory = obj
.get("lastDirectory")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(expand_tilde_path);
let Some(mut last_directory) = last_directory else {
return;
};
if let Ok(canonicalized) = std::fs::canonicalize(&last_directory) {
last_directory = canonicalized;
}
let Ok(stats) = std::fs::metadata(&last_directory) else {
return;
};
if !stats.is_dir() {
return;
}
let normalized_path = last_directory.to_string_lossy().to_string();
if normalized_path.trim().is_empty() {
return;
}
let project_id = Uuid::new_v4().to_string();
let active_project_id = project_id.clone();
let project_path = normalized_path.clone();
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
*projects_value = json!([
{
"id": project_id,
"path": project_path,
"addedAt": now,
"lastOpenedAt": now
}
]);
obj.insert("activeProjectId".to_string(), json!(active_project_id));
// Ensure approvedDirectories includes the migrated project root.
let approved_value = obj
.entry("approvedDirectories")
.or_insert_with(|| json!([]));
if !approved_value.is_array() {
*approved_value = json!([]);
}
if let Some(array) = approved_value.as_array_mut() {
array.push(json!(normalized_path.clone()));
array.retain(|entry| entry.as_str().is_some_and(|value| !value.trim().is_empty()));
let mut seen = HashSet::new();
array.retain(|entry| {
let Some(value) = entry.as_str() else {
return false;
};
if seen.contains(value) {
return false;
}
seen.insert(value.to_string());
true
});
}
}
fn migrate_legacy_theme_settings(settings: &mut Value) {
if !settings.is_object() {
*settings = json!({});
}
let obj = settings.as_object_mut().unwrap();
let theme_id = obj
.get("themeId")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_string());
let theme_variant = obj
.get("themeVariant")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| *value == "light" || *value == "dark")
.map(|value| value.to_string());
let has_light = obj
.get("lightThemeId")
.and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty());
let has_dark = obj
.get("darkThemeId")
.and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty());
if has_light && has_dark {
return;
}
let default_light = "flexoki-light".to_string();
let default_dark = "flexoki-dark".to_string();
if !has_light {
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
if variant == "light" {
id.clone()
} else {
default_light.clone()
}
} else {
default_light.clone()
};
obj.insert("lightThemeId".to_string(), json!(next));
}
if !has_dark {
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
if variant == "dark" {
id.clone()
} else {
default_dark.clone()
}
} else {
default_dark.clone()
};
obj.insert("darkThemeId".to_string(), json!(next));
}
}
fn normalize_project_selection(settings: &mut Value) {
let Some(obj) = settings.as_object_mut() else {
return;
};
let Some(projects) = obj.get("projects").and_then(|value| value.as_array()) else {
return;
};
if projects.is_empty() {
obj.remove("activeProjectId");
return;
}
let current_active = obj
.get("activeProjectId")
.and_then(|value| value.as_str())
.unwrap_or("");
let has_active = projects.iter().any(|entry| {
entry
.get("id")
.and_then(|value| value.as_str())
.map(|id| id == current_active)
.unwrap_or(false)
});
if has_active {
return;
}
let first_id = projects
.first()
.and_then(|entry| entry.get("id"))
.and_then(|value| value.as_str());
if let Some(id) = first_id {
obj.insert("activeProjectId".to_string(), json!(id));
} else {
obj.remove("activeProjectId");
}
}
/// Merge persisted settings (port of Express mergePersistedSettings)
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
let mut result = current.clone();
if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) {
// First apply all changes
for (key, value) in changes_obj {
result_obj.insert(key.clone(), value.clone());
}
// Build approvedDirectories from base + additional
let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") {
extract_string_vec(arr)
} else if let Some(arr) = current.get("approvedDirectories") {
extract_string_vec(arr)
} else {
vec![]
};
let mut additional_approved = vec![];
if let Some(Value::String(s)) = changes_obj.get("lastDirectory") {
if !s.is_empty() {
additional_approved.push(s.clone());
}
}
if let Some(Value::String(s)) = changes_obj.get("homeDirectory") {
if !s.is_empty() {
additional_approved.push(s.clone());
}
}
let project_source = if let Some(Value::Array(arr)) = changes_obj.get("projects") {
Some(arr)
} else {
current.get("projects").and_then(|v| v.as_array())
};
if let Some(entries) = project_source {
for entry in entries {
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
if !path.trim().is_empty() {
additional_approved.push(path.trim().to_string());
}
}
}
}
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
for item in additional_approved {
approved_set.insert(item);
}
let approved_vec: Vec<String> = approved_set.into_iter().collect();
result_obj.insert("approvedDirectories".to_string(), json!(approved_vec));
// Security scoped bookmarks
let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") {
extract_string_vec(arr)
} else if let Some(arr) = current.get("securityScopedBookmarks") {
extract_string_vec(arr)
} else {
vec![]
};
let bookmarks_set: HashSet<String> = base_bookmarks.into_iter().collect();
let bookmarks_vec: Vec<String> = bookmarks_set.into_iter().collect();
result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec));
// Merge typography sizes if present
if changes_obj.contains_key("typographySizes") {
let current_typo = current
.get("typographySizes")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let changes_typo = changes_obj
.get("typographySizes")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let mut merged_typo = current_typo;
for (key, value) in changes_typo {
merged_typo.insert(key, value);
}
result_obj.insert("typographySizes".to_string(), json!(merged_typo));
}
}
result
}
/// Format settings response (port of Express formatSettingsResponse)
fn format_settings_response(settings: &Value) -> Value {
let mut result = sanitize_settings_update(settings);
if let Some(obj) = result.as_object_mut() {
// Ensure array fields are normalized
obj.insert(
"approvedDirectories".to_string(),
normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))),
);
obj.insert(
"securityScopedBookmarks".to_string(),
normalize_string_array(
settings
.get("securityScopedBookmarks")
.unwrap_or(&json!([])),
),
);
obj.insert(
"pinnedDirectories".to_string(),
normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))),
);
// Typography sizes
if let Some(sanitized_typo) = sanitize_typography_sizes_partial(
settings.get("typographySizes").unwrap_or(&json!(null)),
) {
obj.insert("typographySizes".to_string(), sanitized_typo);
}
// showReasoningTraces with fallback
let show_reasoning = settings
.get("showReasoningTraces")
.and_then(|v| v.as_bool())
.or_else(|| {
// Get showReasoningTraces from sanitized result instead of the current mutable borrow
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
Some(*b)
} else {
None
}
})
.unwrap_or(false);
obj.insert("showReasoningTraces".to_string(), json!(show_reasoning));
}
result
}
/// Normalize string array helper
fn normalize_string_array(input: &Value) -> Value {
if let Some(arr) = input.as_array() {
let strings: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let unique: HashSet<String> = strings.into_iter().collect();
json!(unique.into_iter().collect::<Vec<_>>())
} else {
json!([])
}
}
/// Sanitize typography sizes partial helper
fn sanitize_typography_sizes_partial(input: &Value) -> Option<Value> {
if let Some(obj) = input.as_object() {
let mut result = serde_json::Map::new();
let mut populated = false;
for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] {
if let Some(Value::String(s)) = obj.get(*key) {
if !s.is_empty() {
result.insert(key.to_string(), json!(s));
populated = true;
}
}
}
if populated {
Some(json!(result))
} else {
None
}
} else {
None
}
}
/// Extract string vector from JSON value
fn extract_string_vec(value: &Value) -> Vec<String> {
if let Some(arr) = value.as_array() {
arr.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
} else {
vec![]
}
}
@@ -1,501 +0,0 @@
use log::error;
use parking_lot::Mutex;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
env,
io::{Read, Write},
path::{Path, PathBuf},
sync::Arc,
thread,
time::Duration,
};
use tauri::{Emitter, State, Window};
const DEFAULT_SHELL: &str = "/bin/zsh";
const DEFAULT_TERM: &str = "xterm-256color";
const DEFAULT_COLORTERM: &str = "truecolor";
const DEFAULT_LOCALE: &str = "en_US.UTF-8";
const TERM_PROGRAM_NAME: &str = "OpenChamber";
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
// Emit at most ~60fps and avoid tiny payload spam.
const EMIT_INTERVAL: Duration = Duration::from_millis(16);
const EMIT_MAX_BUFFER_BYTES: usize = 64 * 1024;
pub struct TerminalSession {
pub master: Box<dyn MasterPty + Send>,
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
pub child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
}
pub struct TerminalState {
pub sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
}
impl TerminalState {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
}
#[derive(Deserialize)]
pub struct CreateTerminalPayload {
pub cols: u16,
pub rows: u16,
pub cwd: Option<String>,
}
#[derive(Serialize)]
pub struct CreateTerminalResponse {
pub session_id: String,
}
#[tauri::command]
pub async fn create_terminal_session(
payload: CreateTerminalPayload,
state: State<'_, TerminalState>,
window: Window,
) -> Result<CreateTerminalResponse, String> {
let pty_system = NativePtySystem::default();
let size = PtySize {
rows: payload.rows,
cols: payload.cols,
pixel_width: 0,
pixel_height: 0,
};
let working_dir = resolve_working_directory(payload.cwd.as_deref())?;
let shell_path = resolve_shell();
let mut cmd = CommandBuilder::new(&shell_path);
if shell_accepts_login_flag(&shell_path) {
cmd.arg("-l");
}
if let Some(cwd) = working_dir.to_str() {
cmd.cwd(cwd);
}
apply_terminal_environment(&mut cmd, &shell_path);
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
let writer = Arc::new(Mutex::new(
pair.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
));
let master = pair.master;
let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().insert(
session_id.clone(),
TerminalSession {
master,
writer: writer.clone(),
child: child.clone(),
},
);
spawn_reader_thread(reader, window.clone(), session_id.clone());
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
Ok(CreateTerminalResponse { session_id })
}
#[tauri::command]
pub async fn send_terminal_input(
session_id: String,
data: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let writer = {
let sessions = state.sessions.lock();
let Some(session) = sessions.get(&session_id) else {
return Err("Terminal session not found".to_string());
};
session.writer.clone()
};
let mut guard = writer.lock();
guard
.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn resize_terminal(
session_id: String,
cols: u16,
rows: u16,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let mut sessions = state.sessions.lock();
let Some(session) = sessions.get_mut(&session_id) else {
return Err("Terminal session not found".to_string());
};
session
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn close_terminal(
session_id: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let session = { state.sessions.lock().remove(&session_id) };
if let Some(session) = session {
let _ = session.child.lock().kill();
}
Ok(())
}
#[derive(Deserialize)]
pub struct RestartTerminalPayload {
pub session_id: String,
pub cols: u16,
pub rows: u16,
pub cwd: String,
}
#[tauri::command]
pub async fn restart_terminal_session(
payload: RestartTerminalPayload,
state: State<'_, TerminalState>,
window: Window,
) -> Result<CreateTerminalResponse, String> {
{
let session = state.sessions.lock().remove(&payload.session_id);
if let Some(session) = session {
let _ = session.child.lock().kill();
}
}
let pty_system = NativePtySystem::default();
let size = PtySize {
rows: payload.rows,
cols: payload.cols,
pixel_width: 0,
pixel_height: 0,
};
let working_dir = resolve_working_directory(Some(&payload.cwd))?;
let shell_path = resolve_shell();
let mut cmd = CommandBuilder::new(&shell_path);
if shell_accepts_login_flag(&shell_path) {
cmd.arg("-l");
}
if let Some(cwd) = working_dir.to_str() {
cmd.cwd(cwd);
}
apply_terminal_environment(&mut cmd, &shell_path);
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
let writer = Arc::new(Mutex::new(
pair.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
));
let master = pair.master;
let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().insert(
session_id.clone(),
TerminalSession {
master,
writer: writer.clone(),
child: child.clone(),
},
);
spawn_reader_thread(reader, window.clone(), session_id.clone());
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
Ok(CreateTerminalResponse { session_id })
}
#[derive(Deserialize)]
pub struct ForceKillPayload {
pub session_id: Option<String>,
pub cwd: Option<String>,
}
#[tauri::command]
pub async fn force_kill_terminal(
payload: ForceKillPayload,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let mut sessions = state.sessions.lock();
if let Some(session_id) = payload.session_id {
if let Some(session) = sessions.remove(&session_id) {
let _ = session.child.lock().kill();
}
return Ok(());
}
// Current API ignores cwd; keep behavior but avoid holding poisoned locks.
let _ = payload.cwd;
let ids: Vec<String> = sessions.keys().cloned().collect();
for id in ids {
if let Some(session) = sessions.remove(&id) {
let _ = session.child.lock().kill();
}
}
Ok(())
}
fn spawn_reader_thread(reader: Box<dyn Read + Send>, window: Window, session_id: String) {
thread::spawn(move || {
use std::sync::mpsc;
let event_name = format!("terminal://{}", session_id);
let (tx, rx) = mpsc::channel::<Vec<u8>>();
// Dedicated blocking reader thread.
let reader_handle = thread::spawn(move || {
let mut reader = reader;
let mut buffer = [0u8; 16384];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
if tx.send(buffer[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let mut pending = String::new();
let mut pending_bytes: Vec<u8> = Vec::new();
let flush = |pending: &mut String| -> bool {
if pending.is_empty() {
return true;
}
let payload_data = std::mem::take(pending);
let payload = serde_json::json!({ "type": "data", "data": payload_data });
match window.emit(&event_name, payload) {
Ok(_) => true,
Err(error) => {
error!("Failed to emit terminal data: {error}");
false
}
}
};
let decode_pending = |pending_bytes: &mut Vec<u8>, pending: &mut String| {
loop {
match std::str::from_utf8(pending_bytes) {
Ok(text) => {
if !text.is_empty() {
pending.push_str(text);
}
pending_bytes.clear();
break;
}
Err(error) => {
let valid = error.valid_up_to();
if valid > 0 {
let text = std::str::from_utf8(&pending_bytes[..valid]).unwrap_or("");
if !text.is_empty() {
pending.push_str(text);
}
pending_bytes.drain(..valid);
continue;
}
// Incomplete UTF-8 at end; wait for more bytes.
if error.error_len().is_none() {
break;
}
// Invalid leading byte; consume 1 byte and replace.
if !pending_bytes.is_empty() {
pending_bytes.drain(..1);
pending.push('\u{FFFD}');
continue;
}
break;
}
}
}
};
loop {
match rx.recv_timeout(EMIT_INTERVAL) {
Ok(bytes) => {
pending_bytes.extend_from_slice(&bytes);
decode_pending(&mut pending_bytes, &mut pending);
if pending.len() >= EMIT_MAX_BUFFER_BYTES {
if !flush(&mut pending) {
break;
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
// Flush any buffered output even if the PTY is idle.
if !pending_bytes.is_empty() {
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
pending_bytes.clear();
}
if !flush(&mut pending) {
break;
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
if !pending_bytes.is_empty() {
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
pending_bytes.clear();
}
let _ = flush(&mut pending);
break;
}
}
}
let _ = reader_handle.join();
});
}
fn spawn_exit_watcher(
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
window: Window,
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
session_id: String,
) {
thread::spawn(move || {
let status = { child.lock().wait() };
let (exit_code, signal) = match status {
Ok(status) => (
status.exit_code() as i32,
status.signal().map(|sig| sig.to_string()),
),
Err(err) => {
error!("Failed to wait for terminal exit: {err}");
(1, Some("Terminal crashed".to_string()))
}
};
let event_name = format!("terminal://{}", session_id);
let payload = serde_json::json!({
"type": "exit",
"exitCode": exit_code,
"signal": signal
});
let _ = window.emit(&event_name, payload);
sessions.lock().remove(&session_id);
});
}
fn resolve_shell() -> String {
env::var("SHELL")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_SHELL.to_string())
}
fn shell_accepts_login_flag(shell_path: &str) -> bool {
let shell_name = Path::new(shell_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(shell_path)
.to_lowercase();
matches!(
shell_name.as_str(),
name if name.contains("zsh")
|| name.contains("bash")
|| name.contains("sh")
|| name.contains("fish")
|| name.contains("ksh")
)
}
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir());
let Some(path) = maybe_path else {
return Err("Unable to determine working directory".to_string());
};
if !path.exists() || !path.is_dir() {
return Err(format!(
"Working directory is not accessible: {}",
path.display()
));
}
Ok(path)
}
fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) {
cmd.env(
"TERM",
env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()),
);
cmd.env(
"COLORTERM",
env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()),
);
cmd.env(
"LC_ALL",
env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
);
cmd.env(
"LANG",
env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
);
cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME);
cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION);
cmd.env("OPENCHAMBER_DESKTOP", "1");
cmd.env("SHELL", shell_path);
}
-1
View File
@@ -1 +0,0 @@
-20
View File
@@ -1,20 +0,0 @@
use std::path::PathBuf;
#[cfg(target_os = "macos")]
const PLATFORM_LOG_SEGMENTS: &[&str] = &["Library", "Logs", "OpenChamber"];
#[cfg(not(target_os = "macos"))]
const PLATFORM_LOG_SEGMENTS: &[&str] = &[".config", "openchamber", "logs"];
pub fn log_directory() -> Option<PathBuf> {
let mut path = dirs::home_dir()?;
for segment in PLATFORM_LOG_SEGMENTS {
path.push(segment);
}
Some(path)
}
pub fn log_file_path() -> Option<PathBuf> {
let mut dir = log_directory()?;
dir.push("openchamber.log");
Some(dir)
}
File diff suppressed because it is too large Load Diff
@@ -1,109 +0,0 @@
use anyhow::{anyhow, Result};
use log::info;
use serde_json::Value;
use std::path::PathBuf;
use tokio::fs;
/// Get OpenCode data directory path (~/.local/share/opencode)
fn get_data_dir() -> PathBuf {
dirs::home_dir()
.expect("Cannot determine home directory")
.join(".local")
.join("share")
.join("opencode")
}
/// Get auth file path
fn get_auth_file() -> PathBuf {
get_data_dir().join("auth.json")
}
/// Ensure data directory exists
async fn ensure_data_dir() -> Result<()> {
let data_dir = get_data_dir();
fs::create_dir_all(&data_dir).await?;
Ok(())
}
/// Read auth.json file
pub async fn read_auth() -> Result<Value> {
let auth_file = get_auth_file();
if !auth_file.exists() {
return Ok(Value::Object(serde_json::Map::new()));
}
let content = fs::read_to_string(&auth_file).await?;
let trimmed = content.trim();
if trimmed.is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
serde_json::from_str(trimmed).map_err(|e| anyhow!("Failed to parse auth file: {}", e))
}
/// Write auth.json file with backup
pub async fn write_auth(auth: &Value) -> Result<()> {
ensure_data_dir().await?;
let auth_file = get_auth_file();
// Create backup before writing
if auth_file.exists() {
let file_name = auth_file
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("Invalid auth file name"))?;
let backup_path = auth_file.with_file_name(format!("{file_name}.openchamber.backup"));
fs::copy(&auth_file, &backup_path).await?;
info!("Created auth backup: {}", backup_path.display());
}
let json_string = serde_json::to_string_pretty(auth)?;
fs::write(&auth_file, json_string).await?;
info!("Successfully wrote auth file");
Ok(())
}
/// Get provider auth entry from auth.json
pub async fn get_provider_auth(provider_id: &str) -> Result<Option<Value>> {
if provider_id.is_empty() {
return Err(anyhow!("Provider ID is required"));
}
let auth = read_auth().await?;
let auth_obj = auth
.as_object()
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
Ok(auth_obj.get(provider_id).cloned())
}
/// Remove provider auth entry from auth.json
pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
if provider_id.is_empty() {
return Err(anyhow!("Provider ID is required"));
}
let mut auth = read_auth().await?;
let auth_obj = auth
.as_object_mut()
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
if !auth_obj.contains_key(provider_id) {
info!(
"Provider {} not found in auth file, nothing to remove",
provider_id
);
return Ok(false);
}
auth_obj.remove(provider_id);
write_auth(&auth).await?;
info!("Removed provider auth: {}", provider_id);
Ok(true)
}
File diff suppressed because it is too large Load Diff
@@ -1,751 +0,0 @@
use anyhow::{anyhow, Result};
use log::{debug, info, warn};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use regex::Regex;
use reqwest::Client;
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::{Child, Command},
sync::Mutex,
time::timeout,
};
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"https?://[^:\s]+:(?P<port>\d+)(?P<path>/[^\s"']*)?"#).expect("valid regex")
});
const FIRST_SIGNAL_TIMEOUT_MS: u64 = 750;
const READY_CHECK_TIMEOUT_MS: u64 = 20000;
const READY_CHECK_INTERVAL_MS: u64 = 400;
#[derive(Clone)]
pub struct OpenCodeManager {
binary: Option<String>,
args: Vec<String>,
env: HashMap<String, String>,
working_dir: Arc<RwLock<PathBuf>>,
desired_port: u16,
child: Arc<Mutex<Option<Child>>>,
port: Arc<RwLock<Option<u16>>>,
api_prefix: Arc<RwLock<String>>,
is_ready: Arc<AtomicBool>,
shutting_down: Arc<AtomicBool>,
http_client: Client,
}
fn normalize_api_prefix(prefix: &str) -> String {
let trimmed = prefix.trim();
if trimmed.is_empty() || trimmed == "/" {
return String::new();
}
let mut normalized = trimmed.trim_end_matches('/').to_string();
if !normalized.starts_with('/') {
normalized.insert(0, '/');
}
normalized
}
impl OpenCodeManager {
pub fn new_with_directory(_initial_dir: Option<PathBuf>) -> Self {
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
.ok()
.and_then(|raw| raw.parse::<u16>().ok())
.unwrap_or(0);
let binary = resolve_opencode_binary();
if let Some(ref bin) = binary {
if !Path::new(bin).is_absolute() {
info!("[desktop:opencode] using PATH-resolved binary: {}", bin);
} else {
info!("[desktop:opencode] using binary: {}", bin);
}
} else {
warn!("[desktop:opencode] OpenCode CLI not found - app will run in limited mode");
}
let mut args = vec![
"serve".to_string(),
"--port".to_string(),
desired_port.to_string(),
];
if let Ok(config) = std::env::var("OPENCHAMBER_OPENCODE_CONFIG") {
if !config.is_empty() {
args.push("--config".to_string());
args.push(config);
}
}
let env = build_augmented_env();
let working_dir = dirs::home_dir()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
info!(
"[desktop:opencode] Initial working directory: {:?}",
working_dir
);
Self {
binary,
args,
env,
working_dir: Arc::new(RwLock::new(working_dir)),
desired_port,
child: Arc::new(Mutex::new(None)),
port: Arc::new(RwLock::new(None)),
api_prefix: Arc::new(RwLock::new(String::new())),
is_ready: Arc::new(AtomicBool::new(false)),
shutting_down: Arc::new(AtomicBool::new(false)),
http_client: Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap(),
}
}
pub fn is_cli_available(&self) -> bool {
self.binary.is_some()
}
pub async fn ensure_running(&self) -> Result<()> {
if self.binary.is_none() {
return Err(anyhow!("OpenCode CLI is not available"));
}
let mut guard = self.child.lock().await;
if let Some(child) = guard.as_mut() {
if child.try_wait()?.is_none() && self.is_ready.load(Ordering::SeqCst) {
return Ok(());
}
}
self.is_ready.store(false, Ordering::SeqCst);
let child = self.spawn_process().await?;
*guard = Some(child);
drop(guard);
// Wait for port detection from logs
if self.desired_port == 0 {
self.wait_for_port_detection().await?;
}
// Detect API prefix early so proxy can forward correctly
let _ = self.detect_api_prefix().await;
// Wait for OpenCode to become ready by polling endpoints
self.wait_for_ready().await?;
self.is_ready.store(true, Ordering::SeqCst);
if let Some(port) = self.current_port() {
info!("[desktop:opencode] ready on port {port}");
}
Ok(())
}
pub async fn restart(&self) -> Result<()> {
info!("[desktop:opencode] restarting...");
self.is_ready.store(false, Ordering::SeqCst);
self.graceful_stop().await?;
// Brief delay to let OS release resources
tokio::time::sleep(Duration::from_millis(250)).await;
// Reset state
if self.desired_port == 0 {
*self.port.write() = None;
}
*self.api_prefix.write() = String::new();
self.ensure_running().await
}
pub async fn shutdown(&self) -> Result<()> {
self.shutting_down.store(true, Ordering::SeqCst);
self.is_ready.store(false, Ordering::SeqCst);
self.graceful_stop().await
}
#[allow(dead_code)]
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
*self.working_dir.write() = new_dir;
Ok(())
}
#[allow(dead_code)]
pub fn get_working_directory(&self) -> PathBuf {
self.working_dir.read().clone()
}
async fn detect_api_prefix(&self) -> Result<()> {
let Some(port) = self.current_port() else {
return Err(anyhow!("Cannot detect API prefix without port"));
};
// Try no prefix first, then /api (compatibility).
let candidates = ["", "/api"];
for candidate in candidates {
let base = if candidate.is_empty() {
format!("http://127.0.0.1:{port}")
} else {
format!("http://127.0.0.1:{port}{candidate}")
};
let url = format!("{base}/config");
match self.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
// Validate it's actually JSON config, not HTML
if let Ok(text) = resp.text().await {
if text.trim().starts_with('{') || text.trim().starts_with('[') {
info!("[desktop:opencode] Detected API prefix: {:?}", candidate);
*self.api_prefix.write() = normalize_api_prefix(candidate);
return Ok(());
}
}
}
_ => continue,
}
}
info!("[desktop:opencode] No API prefix detected, using empty prefix");
*self.api_prefix.write() = String::new();
Ok(())
}
pub fn current_port(&self) -> Option<u16> {
*self.port.read()
}
pub fn api_prefix(&self) -> String {
self.api_prefix.read().clone()
}
pub fn is_ready(&self) -> bool {
self.is_ready.load(Ordering::SeqCst)
}
pub fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::SeqCst)
}
pub async fn is_child_running(&self) -> Result<bool> {
let mut guard = self.child.lock().await;
if let Some(child) = guard.as_mut() {
match child.try_wait()? {
None => return Ok(true),
Some(_status) => {
*guard = None;
self.is_ready.store(false, Ordering::SeqCst);
return Ok(false);
}
}
}
Ok(false)
}
pub fn rewrite_path(&self, incoming_path: &str) -> String {
// Strip /api prefix to get OpenCode path
let result = incoming_path
.strip_prefix("/api")
.map(|rest| if rest.is_empty() { "/" } else { rest })
.unwrap_or(incoming_path)
.to_string();
debug!(
"[opencode_manager] rewrite_path: '{}' -> '{}'",
incoming_path, result
);
result
}
async fn spawn_process(&self) -> Result<Child> {
let binary = self
.binary
.as_ref()
.ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?;
info!("[desktop:opencode] launching {} {:?}", binary, self.args);
let working_dir = self.working_dir.read().clone();
let mut cmd = Command::new(binary);
cmd.args(&self.args)
.current_dir(&working_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(false);
for (key, value) in &self.env {
cmd.env(key, value);
}
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow!(
"OpenCode binary '{}' not found. Set OPENCODE_BINARY or ensure it's in PATH.",
binary
)
} else {
anyhow!("Failed to spawn OpenCode: {}", e)
}
})?;
// Set port immediately if pre-configured
if self.desired_port > 0 {
*self.port.write() = Some(self.desired_port);
}
// Wait for first signal (stdout/stderr) within 750ms to confirm startup
let first_signal_received = Arc::new(AtomicBool::new(false));
if let Some(stdout) = child.stdout.take() {
let signal_flag = first_signal_received.clone();
self.spawn_output_reader(stdout, "stdout", move || {
signal_flag.store(true, Ordering::SeqCst);
});
}
if let Some(stderr) = child.stderr.take() {
let signal_flag = first_signal_received.clone();
self.spawn_output_reader(stderr, "stderr", move || {
signal_flag.store(true, Ordering::SeqCst);
});
}
// Wait for first signal or timeout
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_millis(FIRST_SIGNAL_TIMEOUT_MS) {
if first_signal_received.load(Ordering::SeqCst) {
break;
}
if let Ok(Some(_)) = child.try_wait() {
return Err(anyhow!("OpenCode process exited immediately after spawn"));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(child)
}
fn spawn_output_reader<F>(
&self,
stream: impl tokio::io::AsyncRead + Unpin + Send + 'static,
label: &'static str,
on_first_line: F,
) where
F: FnOnce() + Send + 'static,
{
let manager = self.clone();
let first_line_flag = Arc::new(Mutex::new(Some(on_first_line)));
tauri::async_runtime::spawn(async move {
let reader = BufReader::new(stream);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
// Trigger first signal callback
if let Some(callback) = first_line_flag.lock().await.take() {
callback();
}
debug!("[opencode:{label}] {line}");
manager.ingest_output_line(&line);
}
});
}
fn ingest_output_line(&self, line: &str) {
if let Some(captures) = URL_REGEX.captures(line) {
if let Some(port_match) = captures
.name("port")
.and_then(|m| m.as_str().parse::<u16>().ok())
{
*self.port.write() = Some(port_match);
}
if let Some(path_match) = captures.name("path") {
let value = path_match.as_str();
if !value.is_empty() && value != "/" {
*self.api_prefix.write() = value.to_string();
}
}
}
}
async fn wait_for_port_detection(&self) -> Result<()> {
let start = std::time::Instant::now();
let timeout_duration = Duration::from_secs(15);
while start.elapsed() < timeout_duration {
if self.current_port().is_some() {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(anyhow!("OpenCode did not report port within 15 seconds"))
}
async fn wait_for_ready(&self) -> Result<()> {
let Some(port) = self.current_port() else {
return Err(anyhow!("Cannot check readiness without port"));
};
let deadline = tokio::time::Instant::now() + Duration::from_millis(READY_CHECK_TIMEOUT_MS);
let mut last_error: Option<String> = None;
while tokio::time::Instant::now() < deadline {
let api_prefix = self.api_prefix();
// Try /config, /agent endpoints
match self.check_endpoints(port, &api_prefix).await {
Ok(()) => {
return Ok(());
}
Err(e) => {
last_error = Some(e.to_string());
}
}
tokio::time::sleep(Duration::from_millis(READY_CHECK_INTERVAL_MS)).await;
}
Err(anyhow!(
"OpenCode not ready after {}ms: {}",
READY_CHECK_TIMEOUT_MS,
last_error.unwrap_or_else(|| "no error details".to_string())
))
}
async fn check_endpoints(&self, port: u16, prefix: &str) -> Result<()> {
let base_url = format!("http://127.0.0.1:{port}{prefix}");
let config_url = format!("{base_url}/config");
let agent_url = format!("{base_url}/agent");
let (config_resp, agent_resp) = tokio::join!(
self.http_client.get(&config_url).send(),
self.http_client.get(&agent_url).send()
);
let config_resp = config_resp?;
if !config_resp.status().is_success() {
return Err(anyhow!("/config returned {}", config_resp.status()));
}
let agent_resp = agent_resp?;
if !agent_resp.status().is_success() {
return Err(anyhow!("/agent returned {}", agent_resp.status()));
}
Ok(())
}
async fn graceful_stop(&self) -> Result<()> {
let port_to_kill = self.current_port();
let mut guard = self.child.lock().await;
let Some(mut child) = guard.take() else {
// No child, but still kill by port in case of orphaned processes
drop(guard);
kill_process_on_port(port_to_kill);
return Ok(());
};
if child.try_wait()?.is_some() {
// Already exited, but still clean up by port
drop(guard);
kill_process_on_port(port_to_kill);
return Ok(());
}
// SIGTERM
#[cfg(unix)]
{
use nix::{
sys::signal::{kill, Signal},
unistd::Pid,
};
if let Some(id) = child.id() {
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
info!("[desktop:opencode] sent SIGTERM");
}
}
#[cfg(windows)]
{
let _ = child.kill().await;
}
// Wait 3 seconds for graceful exit
match timeout(Duration::from_secs(3), child.wait()).await {
Ok(_) => {
info!("[desktop:opencode] exited gracefully");
drop(guard);
kill_process_on_port(port_to_kill);
return Ok(());
}
Err(_) => {
warn!("[desktop:opencode] did not exit after SIGTERM, sending SIGKILL");
}
}
// SIGKILL
let _ = child.kill().await;
match timeout(Duration::from_secs(2), child.wait()).await {
Ok(_) => {
info!("[desktop:opencode] exited after SIGKILL");
}
Err(_) => {
warn!("[desktop:opencode] unresponsive after SIGKILL, continuing anyway");
}
}
drop(guard);
kill_process_on_port(port_to_kill);
Ok(())
}
}
fn kill_process_on_port(port: Option<u16>) {
let Some(port) = port else { return };
// Kill any process listening on our port to clean up orphaned children.
// The opencode CLI is a Node wrapper that spawns the actual binary as a child.
// Killing the wrapper doesn't kill the child, so we kill by port.
#[cfg(unix)]
{
use std::process::Command;
// First get PIDs, then kill them separately to avoid xargs issues
if let Ok(output) = Command::new("lsof")
.args(["-ti", &format!(":{}", port)])
.output()
{
let pids = String::from_utf8_lossy(&output.stdout);
for pid in pids.split_whitespace() {
if let Ok(pid_num) = pid.trim().parse::<i32>() {
// Don't kill our own process
if pid_num != std::process::id() as i32 {
let _ = Command::new("kill")
.args(["-9", &pid_num.to_string()])
.output();
}
}
}
}
}
}
/// Check if CLI binary exists (can be called dynamically for polling)
pub fn check_cli_exists() -> bool {
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
return false;
}
resolve_opencode_binary().is_some()
}
fn resolve_opencode_binary() -> Option<String> {
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
return None;
}
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
if !value.is_empty() && Path::new(&value).exists() {
info!(
"[desktop:opencode] using binary from OPENCODE_BINARY env: {}",
value
);
return Some(value);
}
}
let shell_env = detect_shell_env();
if let Some(ref binary) = shell_env.opencode_binary {
if Path::new(binary).exists() {
info!(
"[desktop:opencode] using binary from shell OPENCODE_BINARY: {}",
binary
);
return Some(binary.clone());
}
}
if let Some(ref login_path) = shell_env.path {
for dir in login_path.split(':') {
let candidate = format!("{}/opencode", dir);
if Path::new(&candidate).exists() {
info!("[desktop:opencode] found binary in PATH: {}", candidate);
return Some(candidate);
}
}
}
if let Some(home) = dirs::home_dir() {
let fallback = home.join(".opencode/bin/opencode");
if fallback.exists() {
info!(
"[desktop:opencode] found binary in fallback location: {:?}",
fallback
);
return Some(fallback.to_string_lossy().to_string());
}
}
warn!("[desktop:opencode] opencode binary not found");
None
}
fn build_augmented_env() -> HashMap<String, String> {
let mut env: HashMap<String, String> = std::env::vars().collect();
if let Ok(login_path) = detect_login_shell_path() {
let current = env.get("PATH").cloned().unwrap_or_default();
env.insert("PATH".to_string(), merge_paths(&login_path, &current));
}
env
}
fn merge_paths(login_path: &str, current: &str) -> String {
let mut segments = Vec::new();
let mut seen = std::collections::HashSet::new();
for part in login_path.split(':').chain(current.split(':')) {
if part.is_empty() || seen.contains(part) {
continue;
}
seen.insert(part.to_string());
segments.push(part);
}
segments.join(":")
}
#[derive(Default)]
struct ShellEnv {
path: Option<String>,
opencode_binary: Option<String>,
}
#[cfg(target_os = "macos")]
fn get_user_shell() -> Option<String> {
use std::process::Command;
let username =
dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
let output = Command::new("dscl")
.args([".", "-read", &format!("/Users/{}", username), "UserShell"])
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.split(':').nth(1).map(|s| s.trim().to_string())
} else {
None
}
}
#[cfg(all(unix, not(target_os = "macos")))]
fn get_user_shell() -> Option<String> {
std::env::var("SHELL").ok()
}
#[cfg(not(unix))]
fn get_user_shell() -> Option<String> {
None
}
fn build_shell_env_command(shell: &str) -> Vec<String> {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("sh");
match shell_name {
"nu" | "nushell" => vec![
"-l".to_string(),
"-i".to_string(),
"-c".to_string(),
"echo $\"__PATH__=($env.PATH | str join (char esep))\"; echo $\"__OPENCODE_BINARY__=($env.OPENCODE_BINARY? | default '')\"".to_string(),
],
"bash" => vec![
"-lic".to_string(),
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(),
],
_ => vec![
"-lic".to_string(),
"echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(),
],
}
}
fn detect_shell_env() -> ShellEnv {
#[cfg(not(unix))]
{
ShellEnv::default()
}
#[cfg(unix)]
{
use std::process::Command;
let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into());
info!("[desktop:opencode] detected user shell: {}", shell);
let args = build_shell_env_command(&shell);
info!("[desktop:opencode] shell args: {:?}", args);
let output = match Command::new(&shell).args(&args).output() {
Ok(o) => o,
Err(e) => {
warn!("[desktop:opencode] failed to run shell {}: {}", shell, e);
return ShellEnv::default();
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(
"[desktop:opencode] shell env detection failed for {}, stderr: {}",
shell, stderr
);
return ShellEnv::default();
}
let stdout = String::from_utf8_lossy(&output.stdout);
info!("[desktop:opencode] shell stdout length: {}", stdout.len());
let mut env = ShellEnv::default();
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("__PATH__=") {
if !path.is_empty() {
env.path = Some(path.to_string());
}
} else if let Some(binary) = line.strip_prefix("__OPENCODE_BINARY__=") {
if !binary.is_empty() {
env.opencode_binary = Some(binary.to_string());
}
}
}
info!(
"[desktop:opencode] parsed path exists: {}",
env.path.is_some()
);
env
}
}
fn detect_login_shell_path() -> Result<String> {
detect_shell_env()
.path
.ok_or_else(|| anyhow!("shell PATH detection failed"))
}
@@ -1,20 +0,0 @@
use std::path::PathBuf;
pub fn expand_tilde_path(value: &str) -> PathBuf {
let trimmed = value.trim();
if trimmed.is_empty() {
return PathBuf::from(trimmed);
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
if trimmed == "~" {
return home;
}
if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
return home.join(&trimmed[2..]);
}
PathBuf::from(trimmed)
}
@@ -1,930 +0,0 @@
use anyhow::{anyhow, Result};
use chrono::{DateTime, Local, TimeZone};
use log::warn;
use reqwest::Client;
use serde::Serialize;
use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
time::Duration,
};
use crate::opencode_auth;
const OPENCODE_CONFIG_DIR: &str = ".config/opencode";
const OPENCODE_DATA_DIR: &str = ".local/share/opencode";
const GOOGLE_CLIENT_ID: &str =
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
const GOOGLE_CLIENT_SECRET: &str = "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";
const DEFAULT_PROJECT_ID: &str = "rising-fact-p41fc";
const GOOGLE_WINDOW_SECONDS: i64 = 5 * 60 * 60;
const GOOGLE_ENDPOINTS: [&str; 3] = [
"https://daily-cloudcode-pa.sandbox.googleapis.com",
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
"https://cloudcode-pa.googleapis.com",
];
const GOOGLE_USER_AGENT: &str = "antigravity/1.11.5 windows/amd64";
const GOOGLE_API_CLIENT: &str = "google-cloud-sdk vscode_cloudshelleditor/0.1";
const GOOGLE_CLIENT_METADATA: &str =
"{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}";
#[derive(Clone, Debug, Default)]
struct AuthEntry {
token: Option<String>,
access: Option<String>,
refresh: Option<String>,
expires: Option<i64>,
key: Option<String>,
}
#[derive(Clone, Debug, Default)]
struct GoogleAuth {
access_token: Option<String>,
refresh_token: Option<String>,
expires: Option<i64>,
project_id: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderResult {
provider_id: String,
provider_name: String,
ok: bool,
configured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
usage: Option<ProviderUsage>,
fetched_at: i64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProviderUsage {
windows: HashMap<String, UsageWindow>,
#[serde(skip_serializing_if = "Option::is_none")]
models: Option<HashMap<String, ProviderUsage>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UsageWindow {
used_percent: Option<f64>,
remaining_percent: Option<f64>,
window_seconds: Option<i64>,
reset_after_seconds: Option<i64>,
reset_at: Option<i64>,
reset_at_formatted: Option<String>,
reset_after_formatted: Option<String>,
}
fn get_home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}
fn opencode_config_dir() -> PathBuf {
get_home_dir().join(OPENCODE_CONFIG_DIR)
}
fn opencode_data_dir() -> PathBuf {
get_home_dir().join(OPENCODE_DATA_DIR)
}
fn antigravity_accounts_paths() -> [PathBuf; 2] {
[
opencode_config_dir().join("antigravity-accounts.json"),
opencode_data_dir().join("antigravity-accounts.json"),
]
}
async fn read_json_file(path: &PathBuf) -> Option<Value> {
if !path.exists() {
return None;
}
let raw = tokio::fs::read_to_string(path).await.ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
serde_json::from_str(trimmed).map_err(|err| {
warn!("Failed to read JSON file {}: {}", path.display(), err);
err
}).ok()
}
fn get_auth_entry<'a>(auth: &'a serde_json::Map<String, Value>, aliases: &[&str]) -> Option<&'a Value> {
for alias in aliases {
if let Some(value) = auth.get(*alias) {
return Some(value);
}
}
None
}
fn normalize_auth_entry(value: Option<&Value>) -> Option<AuthEntry> {
let value = value?;
match value {
Value::String(token) => Some(AuthEntry {
token: Some(token.clone()),
..AuthEntry::default()
}),
Value::Object(map) => {
let token = map.get("token").and_then(|v| v.as_str()).map(|s| s.to_string());
let access = map.get("access").and_then(|v| v.as_str()).map(|s| s.to_string());
let refresh = map.get("refresh").and_then(|v| v.as_str()).map(|s| s.to_string());
let key = map.get("key").and_then(|v| v.as_str()).map(|s| s.to_string());
let expires = map
.get("expires")
.and_then(|v| v.as_i64())
.or_else(|| map.get("expires").and_then(|v| v.as_f64()).map(|v| v.round() as i64));
Some(AuthEntry {
token,
access,
refresh,
expires,
key,
})
}
_ => None,
}
}
fn format_reset_time(timestamp_ms: i64) -> Option<String> {
let reset_dt = Local.timestamp_millis_opt(timestamp_ms).single()?;
let now = Local::now();
let is_today = reset_dt.date_naive() == now.date_naive();
if is_today {
// Same day: show time only (e.g., "9:56 PM")
Some(reset_dt.format("%-I:%M %p").to_string())
} else {
// Different day: show date + weekday + time (e.g., "Feb 2, Sun 9:56 PM")
Some(reset_dt.format("%b %-d, %a %-I:%M %p").to_string())
}
}
fn calculate_reset_after_seconds(reset_at: Option<i64>) -> Option<i64> {
let reset_at = reset_at?;
let now_ms = chrono::Utc::now().timestamp_millis();
let delta = (reset_at - now_ms) / 1000;
Some(delta.max(0))
}
fn to_usage_window(used_percent: Option<f64>, window_seconds: Option<i64>, reset_at: Option<i64>) -> UsageWindow {
let remaining_percent = used_percent.map(|value| (100.0 - value).max(0.0));
let reset_after_seconds = calculate_reset_after_seconds(reset_at);
let reset_formatted = reset_at.and_then(format_reset_time);
UsageWindow {
used_percent,
remaining_percent,
window_seconds,
reset_after_seconds,
reset_at,
reset_at_formatted: reset_formatted.clone(),
reset_after_formatted: reset_formatted,
}
}
fn build_result(
provider_id: &str,
provider_name: &str,
ok: bool,
configured: bool,
usage: Option<ProviderUsage>,
error: Option<String>,
) -> ProviderResult {
ProviderResult {
provider_id: provider_id.to_string(),
provider_name: provider_name.to_string(),
ok,
configured,
error,
usage,
fetched_at: chrono::Utc::now().timestamp_millis(),
}
}
async fn load_auth_map() -> Result<serde_json::Map<String, Value>> {
let auth = opencode_auth::read_auth().await?;
auth.as_object()
.cloned()
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))
}
async fn has_antigravity_accounts() -> bool {
for path in antigravity_accounts_paths() {
if let Some(data) = read_json_file(&path).await {
if data
.get("accounts")
.and_then(|value| value.as_array())
.is_some_and(|accounts| !accounts.is_empty())
{
return true;
}
}
}
false
}
pub async fn list_configured_quota_providers() -> Result<Vec<String>> {
let auth = load_auth_map().await?;
let mut configured: HashSet<String> = HashSet::new();
let openai_auth = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"]));
if let Some(entry) = openai_auth {
if entry.access.is_some() || entry.token.is_some() {
configured.insert("openai".to_string());
}
}
let google_auth = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"]));
if let Some(entry) = google_auth {
if entry.access.is_some() || entry.token.is_some() || entry.refresh.is_some() {
configured.insert("google".to_string());
}
}
let zai_auth =
normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"]));
if let Some(entry) = zai_auth {
if entry.key.is_some() || entry.token.is_some() {
configured.insert("zai-coding-plan".to_string());
}
}
let github_copilot_auth =
normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"]));
if let Some(entry) = github_copilot_auth {
if entry.access.is_some() || entry.token.is_some() {
configured.insert("github-copilot".to_string());
}
}
if has_antigravity_accounts().await {
configured.insert("google".to_string());
}
Ok(configured.into_iter().collect())
}
fn parse_number(value: Option<&Value>) -> Option<f64> {
let value = value?;
value.as_f64().or_else(|| value.as_i64().map(|v| v as f64))
}
async fn fetch_openai_quota(client: &Client) -> Result<ProviderResult> {
let auth = load_auth_map().await?;
let entry = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"]));
let access_token = entry
.as_ref()
.and_then(|entry| entry.access.clone().or(entry.token.clone()));
let Some(access_token) = access_token else {
return Ok(build_result(
"openai",
"OpenAI",
false,
false,
None,
Some("Not configured".to_string()),
));
};
let response = client
.get("https://chatgpt.com/backend-api/wham/usage")
.bearer_auth(access_token)
.header("Content-Type", "application/json")
.send()
.await;
let response = match response {
Ok(resp) => resp,
Err(err) => {
return Ok(build_result(
"openai",
"OpenAI",
false,
true,
None,
Some(err.to_string()),
))
}
};
if !response.status().is_success() {
return Ok(build_result(
"openai",
"OpenAI",
false,
true,
None,
Some(format!("API error: {}", response.status().as_u16())),
));
}
let payload: Value = match response.json().await {
Ok(value) => value,
Err(err) => {
return Ok(build_result(
"openai",
"OpenAI",
false,
true,
None,
Some(err.to_string()),
))
}
};
let primary = payload
.get("rate_limit")
.and_then(|value| value.get("primary_window"));
let secondary = payload
.get("rate_limit")
.and_then(|value| value.get("secondary_window"));
let mut windows: HashMap<String, UsageWindow> = HashMap::new();
if let Some(primary) = primary {
let used_percent = parse_number(primary.get("used_percent"));
let window_seconds = primary
.get("limit_window_seconds")
.and_then(|value| value.as_i64());
let reset_at = primary
.get("reset_at")
.and_then(|value| value.as_i64())
.map(|value| value * 1000);
windows.insert(
"5h".to_string(),
to_usage_window(used_percent, window_seconds, reset_at),
);
}
if let Some(secondary) = secondary {
let used_percent = parse_number(secondary.get("used_percent"));
let window_seconds = secondary
.get("limit_window_seconds")
.and_then(|value| value.as_i64());
let reset_at = secondary
.get("reset_at")
.and_then(|value| value.as_i64())
.map(|value| value * 1000);
windows.insert(
"weekly".to_string(),
to_usage_window(used_percent, window_seconds, reset_at),
);
}
Ok(build_result(
"openai",
"OpenAI",
true,
true,
Some(ProviderUsage {
windows,
models: None,
}),
None,
))
}
async fn resolve_google_auth() -> Result<Option<GoogleAuth>> {
let auth = load_auth_map().await?;
let entry = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"]));
if let Some(entry) = entry {
let mut refresh = entry.refresh.clone();
let mut project_id = None;
if let Some(value) = entry.refresh.clone() {
if let Some((first, second)) = value.split_once('|') {
refresh = Some(first.to_string());
project_id = Some(second.to_string());
}
}
return Ok(Some(GoogleAuth {
access_token: entry.access.or(entry.token),
refresh_token: refresh,
expires: entry.expires,
project_id,
}));
}
for path in antigravity_accounts_paths() {
let data = match read_json_file(&path).await {
Some(data) => data,
None => continue,
};
let accounts = data.get("accounts").and_then(|value| value.as_array());
if let Some(accounts) = accounts {
if accounts.is_empty() {
continue;
}
let index = data
.get("activeIndex")
.and_then(|value| value.as_i64())
.unwrap_or(0)
.max(0) as usize;
let account = accounts.get(index).or_else(|| accounts.first());
if let Some(account) = account {
let refresh_token = account
.get("refreshToken")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
if refresh_token.is_none() {
continue;
}
let project_id = account
.get("projectId")
.and_then(|value| value.as_str())
.or_else(|| {
account
.get("managedProjectId")
.and_then(|value| value.as_str())
})
.map(|value| value.to_string());
return Ok(Some(GoogleAuth {
access_token: None,
refresh_token,
expires: None,
project_id,
}));
}
}
}
Ok(None)
}
async fn refresh_google_access_token(client: &Client, refresh_token: &str) -> Result<Option<String>> {
let body = format!(
"client_id={}&client_secret={}&refresh_token={}&grant_type=refresh_token",
urlencoding::encode(GOOGLE_CLIENT_ID),
urlencoding::encode(GOOGLE_CLIENT_SECRET),
urlencoding::encode(refresh_token)
);
let response = client
.post("https://oauth2.googleapis.com/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await;
let response = match response {
Ok(resp) => resp,
Err(err) => {
warn!("Failed to refresh Google token: {}", err);
return Ok(None);
}
};
if !response.status().is_success() {
return Ok(None);
}
let payload: Value = response.json().await.unwrap_or(Value::Null);
Ok(payload
.get("access_token")
.and_then(|value| value.as_str())
.map(|value| value.to_string()))
}
async fn fetch_google_models(client: &Client, access_token: &str, project_id: Option<&str>) -> Option<Value> {
let body = if let Some(project_id) = project_id {
serde_json::json!({ "project": project_id })
} else {
serde_json::json!({})
};
for endpoint in GOOGLE_ENDPOINTS {
let response = client
.post(format!("{}/v1internal:fetchAvailableModels", endpoint))
.header("Authorization", format!("Bearer {}", access_token))
.header("Content-Type", "application/json")
.header("User-Agent", GOOGLE_USER_AGENT)
.header("X-Goog-Api-Client", GOOGLE_API_CLIENT)
.header("Client-Metadata", GOOGLE_CLIENT_METADATA)
.json(&body)
.timeout(Duration::from_secs(15))
.send()
.await;
let response = match response {
Ok(resp) => resp,
Err(_) => continue,
};
if response.status().is_success() {
if let Ok(payload) = response.json::<Value>().await {
return Some(payload);
}
}
}
None
}
fn parse_reset_time(value: Option<&Value>) -> Option<i64> {
let value = value?;
if let Some(num) = value.as_i64() {
if num > 0 {
return Some(num);
}
}
if let Some(text) = value.as_str() {
if let Ok(parsed) = DateTime::parse_from_rfc3339(text) {
return Some(parsed.timestamp_millis());
}
}
None
}
async fn fetch_google_quota(client: &Client) -> Result<ProviderResult> {
let auth = resolve_google_auth().await?;
let Some(auth) = auth else {
return Ok(build_result(
"google",
"Google",
false,
false,
None,
Some("Not configured".to_string()),
));
};
let now = chrono::Utc::now().timestamp_millis();
let mut access_token = auth.access_token;
if access_token.is_none()
|| auth
.expires
.is_some_and(|expires| expires <= now)
{
let Some(refresh_token) = auth.refresh_token.as_ref() else {
return Ok(build_result(
"google",
"Google",
false,
true,
None,
Some("Missing refresh token".to_string()),
));
};
access_token = refresh_google_access_token(client, refresh_token).await?;
}
let Some(access_token) = access_token else {
return Ok(build_result(
"google",
"Google",
false,
true,
None,
Some("Failed to refresh OAuth token".to_string()),
));
};
let project_id = auth.project_id.unwrap_or_else(|| DEFAULT_PROJECT_ID.to_string());
let payload = fetch_google_models(client, &access_token, Some(project_id.as_str())).await;
let Some(payload) = payload else {
return Ok(build_result(
"google",
"Google",
false,
true,
None,
Some("Failed to fetch models".to_string()),
));
};
let mut models: HashMap<String, ProviderUsage> = HashMap::new();
if let Some(model_map) = payload.get("models").and_then(|value| value.as_object()) {
for (model_name, model_data) in model_map {
let remaining_fraction = parse_number(model_data.get("quotaInfo").and_then(|v| v.get("remainingFraction")));
let remaining_percent = remaining_fraction.map(|value| (value * 100.0).round());
let used_percent = remaining_percent.map(|value| (100.0 - value).max(0.0));
let reset_at = parse_reset_time(model_data.get("quotaInfo").and_then(|v| v.get("resetTime")));
let mut windows = HashMap::new();
windows.insert(
"5h".to_string(),
to_usage_window(used_percent, Some(GOOGLE_WINDOW_SECONDS), reset_at),
);
models.insert(
model_name.to_string(),
ProviderUsage {
windows,
models: None,
},
);
}
}
Ok(build_result(
"google",
"Google",
true,
true,
Some(ProviderUsage {
windows: HashMap::new(),
models: if models.is_empty() { None } else { Some(models) },
}),
None,
))
}
fn normalize_timestamp(value: Option<&Value>) -> Option<i64> {
let value = value?;
if let Some(num) = value.as_i64() {
if num < 1_000_000_000_000 {
return Some(num * 1000);
}
return Some(num);
}
None
}
fn resolve_window_seconds(limit: &Value) -> Option<i64> {
let number = limit.get("number").and_then(|value| value.as_i64())?;
let unit = limit.get("unit").and_then(|value| value.as_i64())?;
let unit_seconds = match unit {
3 => Some(3600),
_ => None,
}?;
Some(unit_seconds * number)
}
fn resolve_window_label(window_seconds: Option<i64>) -> String {
let Some(window_seconds) = window_seconds else {
return "tokens".to_string();
};
if window_seconds % 86400 == 0 {
let days = window_seconds / 86400;
if days == 7 {
return "weekly".to_string();
}
return format!("{}d", days);
}
if window_seconds % 3600 == 0 {
return format!("{}h", window_seconds / 3600);
}
format!("{}s", window_seconds)
}
async fn fetch_zai_quota(client: &Client) -> Result<ProviderResult> {
let auth = load_auth_map().await?;
let entry = normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"]));
let api_key = entry
.as_ref()
.and_then(|entry| entry.key.clone().or(entry.token.clone()));
let Some(api_key) = api_key else {
return Ok(build_result(
"zai-coding-plan",
"z.ai",
false,
false,
None,
Some("Not configured".to_string()),
));
};
let response = client
.get("https://api.z.ai/api/monitor/usage/quota/limit")
.bearer_auth(api_key)
.header("Content-Type", "application/json")
.send()
.await;
let response = match response {
Ok(resp) => resp,
Err(err) => {
return Ok(build_result(
"zai-coding-plan",
"z.ai",
false,
true,
None,
Some(err.to_string()),
))
}
};
if !response.status().is_success() {
return Ok(build_result(
"zai-coding-plan",
"z.ai",
false,
true,
None,
Some(format!("API error: {}", response.status().as_u16())),
));
}
let payload: Value = match response.json().await {
Ok(value) => value,
Err(err) => {
return Ok(build_result(
"zai-coding-plan",
"z.ai",
false,
true,
None,
Some(err.to_string()),
))
}
};
let limits = payload
.get("data")
.and_then(|value| value.get("limits"))
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
let tokens_limit = limits
.iter()
.find(|limit| limit.get("type").and_then(|value| value.as_str()) == Some("TOKENS_LIMIT"));
let mut windows = HashMap::new();
if let Some(limit) = tokens_limit {
let window_seconds = resolve_window_seconds(limit);
let window_label = resolve_window_label(window_seconds);
let reset_at = normalize_timestamp(limit.get("nextResetTime"));
let used_percent = parse_number(limit.get("percentage"));
windows.insert(
window_label,
to_usage_window(used_percent, window_seconds, reset_at),
);
}
Ok(build_result(
"zai-coding-plan",
"z.ai",
true,
true,
Some(ProviderUsage {
windows,
models: None,
}),
None,
))
}
async fn fetch_github_copilot_quota(client: &Client) -> Result<ProviderResult> {
let auth = load_auth_map().await?;
let entry = normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"]));
let access_token = entry
.as_ref()
.and_then(|entry| entry.access.clone().or(entry.token.clone()));
let Some(access_token) = access_token else {
return Ok(build_result(
"github-copilot",
"GitHub Copilot",
false,
false,
None,
Some("Not configured".to_string()),
));
};
let response = client
.get("https://api.github.com/copilot_internal/user")
.bearer_auth(access_token)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "OpenChamber")
.send()
.await;
let response = match response {
Ok(resp) => resp,
Err(err) => {
return Ok(build_result(
"github-copilot",
"GitHub Copilot",
false,
true,
None,
Some(err.to_string()),
))
}
};
if !response.status().is_success() {
return Ok(build_result(
"github-copilot",
"GitHub Copilot",
false,
true,
None,
Some(format!("API error: {}", response.status().as_u16())),
));
}
let payload: Value = match response.json().await {
Ok(value) => value,
Err(err) => {
return Ok(build_result(
"github-copilot",
"GitHub Copilot",
false,
true,
None,
Some(err.to_string()),
))
}
};
// Parse reset date
let mut reset_at: Option<i64> = None;
let reset_date_utc = payload
.get("quota_reset_date_utc")
.and_then(|v| v.as_str());
let reset_date = payload
.get("quota_reset_date")
.and_then(|v| v.as_str());
if let Some(date_str) = reset_date_utc {
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
reset_at = Some(dt.timestamp_millis());
}
} else if let Some(date_str) = reset_date {
// Use the date as UTC midnight
let full_date = format!("{}T00:00:00Z", date_str);
if let Ok(dt) = DateTime::parse_from_rfc3339(&full_date) {
reset_at = Some(dt.timestamp_millis());
}
}
let mut windows: HashMap<String, UsageWindow> = HashMap::new();
// Get premium_interactions snapshot
if let Some(snapshots) = payload.get("quota_snapshots") {
if let Some(premium) = snapshots.get("premium_interactions") {
let mut used_percent: Option<f64> = None;
let unlimited = premium
.get("unlimited")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !unlimited {
if let Some(percent_remaining) = premium.get("percent_remaining").and_then(|v| v.as_f64()) {
used_percent = Some(100.0 - percent_remaining);
} else if let Some(entitlement) = premium.get("entitlement").and_then(|v| v.as_f64()) {
if entitlement > 0.0 {
let remaining = premium
.get("remaining")
.and_then(|v| v.as_f64())
.or_else(|| premium.get("quota_remaining").and_then(|v| v.as_f64()));
if let Some(rem) = remaining {
used_percent = Some(((entitlement - rem) / entitlement) * 100.0);
}
}
}
}
windows.insert(
"premium_interactions".to_string(),
to_usage_window(used_percent, None, reset_at),
);
}
}
Ok(build_result(
"github-copilot",
"GitHub Copilot",
true,
true,
Some(ProviderUsage {
windows,
models: None,
}),
None,
))
}
pub async fn fetch_quota_for_provider(client: &Client, provider_id: &str) -> Result<ProviderResult> {
match provider_id {
"openai" => fetch_openai_quota(client).await,
"google" => fetch_google_quota(client).await,
"zai-coding-plan" => fetch_zai_quota(client).await,
"github-copilot" => fetch_github_copilot_quota(client).await,
_ => Ok(build_result(
provider_id,
provider_id,
false,
false,
None,
Some("Unsupported provider".to_string()),
)),
}
}
@@ -1,527 +0,0 @@
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
use anyhow::Result;
use futures_util::TryStreamExt;
use log::{debug, info, warn};
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use tokio_util::io::StreamReader;
use crate::path_utils::expand_tilde_path;
use crate::DesktopRuntime;
#[derive(Deserialize)]
struct EventEnvelope {
#[serde(rename = "type")]
event_type: String,
#[serde(default)]
properties: Value,
}
#[derive(Deserialize)]
struct MultiplexedEventEnvelope {
#[serde(default)]
directory: Option<String>,
payload: EventEnvelope,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ActivityPhase {
Idle,
Busy,
Cooldown,
}
#[derive(Clone, Debug)]
enum SseScope {
Global,
Directory(std::path::PathBuf),
}
pub fn spawn_session_activity_tracker(
app: AppHandle,
runtime: DesktopRuntime,
) -> tauri::async_runtime::JoinHandle<()> {
tauri::async_runtime::spawn(async move {
let client = Client::builder()
.timeout(Duration::from_secs(24 * 60 * 60))
.tcp_keepalive(Some(Duration::from_secs(30)))
.build()
.expect("failed to build reqwest client");
let mut shutdown_rx = runtime.subscribe_shutdown();
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
let cooldowns = Arc::new(Mutex::new(HashMap::<
String,
tauri::async_runtime::JoinHandle<()>,
>::new()));
loop {
tokio::select! {
_ = shutdown_rx.recv() => {
info!("[desktop:activity] Shutdown received, stopping SSE listener");
break;
}
_ = async {
// Reset stale phases to idle before connecting so UI doesn't stay stuck on "working" after wake.
reset_and_emit_all_phases(&app, phases.clone(), cooldowns.clone()).await;
if let Err(err) = run_once(&app, &runtime, &client, phases.clone(), cooldowns.clone()).await {
warn!("[desktop:activity] SSE loop error: {err:?}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
} => {}
}
}
})
}
async fn run_once(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) -> Result<()> {
let opencode = runtime.opencode_manager();
let port = match opencode.current_port() {
Some(port) => port,
None => {
warn!("[desktop:activity] OpenCode port unavailable; will retry");
tokio::time::sleep(Duration::from_secs(2)).await;
return Ok(());
}
};
let prefix = opencode.api_prefix();
let base = format!("http://127.0.0.1:{port}{prefix}");
let (response, scope) = connect_activity_sse(runtime, client, &base).await?;
use tokio::io::AsyncBufReadExt;
let stream = response
.bytes_stream()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
let mut reader = StreamReader::new(stream);
let mut buf = Vec::new();
let mut data_lines: Vec<String> = Vec::new();
loop {
buf.clear();
let bytes_read = match tokio::time::timeout(
Duration::from_secs(2),
reader.read_until(b'\n', &mut buf),
)
.await
{
Ok(Ok(n)) => n,
Ok(Err(err)) => {
warn!("[desktop:activity] Read error in SSE stream: {err:?}");
return Err(err.into());
}
Err(_) => {
// No data received recently; if we are connected to a directory-scoped stream and the working directory
// has changed, reconnect so activity tracking follows the new directory.
if let SseScope::Directory(connected_dir) = &scope {
if let Some(current_dir) =
resolve_project_directory_from_settings(runtime).await
{
if current_dir != *connected_dir {
debug!(
"[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})",
connected_dir, current_dir
);
return Ok(());
}
}
}
continue;
}
};
if bytes_read == 0 {
break;
}
let line = match std::str::from_utf8(&buf) {
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
Err(err) => {
warn!("[desktop:activity] Non-UTF8 SSE chunk: {err}");
continue;
}
};
if line.is_empty() {
if data_lines.is_empty() {
continue;
}
let raw = data_lines.join("\n");
data_lines.clear();
match parse_event_envelope(&raw) {
Ok((event, _directory)) => {
handle_event(app, event, phases.clone(), cooldowns.clone()).await
}
Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"),
};
continue;
}
if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start().to_string());
}
}
Ok(())
}
fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option<String>)> {
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
return Ok((event, None));
}
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
Ok((multiplexed.payload, multiplexed.directory))
}
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
let settings = runtime.settings().load().await.ok()?;
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
if let Some(path) = projects.iter().find_map(|entry| {
let id = entry.get("id").and_then(Value::as_str)?;
if id != active_id {
return None;
}
entry.get("path").and_then(Value::as_str)
}) {
return Some(expand_tilde_path(path));
}
}
}
settings
.get("lastDirectory")
.and_then(Value::as_str)
.map(expand_tilde_path)
}
async fn connect_activity_sse(
runtime: &DesktopRuntime,
client: &Client,
base: &str,
) -> Result<(reqwest::Response, SseScope)> {
let global_url = format!("{base}/global/event");
match try_connect_sse(client, &global_url, "[desktop:activity]").await {
Ok(response) => {
debug!("[desktop:activity] Using SSE endpoint: {global_url}");
return Ok((response, SseScope::Global));
}
Err(err) => {
debug!(
"[desktop:activity] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
);
}
}
let event_url = format!("{base}/event");
match try_connect_sse(client, &event_url, "[desktop:activity]").await {
Ok(response) => {
debug!("[desktop:activity] Using SSE endpoint: {event_url}");
return Ok((response, SseScope::Global));
}
Err(err) => {
debug!(
"[desktop:activity] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
);
}
}
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
anyhow::bail!("No project directory available for SSE fallback");
};
let directory = working_dir.to_string_lossy().to_string();
let mut parsed = reqwest::Url::parse(&event_url)?;
parsed
.query_pairs_mut()
.append_pair("directory", &directory);
let directory_url = parsed.to_string();
let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?;
debug!("[desktop:activity] Using directory-scoped SSE endpoint: {directory_url}");
Ok((response, SseScope::Directory(working_dir)))
}
async fn try_connect_sse(
client: &Client,
url: &str,
log_prefix: &str,
) -> Result<reqwest::Response> {
debug!("{log_prefix} Connecting SSE: {url}");
let response = client
.get(url)
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.send()
.await?;
debug!(
"{log_prefix} SSE response status={} headers={:?}",
response.status(),
response.headers()
);
if !response.status().is_success() {
anyhow::bail!("SSE connect failed with status {}", response.status());
}
Ok(response)
}
async fn handle_event(
app: &AppHandle,
event: EventEnvelope,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) {
match event.event_type.as_str() {
"session.status" => {
let session_id = event
.properties
.get("sessionID")
.and_then(Value::as_str)
.map(|s| s.to_string());
let status = event
.properties
.get("status")
.and_then(|s| s.get("type"))
.and_then(Value::as_str);
if let (Some(id), Some(status_type)) = (session_id, status) {
let phase = if status_type == "busy" || status_type == "retry" {
ActivityPhase::Busy
} else {
ActivityPhase::Idle
};
set_phase(app, &id, phase, phases.clone(), cooldowns.clone()).await;
}
}
"session.idle" => {
let session_id = event
.properties
.get("sessionID")
.and_then(Value::as_str)
.map(|s| s.to_string());
if let Some(id) = session_id {
set_phase(
app,
&id,
ActivityPhase::Idle,
phases.clone(),
cooldowns.clone(),
)
.await;
}
}
"message.updated" => {
if let Some(info) = event.properties.get("info") {
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
if role != "assistant" {
return;
}
let finish = info.get("finish").and_then(Value::as_str);
if finish != Some("stop") {
return;
}
let session_id = info
.get("sessionID")
.and_then(Value::as_str)
.map(|s| s.to_string());
if let Some(id) = session_id {
enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await;
}
}
}
"message.part.updated" => {
let Some(info) = event.properties.get("info") else {
return;
};
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
if role != "assistant" {
return;
}
let session_id = info
.get("sessionID")
.and_then(Value::as_str)
.map(|s| s.to_string());
let Some(id) = session_id else {
return;
};
// Mark session busy when we see assistant parts streaming (covers cases where session.status is missing).
if is_streaming_assistant_part(&event.properties) {
set_phase(
app,
&id,
ActivityPhase::Busy,
phases.clone(),
cooldowns.clone(),
)
.await;
}
// Derive cooldown from info.finish === 'stop' when present.
if has_finish_stop(info) {
enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await;
}
}
_ => {}
}
}
fn is_streaming_assistant_part(properties: &Value) -> bool {
let Some(part) = properties.get("part") else {
return false;
};
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
matches!(
part_type,
"step-start" | "text" | "tool" | "reasoning" | "file" | "patch"
)
}
fn has_finish_stop(info: &Value) -> bool {
info.get("finish").and_then(Value::as_str) == Some("stop")
}
async fn enter_cooldown_if_busy(
app: &AppHandle,
session_id: &str,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) {
let current = { phases.lock().await.get(session_id).cloned() };
if !matches!(current, Some(ActivityPhase::Busy)) {
return;
}
set_phase(
app,
session_id,
ActivityPhase::Cooldown,
phases.clone(),
cooldowns.clone(),
)
.await;
let app_clone = app.clone();
let phases_clone = phases.clone();
let cooldowns_clone = cooldowns.clone();
let id_clone = session_id.to_string();
let handle = tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let current = { phases_clone.lock().await.get(&id_clone).cloned() };
if matches!(current, Some(ActivityPhase::Cooldown)) {
set_phase(
&app_clone,
&id_clone,
ActivityPhase::Idle,
phases_clone,
cooldowns_clone,
)
.await;
}
});
let mut cd = cooldowns.lock().await;
if let Some(prev) = cd.remove(session_id) {
prev.abort();
}
cd.insert(session_id.to_string(), handle);
}
async fn set_phase(
app: &AppHandle,
session_id: &str,
phase: ActivityPhase,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) {
{
let mut map = phases.lock().await;
let current = map.get(session_id);
if current == Some(&phase) {
return;
}
map.insert(session_id.to_string(), phase.clone());
// Cancel cooldown timer when leaving cooldown
if !matches!(phase, ActivityPhase::Cooldown) {
if let Some(handle) = cooldowns.lock().await.remove(session_id) {
handle.abort();
}
}
}
// Emit to webview so UI stays in sync
let payload = serde_json::json!({
"sessionId": session_id,
"phase": match phase {
ActivityPhase::Idle => "idle",
ActivityPhase::Busy => "busy",
ActivityPhase::Cooldown => "cooldown",
}
});
let _ = app.emit("openchamber:session-activity", payload);
}
async fn reset_and_emit_all_phases(
app: &AppHandle,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) {
// Cancel any cooldown timers and set all phases to idle to avoid stale "busy" after wake.
{
let mut cd = cooldowns.lock().await;
for handle in cd.values() {
handle.abort();
}
cd.clear();
}
let snapshot = {
let mut guard = phases.lock().await;
for value in guard.values_mut() {
*value = ActivityPhase::Idle;
}
guard.clone()
};
if snapshot.is_empty() {
return;
}
for (session_id, phase) in snapshot {
let payload = serde_json::json!({
"sessionId": session_id,
"phase": match phase {
ActivityPhase::Idle => "idle",
ActivityPhase::Busy => "busy",
ActivityPhase::Cooldown => "cooldown",
}
});
let _ = app.emit("openchamber:session-activity", payload);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,167 +0,0 @@
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use tauri::{LogicalPosition, LogicalSize, WebviewWindow, Window};
use tokio::fs as async_fs;
const WINDOW_STATE_FILE: &str = "window-state.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowState {
pub width: f64,
pub height: f64,
pub x: f64,
pub y: f64,
pub is_maximized: bool,
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: 1280.0,
height: 800.0,
x: 0.0,
y: 0.0,
is_maximized: false,
}
}
}
#[derive(Serialize, Deserialize)]
struct WindowStateFile {
#[serde(rename = "windowState")]
pub window_state: WindowState,
}
#[derive(Clone)]
pub struct WindowStateManager {
inner: Arc<Mutex<WindowState>>,
}
impl WindowStateManager {
pub fn new(initial: WindowState) -> Self {
Self {
inner: Arc::new(Mutex::new(initial)),
}
}
pub fn snapshot(&self) -> WindowState {
self.inner.lock().expect("window state poisoned").clone()
}
pub fn update_position(&self, x: f64, y: f64, is_maximized: bool) {
if is_maximized {
return;
}
if let Ok(mut state) = self.inner.lock() {
if !state.is_maximized {
state.x = x;
state.y = y;
}
}
}
pub fn update_size(&self, width: f64, height: f64, is_maximized: bool) {
if let Ok(mut state) = self.inner.lock() {
if !is_maximized {
state.width = width;
state.height = height;
}
state.is_maximized = is_maximized;
}
}
}
fn state_file_path() -> Result<PathBuf> {
let mut path = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?;
path.push(".config");
path.push("openchamber");
path.push(WINDOW_STATE_FILE);
Ok(path)
}
pub async fn load_window_state() -> Result<Option<WindowState>> {
let path = state_file_path()?;
match async_fs::read(&path).await {
Ok(bytes) => {
let file: WindowStateFile = serde_json::from_slice(&bytes)?;
Ok(Some(file.window_state))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
pub async fn save_window_state(state: &WindowState) -> Result<()> {
let path = state_file_path()?;
if let Some(parent) = path.parent() {
async_fs::create_dir_all(parent).await?;
}
let payload = WindowStateFile {
window_state: state.clone(),
};
let data = serde_json::to_vec_pretty(&payload)?;
async_fs::write(&path, data).await?;
Ok(())
}
pub fn apply_window_state(window: &WebviewWindow, state: &WindowState) -> Result<()> {
let mut normalized = state.clone();
clamp_to_visible_region(window, &mut normalized);
if normalized.width > 0.0 && normalized.height > 0.0 {
let _ = window.set_size(LogicalSize::new(normalized.width, normalized.height));
}
let _ = window.set_position(LogicalPosition::new(normalized.x, normalized.y));
if state.is_maximized {
let _ = window.maximize();
} else {
let _ = window.unmaximize();
}
Ok(())
}
pub async fn persist_window_state(window: &Window, manager: &WindowStateManager) -> Result<()> {
let mut snapshot = manager.snapshot();
let is_maximized = window.is_maximized().unwrap_or(snapshot.is_maximized);
snapshot.is_maximized = is_maximized;
if !is_maximized {
let scale_factor = window.scale_factor().unwrap_or(1.0);
if let Ok(size) = window.outer_size() {
let logical: LogicalSize<f64> = size.to_logical(scale_factor);
snapshot.width = logical.width.max(200.0);
snapshot.height = logical.height.max(200.0);
}
if let Ok(position) = window.outer_position() {
let logical: LogicalPosition<f64> = position.to_logical(scale_factor);
snapshot.x = logical.x;
snapshot.y = logical.y;
}
}
save_window_state(&snapshot).await
}
fn clamp_to_visible_region(window: &WebviewWindow, state: &mut WindowState) {
let monitor = match window.current_monitor() {
Ok(Some(monitor)) => monitor,
_ => return,
};
let scale_factor = monitor.scale_factor();
let monitor_size: LogicalSize<f64> = monitor.size().to_logical(scale_factor);
let monitor_position: LogicalPosition<f64> = monitor.position().to_logical(scale_factor);
state.width = state.width.clamp(400.0, monitor_size.width);
state.height = state.height.clamp(300.0, monitor_size.height);
let max_x = monitor_position.x + (monitor_size.width - state.width).max(0.0);
let max_y = monitor_position.y + (monitor_size.height - state.height).max(0.0);
state.x = state.x.clamp(monitor_position.x, max_x);
state.y = state.y.clamp(monitor_position.y, max_y);
}
+8 -4
View File
@@ -4,15 +4,16 @@
"version": "1.6.3",
"identifier": "ai.opencode.openchamber",
"build": {
"beforeDevCommand": "bun run dev",
"beforeBuildCommand": "bun run build",
"devUrl": "http://127.0.0.1:1421",
"frontendDist": "../dist"
"beforeDevCommand": "node ./scripts/dev-web-server.mjs",
"beforeBuildCommand": "bun run build:sidecar",
"devUrl": "http://127.0.0.1:3001",
"frontendDist": "../noop-dist"
},
"app": {
"windows": [
{
"label": "main",
"create": false,
"title": "OpenChamber",
"transparent": false,
"width": 1280,
@@ -33,10 +34,13 @@
"security": {
"csp": null
},
"withGlobalTauri": true,
"macOSPrivateApi": true
},
"bundle": {
"active": true,
"externalBin": ["sidecars/openchamber-server"],
"resources": ["resources/web-dist/**/*"],
"icon": [
"icons/icon.icns",
"icons/icon.png"
-32
View File
@@ -1,32 +0,0 @@
import type { DiagnosticsAPI } from '@openchamber/ui/lib/api/types';
type LogResponse = {
fileName?: string;
content?: string;
};
const normalizePayload = (payload: LogResponse): { fileName: string; content: string } => ({
fileName: typeof payload.fileName === 'string' && payload.fileName.trim().length > 0 ? payload.fileName : 'openchamber.log',
content: typeof payload.content === 'string' ? payload.content : '',
});
export const createDesktopDiagnosticsAPI = (): DiagnosticsAPI => ({
async downloadLogs() {
try {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<LogResponse>('fetch_desktop_logs', {}, {
timeout: 10000,
onCancel: () => {
console.warn('[DiagnosticsAPI] Fetch desktop logs operation timed out');
}
});
return normalizePayload(result ?? {});
} catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error('Failed to download desktop logs');
}
},
});
-280
View File
@@ -1,280 +0,0 @@
import { safeInvoke } from '../lib/tauriCallbackManager';
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI, ListDirectoryOptions } from '@openchamber/ui/lib/api/types';
type ReadFileBinaryResponse = {
dataUrl: string;
path: string;
};
type ListDirectoryResponse = DirectoryListResult & {
path?: string;
entries: Array<
DirectoryListResult['entries'][number] & {
isFile?: boolean;
isSymbolicLink?: boolean;
}
>;
};
type SearchFilesResponse = {
root: string;
count: number;
files: Array<{
name: string;
path: string;
relativePath: string;
extension?: string;
}>;
};
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
const normalizeDirectoryPayload = (result: ListDirectoryResponse): DirectoryListResult => ({
directory: normalizePath(result.directory || result.path || ''),
entries: Array.isArray(result.entries)
? result.entries.map((entry) => ({
name: entry.name || '',
path: normalizePath(entry.path || ''),
isDirectory: entry.isDirectory ?? false,
size: entry.size ?? 0,
modified: (entry as { modified?: string }).modified ?? new Date().toISOString(),
}))
: [],
});
export const createDesktopFilesAPI = (): FilesAPI => ({
async listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult> {
try {
const result = await safeInvoke<ListDirectoryResponse>('list_directory', {
path: normalizePath(path),
// NOTE: pass both casings; Tauri arg casing differs across commands
respectGitignore: options?.respectGitignore ?? false,
respect_gitignore: options?.respectGitignore ?? false,
}, {
timeout: 10000,
onCancel: () => {
console.warn('[FilesAPI] List directory operation timed out');
}
});
return normalizeDirectoryPayload(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to list directory');
}
},
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
try {
const normalizedDirectory =
typeof payload.directory === 'string' && payload.directory.length > 0
? normalizePath(payload.directory)
: undefined;
const result = await safeInvoke<SearchFilesResponse>('search_files', {
directory: normalizedDirectory,
query: payload.query,
// NOTE: pass both casings; Tauri arg casing differs across commands
maxResults: payload.maxResults || 100,
includeHidden: payload.includeHidden ?? false,
respectGitignore: payload.respectGitignore ?? true,
max_results: payload.maxResults || 100,
include_hidden: payload.includeHidden ?? false,
respect_gitignore: payload.respectGitignore ?? true,
}, {
timeout: 15000,
onCancel: () => {
console.warn('[FilesAPI] Search files operation timed out');
}
});
if (!result || !Array.isArray(result.files)) {
return [];
}
return result.files.map<FileSearchResult>((file) => ({
path: normalizePath(file.path),
preview: file.relativePath ? [normalizePath(file.relativePath)] : undefined,
}));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to search files');
}
},
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
try {
const normalizedPath = normalizePath(path);
const result = await safeInvoke<{ success: boolean; path: string }>('create_directory', {
path: normalizedPath
}, {
timeout: 5000,
onCancel: () => {
console.warn('[FilesAPI] Create directory operation timed out');
}
});
return {
success: Boolean(result?.success),
path: result?.path ? normalizePath(result.path) : normalizedPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to create directory');
}
},
async readFile(path: string): Promise<{ content: string; path: string }> {
try {
const normalizedPath = normalizePath(path);
const result = await safeInvoke<{ content: string; path: string }>('read_file', {
path: normalizedPath
}, {
timeout: 10000,
onCancel: () => {
console.warn('[FilesAPI] Read file operation timed out');
}
});
return {
content: result?.content ?? '',
path: result?.path ? normalizePath(result.path) : normalizedPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to read file');
}
},
async readFileBinary(path: string): Promise<ReadFileBinaryResponse> {
try {
const normalizedPath = normalizePath(path);
const result = await safeInvoke<ReadFileBinaryResponse>('read_file_binary', {
path: normalizedPath
}, {
timeout: 15000,
onCancel: () => {
console.warn('[FilesAPI] Read binary file operation timed out');
}
});
return {
dataUrl: result?.dataUrl ?? '',
path: result?.path ? normalizePath(result.path) : normalizedPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to read file');
}
},
async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> {
try {
const normalizedPath = normalizePath(path);
const result = await safeInvoke<{ success: boolean; path: string }>('write_file', {
path: normalizedPath,
content
}, {
timeout: 10000,
onCancel: () => {
console.warn('[FilesAPI] Write file operation timed out');
}
});
return {
success: Boolean(result?.success),
path: result?.path ? normalizePath(result.path) : normalizedPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to write file');
}
},
async delete(path: string): Promise<{ success: boolean }> {
try {
const normalizedPath = normalizePath(path);
const result = await safeInvoke<{ success: boolean }>('delete_path', {
path: normalizedPath,
}, {
timeout: 10000,
onCancel: () => {
console.warn('[FilesAPI] Delete operation timed out');
}
});
return {
success: Boolean(result?.success),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to delete path');
}
},
async rename(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }> {
try {
const result = await safeInvoke<{ success: boolean; path: string }>('rename_path', {
oldPath: normalizePath(oldPath),
newPath: normalizePath(newPath),
}, {
timeout: 10000,
onCancel: () => {
console.warn('[FilesAPI] Rename operation timed out');
}
});
return {
success: Boolean(result?.success),
path: result?.path ? normalizePath(result.path) : normalizePath(newPath),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to rename path');
}
},
async execCommands(commands: string[], cwd: string): Promise<{
success: boolean;
results: Array<{
command: string;
success: boolean;
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}>;
}> {
try {
const normalizedCwd = normalizePath(cwd);
const result = await safeInvoke<{
success: boolean;
results: Array<{
command: string;
success: boolean;
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}>;
}>('exec_commands', {
commands,
cwd: normalizedCwd
}, {
timeout: 120000, // 2 minutes for command execution
onCancel: () => {
console.warn('[FilesAPI] Exec commands operation timed out');
}
});
return {
success: Boolean(result?.success),
results: result?.results ?? [],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(message || 'Failed to execute commands');
}
},
});
-286
View File
@@ -1,286 +0,0 @@
import { safeInvoke } from '../lib/tauriCallbackManager';
import type {
GitAPI,
GitStatus,
GitDiffResponse,
GetGitDiffOptions,
GitFileDiffResponse,
GitBranch,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GeneratedCommitMessage,
GeneratedPullRequestDescription,
GitWorktreeInfo,
GitAddWorktreePayload,
GitRemoveWorktreePayload,
CreateGitCommitOptions,
GitCommitResult,
GitPushResult,
GitPullResult,
GitLogOptions,
GitLogResponse,
GitCommitFilesResponse,
GitIdentitySummary,
GitIdentityProfile,
DiscoveredGitCredential
} from '@openchamber/ui/lib/api/types';
async function safeGitInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await safeInvoke<T>(command, args, {
timeout: 120000,
onCancel: () => {
console.warn(`[GitAPI] Git operation ${command} did not complete within 120s; it may still be running.`);
}
});
} catch (error) {
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
throw new Error(message);
}
}
export const createDesktopGitAPI = (): GitAPI => ({
async checkIsGitRepository(directory: string): Promise<boolean> {
return safeGitInvoke<boolean>('check_is_git_repository', { directory });
},
async getGitStatus(directory: string): Promise<GitStatus> {
return safeGitInvoke<GitStatus>('get_git_status', { directory });
},
async getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
const diff = await safeGitInvoke<string>('get_git_diff', {
directory,
pathStr: options.path,
staged: options.staged,
contextLines: options.contextLines
});
return { diff };
},
async getGitFileDiff(directory: string, options: { path: string }): Promise<GitFileDiffResponse> {
const [original, modified] = await safeGitInvoke<[string, string]>('get_git_file_diff', {
directory,
pathStr: options.path,
});
return {
original: original ?? '',
modified: modified ?? '',
path: options.path,
};
},
async revertGitFile(directory: string, filePath: string): Promise<void> {
return safeGitInvoke<void>('revert_git_file', { directory, filePath });
},
async isLinkedWorktree(directory: string): Promise<boolean> {
return safeGitInvoke<boolean>('is_linked_worktree', { directory });
},
async getGitBranches(directory: string): Promise<GitBranch> {
return safeGitInvoke<GitBranch>('get_git_branches', { directory });
},
async deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> {
await safeGitInvoke<void>('delete_git_branch', {
directory,
branch: payload.branch,
force: payload.force
});
return { success: true };
},
async deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
await safeGitInvoke<void>('delete_remote_branch', {
directory,
branch: payload.branch,
remote: payload.remote
});
return { success: true };
},
async generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }> {
const response = await safeGitInvoke<{ message: GeneratedCommitMessage }>('generate_commit_message', {
directory,
files
});
return response;
},
async generatePullRequestDescription(
directory: string,
payload: { base: string; head: string; context?: string }
): Promise<GeneratedPullRequestDescription> {
const params: { directory: string; base: string; head: string; context?: string } = {
directory,
base: payload.base,
head: payload.head,
};
if (payload.context?.trim()) {
params.context = payload.context.trim();
}
return safeGitInvoke<GeneratedPullRequestDescription>('generate_pr_description', params);
},
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
return safeGitInvoke<GitWorktreeInfo[]>('list_git_worktrees', { directory });
},
async addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }> {
await safeGitInvoke<void>('add_git_worktree', {
directory,
pathStr: payload.path,
branch: payload.branch,
createBranch: payload.createBranch,
startPoint: payload.startPoint,
});
return { success: true, path: payload.path, branch: payload.branch };
},
async removeGitWorktree(directory: string, payload: GitRemoveWorktreePayload): Promise<{ success: boolean }> {
await safeGitInvoke<void>('remove_git_worktree', {
directory,
pathStr: payload.path,
force: payload.force
});
return { success: true };
},
async ensureOpenChamberIgnored(directory: string): Promise<void> {
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
return safeGitInvoke<void>('ensure_openchamber_ignored', { directory });
},
async createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult> {
return safeGitInvoke<GitCommitResult>('create_git_commit', {
directory,
message,
addAll: options?.addAll,
files: options?.files
});
},
async gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult> {
return safeGitInvoke<GitPushResult>('git_push', {
directory,
remote: options?.remote,
branch: options?.branch,
options: options?.options
});
},
async gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult> {
return safeGitInvoke<GitPullResult>('git_pull', {
directory,
remote: options?.remote,
branch: options?.branch
});
},
async gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }> {
await safeGitInvoke<void>('git_fetch', {
directory,
remote: options?.remote
});
return { success: true };
},
async checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
await safeGitInvoke<void>('checkout_branch', { directory, branch });
return { success: true, branch };
},
async createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }> {
await safeGitInvoke<void>('create_branch', {
directory,
name,
startPoint
});
return { success: true, branch: name };
},
async renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> {
await safeGitInvoke<void>('rename_branch', {
directory,
oldName,
newName
});
return { success: true, branch: newName };
},
async getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse> {
return safeGitInvoke<GitLogResponse>('get_git_log', {
directory,
maxCount: options?.maxCount,
from: options?.from,
to: options?.to,
file: options?.file
});
},
async getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse> {
return safeGitInvoke<GitCommitFilesResponse>('get_commit_files', {
directory,
hash
});
},
async getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null> {
try {
return await safeGitInvoke<GitIdentitySummary>('get_current_git_identity', { directory });
} catch {
return null;
}
},
async hasLocalIdentity(directory: string): Promise<boolean> {
try {
return await safeGitInvoke<boolean>('has_local_identity', { directory });
} catch {
return false;
}
},
async setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }> {
const profile = await safeGitInvoke<GitIdentityProfile>('set_git_identity', { directory, profileId });
return { success: true, profile };
},
async getGitIdentities(): Promise<GitIdentityProfile[]> {
return safeGitInvoke<GitIdentityProfile[]>('get_git_identities');
},
async createGitIdentity(profile: GitIdentityProfile): Promise<GitIdentityProfile> {
return safeGitInvoke<GitIdentityProfile>('create_git_identity', { profile });
},
async updateGitIdentity(id: string, updates: GitIdentityProfile): Promise<GitIdentityProfile> {
return safeGitInvoke<GitIdentityProfile>('update_git_identity', { id, updates });
},
async deleteGitIdentity(id: string): Promise<void> {
return safeGitInvoke<void>('delete_git_identity', { id });
},
async discoverGitCredentials(): Promise<DiscoveredGitCredential[]> {
return safeGitInvoke<DiscoveredGitCredential[]>('discover_git_credentials');
},
async getGlobalGitIdentity(): Promise<GitIdentitySummary | null> {
try {
return await safeGitInvoke<GitIdentitySummary>('get_global_git_identity');
} catch {
return null;
}
},
async getRemoteUrl(directory: string, remote?: string): Promise<string | null> {
try {
return await safeGitInvoke<string | null>('get_remote_url', { directory, remote });
} catch {
return null;
}
},
});
-105
View File
@@ -1,105 +0,0 @@
import type {
GitHubAPI,
GitHubAuthStatus,
GitHubIssueCommentsResult,
GitHubIssueGetResult,
GitHubIssuesListResult,
GitHubPullRequestContextResult,
GitHubPullRequestsListResult,
GitHubPullRequest,
GitHubPullRequestCreateInput,
GitHubPullRequestMergeInput,
GitHubPullRequestMergeResult,
GitHubPullRequestReadyInput,
GitHubPullRequestReadyResult,
GitHubPullRequestStatus,
GitHubDeviceFlowComplete,
GitHubDeviceFlowStart,
GitHubUserSummary,
} from '@openchamber/ui/lib/api/types';
export const createDesktopGitHubAPI = (): GitHubAPI => ({
async authStatus(): Promise<GitHubAuthStatus> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubAuthStatus>('github_auth_status', {}, { timeout: 8000 });
},
async authStart(): Promise<GitHubDeviceFlowStart> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubDeviceFlowStart>('github_auth_start', {}, { timeout: 8000 });
},
async authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubDeviceFlowComplete>('github_auth_complete', { deviceCode }, { timeout: 12000 });
},
async authDisconnect(): Promise<{ removed: boolean }> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<{ removed: boolean }>('github_auth_disconnect', {}, { timeout: 8000 });
return { removed: Boolean(result?.removed) };
},
async authActivate(accountId: string): Promise<GitHubAuthStatus> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubAuthStatus>('github_auth_activate', { accountId }, { timeout: 8000 });
},
async me(): Promise<GitHubUserSummary> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubUserSummary>('github_me', {}, { timeout: 8000 });
},
async prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestStatus>('github_pr_status', { directory, branch }, { timeout: 12000 });
},
async prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequest>('github_pr_create', payload, { timeout: 20000 });
},
async prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestMergeResult>('github_pr_merge', payload, { timeout: 20000 });
},
async prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestReadyResult>('github_pr_ready', payload, { timeout: 20000 });
},
async issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubIssuesListResult>('github_issues_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 });
},
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubIssueGetResult>('github_issue_get', { directory, number }, { timeout: 20000 });
},
async issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubIssueCommentsResult>('github_issue_comments', { directory, number }, { timeout: 20000 });
},
async prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestsListResult>('github_prs_list', { directory, page: options?.page ?? 1 }, { timeout: 20000 });
},
async prContext(
directory: string,
number: number,
options?: { includeDiff?: boolean; includeCheckDetails?: boolean }
): Promise<GitHubPullRequestContextResult> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubPullRequestContextResult>(
'github_pr_context',
{ directory, number, includeDiff: Boolean(options?.includeDiff), includeCheckDetails: Boolean(options?.includeCheckDetails) },
{ timeout: 30000 }
);
},
});
-59
View File
@@ -1,59 +0,0 @@
import type { RuntimeAPIs, TerminalHandlers } from '@openchamber/ui/lib/api/types';
import { createDesktopTerminalAPI } from './terminal';
import { createDesktopGitAPI } from './git';
import { createDesktopFilesAPI } from './files';
import { createDesktopSettingsAPI } from './settings';
import { createDesktopPermissionsAPI } from './permissions';
import { createDesktopDiagnosticsAPI } from './diagnostics';
import { createDesktopNotificationsAPI } from './notifications';
import { createDesktopToolsAPI } from './tools';
import { createDesktopGitHubAPI } from './github';
const activeTerminalConnections = new Set<string>();
export const createDesktopAPIs = (): RuntimeAPIs & { cleanup?: () => void } => {
const terminalAPI = createDesktopTerminalAPI();
const originalConnect = terminalAPI.connect.bind(terminalAPI);
const wrappedTerminalAPI = {
...terminalAPI,
connect: (sessionId: string, handlers: TerminalHandlers) => {
activeTerminalConnections.add(sessionId);
const connection = originalConnect(sessionId, handlers);
const originalClose = connection.close;
return {
...connection,
close: () => {
activeTerminalConnections.delete(sessionId);
originalClose();
},
};
},
};
return {
runtime: { platform: 'desktop', isDesktop: true, isVSCode: false, label: 'tauri-bootstrap' },
terminal: wrappedTerminalAPI,
git: createDesktopGitAPI(),
files: createDesktopFilesAPI(),
settings: createDesktopSettingsAPI(),
permissions: createDesktopPermissionsAPI(),
notifications: createDesktopNotificationsAPI(),
github: createDesktopGitHubAPI(),
diagnostics: createDesktopDiagnosticsAPI(),
tools: createDesktopToolsAPI(),
cleanup: () => {
console.info('[DesktopAPIs] Performing cleanup...');
const activeConnections = Array.from(activeTerminalConnections);
activeConnections.forEach(sessionId => {
console.info(`[DesktopAPIs] Closing terminal session: ${sessionId}`);
activeTerminalConnections.delete(sessionId);
});
console.info(`[DesktopAPIs] Cleanup completed, closed ${activeConnections.length} terminal connections`);
},
};
};
-58
View File
@@ -1,58 +0,0 @@
import type { NotificationsAPI, NotificationPayload } from '@openchamber/ui/lib/api/types';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { safeInvoke } from '../lib/tauriCallbackManager';
export const requestInitialNotificationPermission = async (): Promise<void> => {
try {
if (typeof window !== 'undefined' && 'Notification' in window) {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('[notifications] Notification permission not granted');
}
}
} catch (error) {
console.error('[notifications] Failed to request permission:', error);
}
};
export const createDesktopNotificationsAPI = (): NotificationsAPI => ({
async notifyAgentCompletion(payload?: NotificationPayload): Promise<boolean> {
try {
let granted = await isPermissionGranted();
if (!granted) {
const permission = await requestPermission();
granted = permission === 'granted';
}
if (!granted) {
console.warn('[notifications] Cannot send notification: Permission denied');
return false;
}
await safeInvoke(
'desktop_notify',
{ payload },
{
timeout: 5000,
onCancel: () => {
console.warn('[NotificationsAPI] Notify operation timed out');
},
},
);
return true;
} catch (error) {
console.error('[notifications] Failed to send notification:', error);
return false;
}
},
async canNotify(): Promise<boolean> {
try {
return await isPermissionGranted();
} catch (error) {
console.warn('[notifications] Failed to check notification permission:', error);
return false;
}
}
});
-49
View File
@@ -1,49 +0,0 @@
import type { DirectoryPermissionRequest, DirectoryPermissionResult, PermissionsAPI, StartAccessingResult } from '@openchamber/ui/lib/api/types';
export const createDesktopPermissionsAPI = (): PermissionsAPI => ({
async requestDirectoryAccess(request: DirectoryPermissionRequest): Promise<DirectoryPermissionResult> {
try {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<DirectoryPermissionResult>('request_directory_access', { request }, {
timeout: 30000,
onCancel: () => {
console.warn('[PermissionsAPI] Request directory access operation timed out');
}
});
return result;
} catch (error) {
console.error('[desktop] Error requesting directory access:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
async startAccessingDirectory(path: string): Promise<StartAccessingResult> {
try {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<StartAccessingResult>('start_accessing_directory', { path }, {
timeout: 10000,
onCancel: () => {
console.warn('[PermissionsAPI] Start accessing directory operation timed out');
}
});
return result;
} catch (error) {
console.error('[desktop] Error starting directory access:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
async stopAccessingDirectory(path: string): Promise<StartAccessingResult> {
try {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<StartAccessingResult>('stop_accessing_directory', { path }, {
timeout: 5000,
onCancel: () => {
console.warn('[PermissionsAPI] Stop accessing directory operation timed out');
}
});
return result;
} catch (error) {
console.error('[desktop] Error stopping directory access:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
});
-58
View File
@@ -1,58 +0,0 @@
import type { SettingsAPI, SettingsLoadResult, SettingsPayload } from '@openchamber/ui/lib/api/types';
const sanitizePayload = (data: unknown): SettingsPayload => {
if (!data || typeof data !== 'object') {
return {};
}
return data as SettingsPayload;
};
export const createDesktopSettingsAPI = (): SettingsAPI => ({
async load(): Promise<SettingsLoadResult> {
try {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<{ settings: unknown; source: 'desktop' | 'web' }>('load_settings', {}, {
timeout: 5000,
onCancel: () => {
console.warn('[SettingsAPI] Load settings operation timed out');
}
});
return {
settings: sanitizePayload(result.settings),
source: result.source,
};
} catch (error) {
throw new Error(`Failed to load settings: ${error instanceof Error ? error.message : String(error)}`);
}
},
async save(changes: Partial<SettingsPayload>): Promise<SettingsPayload> {
try {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<unknown>('save_settings', { changes }, {
timeout: 5000,
onCancel: () => {
console.warn('[SettingsAPI] Save settings operation timed out');
}
});
return sanitizePayload(result);
} catch (error) {
throw new Error(`Failed to save settings: ${error instanceof Error ? error.message : String(error)}`);
}
},
async restartOpenCode(): Promise<{ restarted: boolean }> {
try {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
const result = await safeInvoke<{ restarted: boolean }>('restart_opencode', {}, {
timeout: 10000,
onCancel: () => {
console.warn('[SettingsAPI] Restart OpenCode operation timed out');
}
});
return { restarted: result.restarted };
} catch (error) {
throw new Error(`Failed to restart OpenCode: ${error instanceof Error ? error.message : String(error)}`);
}
},
});
-168
View File
@@ -1,168 +0,0 @@
import { safeInvoke, safeListen } from '../lib/tauriCallbackManager';
import type {
TerminalAPI,
TerminalHandlers,
CreateTerminalOptions,
ResizeTerminalPayload,
TerminalSession,
TerminalStreamEvent
} from '@openchamber/ui/lib/api/types';
async function safeTerminalInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
try {
return await safeInvoke<T>(command, args, {
timeout: 10000,
onCancel: () => {
console.warn(`[TerminalAPI] Command ${command} timed out`);
}
});
} catch (error) {
const message = typeof error === 'string' ? error : (error as Error).message || 'Unknown error';
throw new Error(message);
}
}
export const createDesktopTerminalAPI = (): TerminalAPI => ({
async createSession(options: CreateTerminalOptions): Promise<TerminalSession> {
const cols = options.cols ?? 80;
const rows = options.rows ?? 24;
const res = await safeTerminalInvoke<{ session_id: string }>('create_terminal_session', {
payload: {
cols,
rows,
cwd: options.cwd
}
});
return {
sessionId: res.session_id,
cols,
rows
};
},
connect(sessionId: string, handlers: TerminalHandlers) {
let unlistenFn: (() => void) | undefined;
let cancelled = false;
let isConnected = false;
const stopListening = () => {
if (unlistenFn) {
unlistenFn();
unlistenFn = undefined;
isConnected = false;
}
};
const startListening = async () => {
try {
const unlisten = await safeListen<TerminalStreamEvent>(
`terminal://${sessionId}`,
(event) => {
if (cancelled) {
return;
}
handlers.onEvent(event.payload);
if (event.payload?.type === 'exit') {
stopListening();
}
},
{
// Terminal streams are long-lived; never auto-expire this listener.
timeout: 0,
}
);
if (cancelled) {
unlisten();
return;
}
unlistenFn = unlisten;
isConnected = true;
handlers.onEvent({ type: 'connected' });
} catch (err) {
console.error('Failed to listen to terminal events:', err);
if (!cancelled) {
handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
}
}
};
startListening();
return {
close: () => {
cancelled = true;
stopListening();
},
isConnected: () => isConnected,
};
},
async sendInput(sessionId: string, input: string): Promise<void> {
await safeTerminalInvoke('send_terminal_input', {
sessionId,
session_id: sessionId,
data: input,
});
},
async resize(payload: ResizeTerminalPayload): Promise<void> {
await safeTerminalInvoke('resize_terminal', {
sessionId: payload.sessionId,
session_id: payload.sessionId,
cols: payload.cols,
rows: payload.rows,
});
},
async close(sessionId: string): Promise<void> {
await safeTerminalInvoke('close_terminal', {
sessionId,
session_id: sessionId,
});
},
async restartSession(
currentSessionId: string,
options: CreateTerminalOptions
): Promise<TerminalSession> {
const cols = options.cols ?? 80;
const rows = options.rows ?? 24;
const res = await safeTerminalInvoke<{ session_id: string }>(
'restart_terminal_session',
{
payload: {
session_id: currentSessionId,
cols,
rows,
cwd: options.cwd ?? '',
},
}
);
return {
sessionId: res.session_id,
cols,
rows,
};
},
async forceKill(options: {
sessionId?: string;
cwd?: string;
}): Promise<void> {
await safeTerminalInvoke('force_kill_terminal', {
payload: {
session_id: options.sessionId ?? null,
cwd: options.cwd ?? null,
},
});
},
});
-22
View File
@@ -1,22 +0,0 @@
import type { ToolsAPI } from '@openchamber/ui/lib/api/types';
export const createDesktopToolsAPI = (): ToolsAPI => ({
async getAvailableTools(): Promise<string[]> {
const response = await fetch('/api/experimental/tool/ids');
if (!response.ok) {
throw new Error(`Tools API returned ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error('Tools API returned invalid data format');
}
return data
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool !== 'invalid')
.sort();
},
});
-145
View File
@@ -1,145 +0,0 @@
export interface UpdateInfo {
available: boolean;
version?: string;
currentVersion: string;
body?: string;
date?: string;
}
export interface UpdateProgress {
downloaded: number;
total?: number;
}
interface Update {
version: string;
body?: string;
date?: string;
downloadAndInstall: (
onEvent?: (event: DownloadEvent) => void
) => Promise<void>;
}
type DownloadEvent =
| { event: 'Started'; data: { contentLength?: number } }
| { event: 'Progress'; data: { chunkLength: number } }
| { event: 'Finished' };
let cachedUpdate: Update | null = null;
export async function checkForUpdates(): Promise<UpdateInfo> {
try {
const { check } = await import('@tauri-apps/plugin-updater');
const [update, currentVersion] = await Promise.all([
check(),
getCurrentVersion(),
]);
cachedUpdate = update;
if (!update) {
return {
available: false,
currentVersion,
};
}
const changelogNotes = await fetchChangelogNotes(currentVersion, update.version);
return {
available: true,
version: update.version,
currentVersion,
body: changelogNotes ?? update.body ?? undefined,
date: update.date ?? undefined,
};
} catch (error) {
console.error('[updater] Failed to check for updates:', error);
return {
available: false,
currentVersion: await getCurrentVersion(),
};
}
}
export async function downloadUpdate(
onProgress?: (progress: UpdateProgress) => void
): Promise<void> {
let update = cachedUpdate;
if (!update) {
const { check } = await import('@tauri-apps/plugin-updater');
const checked = await check();
if (!checked) {
throw new Error('No update available');
}
update = checked;
cachedUpdate = checked;
}
let downloaded = 0;
let total: number | undefined;
await update.downloadAndInstall((event: DownloadEvent) => {
switch (event.event) {
case 'Started':
total = event.data.contentLength;
onProgress?.({ downloaded: 0, total });
break;
case 'Progress':
downloaded += event.data.chunkLength;
onProgress?.({ downloaded, total });
break;
case 'Finished':
onProgress?.({ downloaded: total ?? downloaded, total });
break;
}
});
}
export async function restartToUpdate(): Promise<void> {
const { relaunch } = await import('@tauri-apps/plugin-process');
await relaunch();
}
async function getCurrentVersion(): Promise<string> {
try {
const { getVersion } = await import('@tauri-apps/api/app');
return await getVersion();
} catch {
return 'unknown';
}
}
async function fetchChangelogNotes(fromVersion: string, toVersion: string): Promise<string | undefined> {
try {
const response = await fetch(
'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'
);
if (!response.ok) return undefined;
const changelog = await response.text();
const sections = changelog.split(/^## /m).slice(1);
const fromNum = parseVersion(fromVersion);
const toNum = parseVersion(toVersion);
const relevantSections = sections.filter((section) => {
const match = section.match(/^\[(\d+\.\d+\.\d+)\]/);
if (!match) return false;
const ver = parseVersion(match[1]);
return ver > fromNum && ver <= toNum;
});
if (relevantSections.length === 0) return undefined;
return relevantSections
.map((s) => '## ' + s.trim())
.join('\n\n');
} catch {
return undefined;
}
}
function parseVersion(version: string): number {
const parts = version.split('.').map(Number);
return (parts[0] || 0) * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0);
}
-157
View File
@@ -1,157 +0,0 @@
import { safeInvoke, cleanupAllTauriCallbacks } from './tauriCallbackManager';
type ServerInfo = {
server_port: number;
opencode_port?: number | null;
api_prefix?: string | null;
cli_available?: boolean;
};
declare global {
interface Window {
__OPENCHAMBER_DESKTOP_SERVER__?: {
origin: string;
opencodePort: number | null;
apiPrefix: string;
cliAvailable: boolean;
};
}
}
let bridgePromise: Promise<void> | null = null;
export function initializeDesktopBridge(): Promise<void> {
if (!bridgePromise) {
bridgePromise = setupBridge();
}
return bridgePromise;
}
async function setupBridge(): Promise<void> {
try {
const info = await safeInvoke<ServerInfo>('desktop_server_info', {}, {
timeout: 10000,
onCancel: () => {
console.warn('[Bridge] Server info request timed out');
}
});
const origin = `http://127.0.0.1:${info.server_port}`;
window.__OPENCHAMBER_DESKTOP_SERVER__ = {
origin,
opencodePort: info.opencode_port ?? null,
apiPrefix: info.api_prefix ?? '',
cliAvailable: info.cli_available ?? false,
};
patchFetch(origin);
patchEventSource(origin);
const cleanupDevtools = registerDevtoolsShortcut();
if (typeof window !== 'undefined') {
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup = () => {
cleanupDevtools();
};
}
} catch (error) {
console.error('[bridge] Failed to initialize bridge:', error);
if (typeof window !== 'undefined' && (window as { __openchamberCleanup?: () => void }).__openchamberCleanup) {
try {
(window as { __openchamberCleanup?: () => void }).__openchamberCleanup?.();
} catch (cleanupError) {
console.warn('[bridge] Cleanup during failed initialization failed:', cleanupError);
}
delete (window as { __openchamberCleanup?: () => void }).__openchamberCleanup;
}
cleanupAllTauriCallbacks();
throw error;
}
}
function patchFetch(origin: string) {
const originalFetch = window.fetch.bind(window);
const rewrite = (value: string): string => {
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
if (value.startsWith('//')) {
return `http:${value}`;
}
if (value.startsWith('/')) {
return `${origin}${value}`;
}
return value;
};
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
if (typeof input === 'string') {
return originalFetch(rewrite(input), init);
}
if (input instanceof Request) {
const rewritten = rewrite(input.url);
if (rewritten === input.url) {
return originalFetch(input, init);
}
const cloned = new Request(rewritten, input);
return originalFetch(cloned, init);
}
if (input instanceof URL) {
return originalFetch(rewrite(input.toString()), init);
}
return originalFetch(input, init);
};
}
function patchEventSource(origin: string) {
if (typeof window.EventSource === 'undefined') {
return;
}
const OriginalEventSource = window.EventSource;
class DesktopEventSource extends OriginalEventSource {
constructor(url: string | URL, eventSourceInit?: EventSourceInit) {
const normalized = typeof url === 'string' ? url : url.toString();
super(normalized.startsWith('/') ? `${origin}${normalized}` : normalized, eventSourceInit);
}
}
Object.defineProperty(DesktopEventSource, 'name', { value: 'DesktopEventSource' });
Object.setPrototypeOf(DesktopEventSource.prototype, OriginalEventSource.prototype);
Object.setPrototypeOf(DesktopEventSource, OriginalEventSource);
window.EventSource = DesktopEventSource as unknown as typeof EventSource;
}
function registerDevtoolsShortcut() {
const handler = (event: KeyboardEvent) => {
const key = event.key?.toLowerCase();
if ((event.metaKey || event.ctrlKey) && event.altKey && key === 'i') {
event.preventDefault();
const devtoolsPromise = safeInvoke('desktop_open_devtools', {}, {
timeout: 2000,
onCancel: () => {
console.warn('[Bridge] Devtools invocation timed out');
}
});
devtoolsPromise.catch(() => {
});
}
};
window.addEventListener('keydown', handler);
return () => {
window.removeEventListener('keydown', handler);
};
}
@@ -1,320 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
interface PendingCallback {
id: string;
timestamp: number;
type: 'invoke' | 'listen';
cleanup?: () => void;
timeout?: NodeJS.Timeout;
timeoutMs?: number;
}
interface CallbackManagerConfig {
maxCallbackAge?: number;
cleanupInterval?: number;
invokeTimeout?: number;
listenTimeout?: number;
}
class TauriCallbackManager {
private callbacks = new Map<string, PendingCallback>();
private isShuttingDown = false;
private cleanupTimer?: NodeJS.Timeout;
private config: Required<CallbackManagerConfig>;
private windowUnloadHandler?: () => void;
constructor(config: CallbackManagerConfig = {}) {
this.config = {
maxCallbackAge: 30000,
cleanupInterval: 5000,
invokeTimeout: 10000,
listenTimeout: 30000,
...config,
};
this.setupWindowUnloadHandler();
this.startCleanupTimer();
}
register(callback: Omit<PendingCallback, 'timestamp'>): string {
if (this.isShuttingDown) {
console.warn('[TauriCallbackManager] Attempted to register callback during shutdown');
return callback.id;
}
const fullCallback: PendingCallback = {
...callback,
timestamp: Date.now(),
};
this.callbacks.set(callback.id, fullCallback);
const timeoutMs =
typeof fullCallback.timeoutMs === 'number'
? fullCallback.timeoutMs
: callback.type === 'listen'
? this.config.listenTimeout
: 0;
if (timeoutMs > 0) {
const timeout = setTimeout(() => {
this.cleanupCallback(callback.id, 'timeout');
}, timeoutMs);
fullCallback.timeout = timeout;
}
return callback.id;
}
unregister(callbackId: string): void {
const callback = this.callbacks.get(callbackId);
if (!callback) {
return;
}
if (callback.timeout) {
clearTimeout(callback.timeout);
}
if (callback.cleanup) {
try {
callback.cleanup();
} catch (error) {
console.warn('[TauriCallbackManager] Cleanup function failed:', error);
}
}
this.callbacks.delete(callbackId);
}
private cleanupCallback(callbackId: string, reason: 'timeout' | 'shutdown' | 'expired'): void {
const callback = this.callbacks.get(callbackId);
if (!callback) {
return;
}
if (reason === 'expired') {
console.warn(`[TauriCallbackManager] Callback ${callbackId} expired and was cleaned up`);
}
this.unregister(callbackId);
}
cleanupAll(): void {
this.isShuttingDown = true;
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = undefined;
}
const callbackIds = Array.from(this.callbacks.keys());
callbackIds.forEach(id => this.cleanupCallback(id, 'shutdown'));
this.callbacks.clear();
}
private startCleanupTimer(): void {
this.cleanupTimer = setInterval(() => {
if (this.isShuttingDown) {
return;
}
const now = Date.now();
const expiredCallbacks: string[] = [];
this.callbacks.forEach((callback, id) => {
if (callback.type !== 'invoke') {
return;
}
const age = now - callback.timestamp;
if (age > this.config.maxCallbackAge) {
expiredCallbacks.push(id);
}
});
expiredCallbacks.forEach(id => this.cleanupCallback(id, 'expired'));
}, this.config.cleanupInterval);
}
private setupWindowUnloadHandler(): void {
if (typeof window === 'undefined') {
return;
}
this.windowUnloadHandler = () => {
console.info('[TauriCallbackManager] Window unloading, cleaning up callbacks...');
this.cleanupAll();
};
window.addEventListener('beforeunload', this.windowUnloadHandler);
window.addEventListener('pagehide', this.windowUnloadHandler);
}
removeWindowHandlers(): void {
if (this.windowUnloadHandler && typeof window !== 'undefined') {
window.removeEventListener('beforeunload', this.windowUnloadHandler);
window.removeEventListener('pagehide', this.windowUnloadHandler);
this.windowUnloadHandler = undefined;
}
}
getStats(): { total: number; invoke: number; listen: number } {
const stats = { total: 0, invoke: 0, listen: 0 };
this.callbacks.forEach(callback => {
stats.total++;
stats[callback.type]++;
});
return stats;
}
}
let globalCallbackManager: TauriCallbackManager | null = null;
export function getTauriCallbackManager(config?: CallbackManagerConfig): TauriCallbackManager {
if (!globalCallbackManager) {
globalCallbackManager = new TauriCallbackManager(config);
}
return globalCallbackManager;
}
export async function safeInvoke<T>(
command: string,
args?: Record<string, unknown>,
options?: {
timeout?: number;
onCancel?: () => void;
}
): Promise<T> {
const manager = getTauriCallbackManager();
const callbackId = `invoke:${command}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
let timeoutHandle: NodeJS.Timeout | undefined;
let settled = false;
manager.register({
id: callbackId,
type: 'invoke',
});
const clearAndUnregister = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = undefined;
}
manager.unregister(callbackId);
};
if (!options?.timeout || options.timeout <= 0) {
try {
const result = await invoke<T>(command, args);
clearAndUnregister();
return result;
} catch (error) {
clearAndUnregister();
throw error;
}
}
return new Promise<T>((resolve, reject) => {
timeoutHandle = setTimeout(() => {
if (settled) {
return;
}
settled = true;
console.warn(`[safeInvoke] Command ${command} timed out after ${options.timeout}ms`);
try {
options.onCancel?.();
} catch (error) {
console.warn('[safeInvoke] onCancel handler threw:', error);
}
clearAndUnregister();
reject(new Error(`Command ${command} timed out after ${options.timeout}ms`));
}, options.timeout);
invoke<T>(command, args)
.then((result) => {
if (settled) {
return;
}
settled = true;
clearAndUnregister();
resolve(result);
})
.catch((error) => {
if (settled) {
return;
}
settled = true;
clearAndUnregister();
reject(error);
});
});
}
export async function safeListen<T>(
event: string,
handler: (event: { payload: T }) => void,
options?: {
timeout?: number;
onCancel?: () => void;
}
): Promise<UnlistenFn> {
const manager = getTauriCallbackManager();
const callbackId = `listen:${event}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
try {
manager.register({
id: callbackId,
type: 'listen',
cleanup: options?.onCancel,
timeoutMs: options?.timeout,
});
const unlisten = await listen<T>(event, (event) => {
const currentManager = getTauriCallbackManager();
if (currentManager.getStats().total === 0) {
return;
}
try {
handler(event);
} catch (error) {
console.error(`[safeListen] Handler error for event ${event}:`, error);
}
});
const enhancedUnlisten = () => {
try {
unlisten();
} catch (error) {
console.warn(`[safeListen] Failed to unlisten from ${event}:`, error);
}
manager.unregister(callbackId);
};
return enhancedUnlisten;
} catch (error) {
manager.unregister(callbackId);
throw error;
}
}
export function cleanupAllTauriCallbacks(): void {
if (globalCallbackManager) {
globalCallbackManager.cleanupAll();
globalCallbackManager.removeWindowHandlers();
globalCallbackManager = null;
}
}
-372
View File
@@ -1,372 +0,0 @@
import { createDesktopAPIs } from './api';
import { requestInitialNotificationPermission } from './api/notifications';
import { checkForUpdates, downloadUpdate, restartToUpdate, type UpdateInfo, type UpdateProgress } from './api/updater';
import { initializeDesktopBridge } from './lib/bridge';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
import type { DesktopApi, DesktopSettings } from '@openchamber/ui/lib/desktop';
import '@openchamber/ui/index.css';
import '@openchamber/ui/styles/fonts';
if (!(window as typeof globalThis & { process?: unknown }).process) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as typeof globalThis & { process?: any }).process = {
env: {},
platform: 'darwin',
version: 'v20.0.0',
versions: {},
cwd: () => '/',
nextTick: (fn: () => void) => Promise.resolve().then(() => fn()),
};
}
if (import.meta.env.PROD) {
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'r') {
e.preventDefault();
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'r') {
e.preventDefault();
}
});
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
}
declare global {
interface Window {
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
__OPENCHAMBER_HOME__?: string;
opencodeDesktop?: DesktopApi;
}
}
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
const MENU_ACTION_EVENT = 'openchamber:menu-action';
const cleanupFunctions: Array<() => void | Promise<void>> = [];
try {
await initializeDesktopBridge();
const activityUnlisten = await listen('openchamber:session-activity', (event) => {
window.dispatchEvent(new CustomEvent('openchamber:session-activity', { detail: event.payload }));
});
cleanupFunctions.push(() => activityUnlisten());
const updateCheckUnlisten = await listen(CHECK_FOR_UPDATES_EVENT, () => {
window.dispatchEvent(new CustomEvent(CHECK_FOR_UPDATES_EVENT));
});
cleanupFunctions.push(() => updateCheckUnlisten());
const menuActionUnlisten = await listen<string>(MENU_ACTION_EVENT, (event) => {
window.dispatchEvent(new CustomEvent(MENU_ACTION_EVENT, { detail: event.payload }));
});
cleanupFunctions.push(() => menuActionUnlisten());
requestInitialNotificationPermission().catch(err => {
console.error('[main] Failed to request notification permission:', err);
});
window.__OPENCHAMBER_RUNTIME_APIS__ = createDesktopAPIs();
cleanupFunctions.push(() => {
console.info('[main] Cleaning up runtime APIs');
if (window.__OPENCHAMBER_RUNTIME_APIS__) {
/* cleanup placeholder */
}
});
} catch (error) {
console.error('[main] FATAL: Failed to initialize desktop runtime:', error);
for (const cleanup of cleanupFunctions) {
try {
const result = cleanup();
if (result instanceof Promise) {
await result;
}
} catch (cleanupError) {
console.warn('[main] Cleanup function failed during error handling:', cleanupError);
}
}
document.body.innerHTML = `
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
<h1>Desktop Runtime Initialization Failed</h1>
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
${error instanceof Error ? error.stack : String(error)}
</pre>
<p style="margin-top: 20px; color: #999;">Press Cmd+Option+I to open DevTools for more details</p>
</div>
`;
throw error;
}
let homeDirectory: string | undefined;
try {
const { homeDir } = await import('@tauri-apps/api/path');
homeDirectory = await homeDir();
} catch {
homeDirectory = undefined;
}
if (homeDirectory) {
window.__OPENCHAMBER_HOME__ = homeDirectory;
}
window.opencodeDesktop = {
homeDirectory,
macosMajorVersion: null as number | null,
async getServerInfo() {
try {
const info = await invoke<ServerInfo>('desktop_server_info');
return {
webPort: info.server_port,
openCodePort: info.opencode_port ?? null,
host: '127.0.0.1',
ready: info.opencode_port !== null,
cliAvailable: info.cli_available ?? false,
};
} catch {
const server = window.__OPENCHAMBER_DESKTOP_SERVER__;
return {
webPort: server?.origin ? parseInt(server.origin.split(':')[2] || '0', 10) : null,
openCodePort: server?.opencodePort ?? null,
host: '127.0.0.1',
ready: false,
cliAvailable: server?.cliAvailable ?? false,
};
}
},
async getSettings(): Promise<DesktopSettings> {
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
return result.settings;
},
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
const result = await invoke<DesktopSettings>('save_settings', { changes });
return result;
},
async restartOpenCode() {
try {
await invoke('restart_opencode');
return { success: true };
} catch (error) {
console.error('[desktop] Error restarting OpenCode:', error);
return { success: false };
}
},
async shutdown() {
return { success: false };
},
async getHomeDirectory() {
return { success: true, path: homeDirectory || null };
},
async openExternal(url: string) {
try {
await open(url);
return { success: true };
} catch (error) {
console.error('[desktop] Error opening external link:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
markRendererReady() {
},
async requestDirectoryAccess(directoryPath?: string) {
try {
const normalized = typeof directoryPath === 'string' ? directoryPath.trim() : '';
// When the UI already picked a path (typed / directory tree), skip native dialog.
if (normalized.length > 0) {
const result = await invoke<{
success: boolean;
path?: string;
projectId?: string;
error?: string;
}>('process_directory_selection', {
path: normalized,
});
return result;
}
const { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({
directory: true,
multiple: false,
title: 'Select Working Directory',
});
if (!selected || typeof selected !== 'string') {
return { success: false, error: 'Directory selection cancelled' };
}
const result = await invoke<{
success: boolean;
path?: string;
projectId?: string;
error?: string;
}>('process_directory_selection', {
path: selected,
});
return result;
} catch (error) {
console.error('[desktop] Error requesting directory access:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
async startAccessingDirectory(directoryPath: string) {
try {
const result = await invoke<{ success: boolean; error?: string }>('start_accessing_directory', { path: directoryPath });
return result;
} catch (error) {
console.error('[desktop] Error starting directory access:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
async stopAccessingDirectory(directoryPath: string) {
try {
const result = await invoke<{ success: boolean; error?: string }>('stop_accessing_directory', { path: directoryPath });
return result;
} catch (error) {
console.error('[desktop] Error stopping directory access:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
},
async notifyAssistantCompletion(payload) {
try {
const { createDesktopNotificationsAPI } = await import('./api/notifications');
const result = await createDesktopNotificationsAPI().notifyAgentCompletion(payload);
return { success: result };
} catch (error) {
console.error('[desktop] Error sending notification:', error);
return { success: false };
}
},
async checkForUpdates(): Promise<UpdateInfo> {
return checkForUpdates();
},
async downloadUpdate(onProgress?: (progress: UpdateProgress) => void): Promise<void> {
return downloadUpdate(onProgress);
},
async restartToUpdate(): Promise<void> {
return restartToUpdate();
}
};
// Fetch macOS version from Rust
try {
const macosVersion = await invoke<number>('desktop_get_macos_version');
window.opencodeDesktop.macosMajorVersion = macosVersion > 0 ? macosVersion : null;
console.info('[main] macOS version:', macosVersion);
} catch (err) {
console.warn('[main] Failed to get macOS version:', err);
window.opencodeDesktop.macosMajorVersion = null;
}
console.info('[main] window.opencodeDesktop assigned');
if (typeof window !== 'undefined') {
const handleBeforeUnload = () => {
console.info('[main] App is unloading, performing cleanup...');
cleanupFunctions.forEach((cleanup) => {
try {
const result = cleanup();
if (result instanceof Promise) {
result.catch(cleanupError => {
console.warn('[main] Cleanup function failed during unload:', cleanupError);
});
}
} catch (cleanupError) {
console.warn('[main] Cleanup function failed during unload:', cleanupError);
}
});
console.info('[main] Cleanup initiated');
};
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handleBeforeUnload);
cleanupFunctions.push(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handleBeforeUnload);
});
}
interface ServerInfo {
server_port: number;
opencode_port: number | null;
api_prefix: string;
cli_available: boolean;
has_last_directory: boolean;
}
// Check if we need to prompt for directory selection first
const promptForDirectoryIfNeeded = async (): Promise<void> => {
try {
const info = await invoke<ServerInfo>('desktop_server_info');
// If CLI available but no saved directory, prompt user
if (info.cli_available && !info.has_last_directory) {
console.info('[main] No saved directory - prompting user');
const { open } = await import('@tauri-apps/plugin-dialog');
const selected = await open({
directory: true,
multiple: false,
title: 'Select a project folder to get started'
});
if (selected && typeof selected === 'string') {
await invoke('process_directory_selection', { path: selected });
await invoke('restart_opencode');
}
}
} catch (error) {
console.error('[main] Directory selection failed:', error);
}
};
// Check if directory selection is needed, then wait for opencode
await promptForDirectoryIfNeeded();
// Wait for opencode to be ready (or timeout if no CLI)
const waitForOpencode = async (): Promise<void> => {
const maxAttempts = 50;
for (let i = 0; i < maxAttempts; i++) {
const info = await invoke<ServerInfo>('desktop_server_info');
// Ready if opencode running, or no CLI (will show onboarding)
if (!info.cli_available || info.opencode_port !== null) {
return;
}
await new Promise(r => setTimeout(r, 200));
}
};
await waitForOpencode();
try {
await import('@openchamber/ui/main');
} catch (error) {
console.error('[main] FATAL: Failed to load UI module:', error);
document.body.innerHTML = `
<div style="padding: 40px; font-family: monospace; color: #ff6b6b; background: #1a1a1a; height: 100vh;">
<h1>UI Module Load Failed</h1>
<pre style="background: #2a2a2a; padding: 20px; border-radius: 8px; overflow: auto;">
${error instanceof Error ? error.stack : String(error)}
</pre>
<p style="margin-top: 20px; color: #999;">Check DevTools console for details</p>
</div>
`;
throw error;
}
-25
View File
@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"types": ["vite/client"],
"paths": {
"@/*": ["../ui/src/*"],
"@desktop/*": ["./src/*"],
"@openchamber/ui/*": ["../ui/src/*"],
"@openchamber/desktop/*": ["./src/*"]
}
},
"include": ["src", "../ui/src", "../ui/src/types/**/*"]
}
-81
View File
@@ -1,81 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { themeStoragePlugin } from '../../vite-theme-plugin';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
export default defineConfig({
root: path.resolve(__dirname, '.'),
plugins: [react(), themeStoragePlugin()],
resolve: {
alias: [
{ find: '@opencode-ai/sdk/v2', replacement: path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/v2/client.js') },
{ find: '@openchamber/ui', replacement: path.resolve(__dirname, '../ui/src') },
{ find: '@desktop', replacement: path.resolve(__dirname, './src') },
{ find: '@', replacement: path.resolve(__dirname, '../ui/src') },
],
},
worker: {
format: 'es',
},
define: {
'process.env': {},
'process.platform': JSON.stringify('darwin'),
'process.version': JSON.stringify('v20.0.0'),
'process.versions': JSON.stringify({}),
global: 'globalThis',
__APP_VERSION__: JSON.stringify(packageJson.version),
},
optimizeDeps: {
include: ['@opencode-ai/sdk/v2'],
exclude: [
'@tauri-apps/plugin-dialog',
'@tauri-apps/api/core',
'@tauri-apps/api/path',
],
},
server: {
host: '127.0.0.1',
port: 1421,
strictPort: true,
hmr: {
protocol: 'ws',
host: '127.0.0.1',
port: 1421,
},
},
build: {
outDir: path.resolve(__dirname, 'dist'),
emptyOutDir: true,
chunkSizeWarningLimit: 1200,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
const match = id.split('node_modules/')[1];
if (!match) return undefined;
const segments = match.split('/');
const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0];
if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react';
if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand';
if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk';
if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown';
if (packageName.startsWith('@radix-ui')) return 'vendor-radix';
if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax';
if (packageName.startsWith('@tauri-apps')) return 'vendor-tauri';
const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-');
return `vendor-${sanitized}`;
},
},
},
},
});