Initial public release
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Vite build output
|
||||
dist/
|
||||
|
||||
# Tauri build artifacts
|
||||
src-tauri/target/
|
||||
|
||||
# Tauri generated code
|
||||
src-tauri/gen/
|
||||
|
||||
# OpenCode CLI state tracking
|
||||
.opencode-cli-state.json
|
||||
|
||||
# OS-specific
|
||||
.DS_Store
|
||||
@@ -0,0 +1,36 @@
|
||||
# @openchamber/desktop
|
||||
|
||||
Desktop application for the [OpenCode](https://opencode.ai) AI coding agent. Built with Tauri.
|
||||
|
||||
## Installation
|
||||
|
||||
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases).
|
||||
|
||||
Currently available for macOS (Apple Silicon).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [OpenCode CLI](https://opencode.ai) installed
|
||||
|
||||
## Features
|
||||
|
||||
- Native macOS app with auto-updates
|
||||
- Integrated terminal
|
||||
- Git operations with identity management and AI commit message generation
|
||||
- Beautiful themes (Flexoki Light/Dark)
|
||||
- Rich permission cards with syntax-highlighted operation previews
|
||||
- Smart tool visualization (inline diffs, file trees, results highlighting)
|
||||
- Per-agent permission mode control
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/btriapitsyn/openchamber.git
|
||||
cd openchamber
|
||||
pnpm install
|
||||
pnpm run desktop:dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,64 @@
|
||||
<!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>
|
||||
<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;
|
||||
}
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
background-color: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.loading span {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: slideUp 0.5s ease-out forwards;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="root" class="h-full">
|
||||
<div class="loading"><span style="animation-delay:0.1s">O</span><span style="animation-delay:0.15s">p</span><span style="animation-delay:0.2s">e</span><span style="animation-delay:0.25s">n</span><span style="animation-delay:0.3s">C</span><span style="animation-delay:0.35s">h</span><span style="animation-delay:0.4s">a</span><span style="animation-delay:0.45s">m</span><span style="animation-delay:0.5s">b</span><span style="animation-delay:0.55s">e</span><span style="animation-delay:0.6s">r</span></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@openchamber/desktop",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"desktopPrerequisites": [
|
||||
"Rust stable toolchain (via rustup)",
|
||||
"Xcode Command Line Tools installed",
|
||||
"Tauri CLI installed (cargo install tauri-cli@^2)"
|
||||
],
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
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);
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const desktopDir = path.join(repoRoot, 'packages/desktop');
|
||||
|
||||
function spawnProcess(command, args, opts = {}) {
|
||||
return spawn(command, args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env },
|
||||
stdio: 'inherit',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await startCli();
|
||||
|
||||
const tauriProcess = spawnProcess('pnpm', ['-C', desktopDir, 'tauri', 'dev']);
|
||||
|
||||
let cleaning = false;
|
||||
|
||||
const teardown = async (code) => {
|
||||
if (cleaning) {
|
||||
return;
|
||||
}
|
||||
cleaning = true;
|
||||
|
||||
const stopChild = (child, label) => {
|
||||
if (!child || child.killed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill('SIGINT');
|
||||
} catch (error) {
|
||||
console.warn(`[desktop:dev] Failed to stop ${label}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleChildExit = (childName) => (code, signal) => {
|
||||
if (code !== 0 || signal) {
|
||||
console.warn(`[desktop:dev] ${childName} exited with code ${code ?? 'null'} signal ${signal ?? 'none'}.`);
|
||||
}
|
||||
teardown(code).catch((error) => {
|
||||
console.error('[desktop:dev] Cleanup error:', error);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
};
|
||||
|
||||
tauriProcess.on('exit', handleChildExit('Tauri dev process'));
|
||||
const errorHandler = (label) => (error) => {
|
||||
console.error(`[desktop:dev] Failed to start ${label}:`, error);
|
||||
teardown(1).catch(() => process.exit(1));
|
||||
};
|
||||
|
||||
tauriProcess.on('error', errorHandler('Tauri dev process'));
|
||||
|
||||
const signalExitCodes = {
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143,
|
||||
SIGQUIT: 131,
|
||||
};
|
||||
|
||||
Object.entries(signalExitCodes).forEach(([signal, exitCode]) => {
|
||||
process.on(signal, () => {
|
||||
teardown(exitCode).catch(() => process.exit(exitCode));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[desktop:dev] Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access, readFile, unlink, writeFile } from 'node:fs/promises';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const desktopDir = path.resolve(__dirname, '..');
|
||||
const stateFile = path.join(desktopDir, '.opencode-cli-state.json');
|
||||
const DEFAULT_BIN_CANDIDATES = [
|
||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||
process.env.OPENCODE_PATH,
|
||||
process.env.OPENCODE_BINARY,
|
||||
'/opt/homebrew/bin/opencode',
|
||||
'/usr/local/bin/opencode',
|
||||
'/usr/bin/opencode',
|
||||
path.join(os.homedir(), '.local/bin/opencode'),
|
||||
].filter(Boolean);
|
||||
const CLI_ARGS_ENV = process.env.OPENCHAMBER_OPENCODE_ARGS;
|
||||
const DEFAULT_ARGS = CLI_ARGS_ENV
|
||||
? parseArgs(CLI_ARGS_ENV)
|
||||
: ['api'];
|
||||
|
||||
function parseArgs(raw) {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return [];
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fall through to whitespace split
|
||||
}
|
||||
}
|
||||
return trimmed.split(/\s+/g);
|
||||
}
|
||||
|
||||
async function fileExists(targetPath) {
|
||||
try {
|
||||
await access(targetPath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCliPath() {
|
||||
for (const candidate of DEFAULT_BIN_CANDIDATES) {
|
||||
if (candidate && await fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const envPath = process.env.PATH || '';
|
||||
for (const segment of envPath.split(path.delimiter)) {
|
||||
const candidate = path.join(segment, 'opencode');
|
||||
if (await fileExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to locate the OpenCode CLI. Set OPENCHAMBER_OPENCODE_PATH to the executable.');
|
||||
}
|
||||
|
||||
async function readState() {
|
||||
try {
|
||||
const raw = await readFile(stateFile, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
if (typeof data?.pid === 'number') {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!pid || typeof pid !== 'number') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(pid) {
|
||||
await writeFile(stateFile, JSON.stringify({ pid }), 'utf8');
|
||||
}
|
||||
|
||||
async function removeStateFile() {
|
||||
try {
|
||||
await unlink(stateFile);
|
||||
} catch {
|
||||
// already removed
|
||||
}
|
||||
}
|
||||
|
||||
function spawnCli(cliPath, args) {
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_OPENCODE_PORT: process.env.OPENCHAMBER_OPENCODE_PORT || process.env.OPENCODE_PORT || process.env.OPENCHAMBER_INTERNAL_PORT || '0',
|
||||
};
|
||||
const cwd = process.env.OPENCHAMBER_OPENCODE_CWD || process.cwd();
|
||||
|
||||
const child = spawn(cliPath, args.length > 0 ? args : DEFAULT_ARGS, {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
child.unref();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function startCli({ silent = false } = {}) {
|
||||
const existing = await readState();
|
||||
if (existing?.pid && isProcessAlive(existing.pid)) {
|
||||
if (!silent) {
|
||||
console.log(`[desktop:start-cli] OpenCode CLI already running (pid ${existing.pid}).`);
|
||||
}
|
||||
return existing.pid;
|
||||
}
|
||||
|
||||
const cliPath = await resolveCliPath();
|
||||
const child = spawnCli(cliPath, DEFAULT_ARGS);
|
||||
await writeState(child.pid);
|
||||
if (!silent) {
|
||||
console.log(`[desktop:start-cli] OpenCode CLI started (${cliPath}) pid ${child.pid}.`);
|
||||
}
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
export async function stopCli({ silent = false } = {}) {
|
||||
const state = await readState();
|
||||
if (!state?.pid) {
|
||||
if (!silent) {
|
||||
console.log('[desktop:stop-cli] No OpenCode CLI PID recorded.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { pid } = state;
|
||||
if (!isProcessAlive(pid)) {
|
||||
await removeStateFile();
|
||||
if (!silent) {
|
||||
console.log('[desktop:stop-cli] CLI already stopped.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error(`[desktop:stop-cli] Failed to send SIGTERM to pid ${pid}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutMs = 5000;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
await removeStateFile();
|
||||
if (!silent) {
|
||||
console.log('[desktop:stop-cli] OpenCode CLI stopped.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
if (!silent) {
|
||||
console.warn(`[desktop:stop-cli] Forced termination sent to pid ${pid}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error(`[desktop:stop-cli] Unable to terminate pid ${pid}:`, error);
|
||||
}
|
||||
} finally {
|
||||
await removeStateFile();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , command] = process.argv;
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log('Usage: node opencode-cli.mjs <start|stop|status>');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === 'start') {
|
||||
await startCli();
|
||||
return;
|
||||
}
|
||||
if (command === 'stop') {
|
||||
await stopCli();
|
||||
return;
|
||||
}
|
||||
if (command === 'status') {
|
||||
const state = await readState();
|
||||
if (state?.pid && isProcessAlive(state.pid)) {
|
||||
console.log(`OpenCode CLI running (pid ${state.pid}).`);
|
||||
} else {
|
||||
console.log('OpenCode CLI not running.');
|
||||
}
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Unknown command: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
main().catch((error) => {
|
||||
console.error('[desktop:opencode-cli] Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Generated
+6606
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
[package]
|
||||
name = "openchamber-desktop"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "openchamber_desktop"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "openchamber-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[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"] }
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
serde_json = "1.0.143"
|
||||
serde_yaml = "0.9"
|
||||
tauri = { version = "2.9.4", features = ["macos-private-api", "devtools" ] }
|
||||
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"] }
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
window-vibrancy = "0.7.1"
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for OpenChamber desktop runtime",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:webview:default",
|
||||
"core:webview:allow-webview-close",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="iconShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="14" flood-opacity="0.5" flood-color="#000000"/>
|
||||
</filter>
|
||||
<filter id="glyphShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="2" dy="4" stdDeviation="8" flood-opacity="0.3" flood-color="#000000"/>
|
||||
</filter>
|
||||
<linearGradient id="bgGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#131010"/>
|
||||
<stop offset="100%" stop-color="#1E1919"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform="translate(100, 100)">
|
||||
<rect x="0" y="0" width="824" height="824" rx="185" ry="185" filter="url(#iconShadow)" fill="url(#bgGradient)"/>
|
||||
|
||||
<g transform="translate(412, 412) scale(7.65) translate(-35, -35.5)" filter="url(#glyphShadow)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="#F8F7F3"/>
|
||||
<rect x="8.75" y="30" width="17.5" height="18.5" fill="#4B4646"/>
|
||||
<path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="#F8F7F3"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,35 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<ns0:defs>
|
||||
<ns0:filter id="iconShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<ns0:feDropShadow dx="0" dy="12" stdDeviation="14" flood-opacity="0.5" flood-color="#000000" />
|
||||
</ns0:filter>
|
||||
<ns0:filter id="glyphShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<ns0:feDropShadow dx="2" dy="4" stdDeviation="6" flood-opacity="0.28" flood-color="#000000" />
|
||||
</ns0:filter>
|
||||
<ns0:linearGradient id="bgGradient" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2="1">
|
||||
<ns0:stop offset="0%" stop-color="#151313" />
|
||||
<ns0:stop offset="100%" stop-color="#151313" />
|
||||
</ns0:linearGradient>
|
||||
<ns0:linearGradient id="glyphGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<ns0:stop offset="0%" stop-color="#F8F8F8" />
|
||||
<ns0:stop offset="55%" stop-color="#DAD6D0" />
|
||||
<ns0:stop offset="100%" stop-color="#BAB4AF" />
|
||||
</ns0:linearGradient>
|
||||
<ns0:linearGradient id="glyphStrokeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<ns0:stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.08" />
|
||||
<ns0:stop offset="45%" stop-color="#000000" stop-opacity="0.15" />
|
||||
<ns0:stop offset="100%" stop-color="#000000" stop-opacity="0.2" />
|
||||
</ns0:linearGradient>
|
||||
</ns0:defs>
|
||||
|
||||
<ns0:g transform="translate(100, 100)">
|
||||
<ns0:rect x="0" y="0" width="824" height="824" rx="185" ry="185" filter="url(#iconShadow)" fill="url(#bgGradient)" />
|
||||
|
||||
<ns0:g transform="translate(412, 412) scale(8.15) translate(-35, -35.5)" filter="url(#glyphShadow)">
|
||||
<ns0:rect x="8.75" y="31.0" width="17.5" height="20.5" fill="#4B4646" />
|
||||
<ns0:path fill-rule="evenodd" clip-rule="evenodd" d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z" fill="url(#glyphGradient)" stroke="url(#glyphStrokeGradient)" stroke-width="1.1" stroke-linejoin="round" />
|
||||
<ns0:path d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z" fill="url(#glyphGradient)" stroke="url(#glyphStrokeGradient)" stroke-width="1.1" stroke-linejoin="round" />
|
||||
</ns0:g>
|
||||
</ns0:g>
|
||||
</ns0:svg>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,280 @@
|
||||
use std::{collections::HashSet, 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::DesktopRuntime;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EventEnvelope {
|
||||
#[serde(rename = "type")]
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
properties: Value,
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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, ¬ified_messages).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>>,
|
||||
) -> 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 mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
||||
|
||||
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
|
||||
let mut parsed = reqwest::Url::parse(&url)?;
|
||||
parsed
|
||||
.query_pairs_mut()
|
||||
.append_pair("directory", &dir);
|
||||
url = parsed.to_string();
|
||||
}
|
||||
|
||||
debug!("[desktop:notify] Connecting SSE for notifications: {url}");
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
"[desktop:notify] SSE response status={} headers={:?}",
|
||||
response.status(),
|
||||
response.headers()
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
warn!(
|
||||
"[desktop:notify] SSE connect failed with status {}",
|
||||
response.status()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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 serde_json::from_str::<EventEnvelope>(&raw) {
|
||||
Ok(event) => handle_event(app, event, notified_messages).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(())
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
app: &AppHandle,
|
||||
event: EventEnvelope,
|
||||
notified_messages: &Mutex<HashSet<String>>,
|
||||
) {
|
||||
if event.event_type.as_str() != "message.updated" {
|
||||
return;
|
||||
}
|
||||
|
||||
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 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,
|
||||
};
|
||||
|
||||
{
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
use crate::{DesktopRuntime, SettingsStore};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
path::{Path, PathBuf},
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
const DEFAULT_FILE_SEARCH_LIMIT: usize = 60;
|
||||
const MAX_FILE_SEARCH_LIMIT: usize = 400;
|
||||
const FILE_SEARCH_MAX_CONCURRENCY: usize = 5;
|
||||
const FILE_SEARCH_EXCLUDED_DIRS: &[&str] = &[
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".turbo",
|
||||
".cache",
|
||||
"coverage",
|
||||
"tmp",
|
||||
"logs",
|
||||
];
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileListEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
is_directory: bool,
|
||||
is_file: bool,
|
||||
is_symbolic_link: bool,
|
||||
size: Option<u64>,
|
||||
modified_time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryListResult {
|
||||
directory: String,
|
||||
path: String,
|
||||
entries: Vec<FileListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectoryResponse {
|
||||
success: bool,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileSearchHit {
|
||||
name: String,
|
||||
path: String,
|
||||
relative_path: String,
|
||||
extension: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchFilesResponse {
|
||||
root: String,
|
||||
count: usize,
|
||||
files: Vec<FileSearchHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum FsCommandError {
|
||||
NotFound,
|
||||
AccessDenied,
|
||||
NotDirectory,
|
||||
OutsideWorkspace,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl FsCommandError {
|
||||
fn to_list_message(&self) -> String {
|
||||
match self {
|
||||
FsCommandError::NotFound => "Directory not found".to_string(),
|
||||
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
|
||||
"Access to directory denied".to_string()
|
||||
}
|
||||
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
|
||||
FsCommandError::Other(message) => {
|
||||
let _ = message;
|
||||
"Failed to list directory".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_search_message(&self) -> String {
|
||||
match self {
|
||||
FsCommandError::NotFound => "Directory not found".to_string(),
|
||||
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
|
||||
"Access to directory denied".to_string()
|
||||
}
|
||||
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
|
||||
FsCommandError::Other(message) => {
|
||||
let _ = message;
|
||||
"Failed to search files".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_create_message(&self) -> String {
|
||||
match self {
|
||||
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
|
||||
"Access to directory denied".to_string()
|
||||
}
|
||||
FsCommandError::NotDirectory => "Parent path must be a directory".to_string(),
|
||||
FsCommandError::Other(message) => {
|
||||
let _ = message;
|
||||
"Failed to create directory".to_string()
|
||||
}
|
||||
FsCommandError::NotFound => "Parent directory not found".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for FsCommandError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::NotFound => FsCommandError::NotFound,
|
||||
std::io::ErrorKind::PermissionDenied => FsCommandError::AccessDenied,
|
||||
_ => FsCommandError::Other(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_directory(
|
||||
path: Option<String>,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryListResult, String> {
|
||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_list_message())?;
|
||||
|
||||
let metadata = fs::metadata(&resolved_path)
|
||||
.await
|
||||
.map_err(|err| FsCommandError::from(err).to_list_message())?;
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return Err(FsCommandError::NotDirectory.to_list_message());
|
||||
}
|
||||
|
||||
// Re-check boundary after canonicalization to guard against traversal
|
||||
if let Some(root) = &workspace_root {
|
||||
if !resolved_path.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace.to_list_message());
|
||||
}
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut dir_entries = fs::read_dir(&resolved_path)
|
||||
.await
|
||||
.map_err(|err| FsCommandError::from(err).to_list_message())?;
|
||||
|
||||
while let Some(entry) = dir_entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|err| FsCommandError::from(err).to_list_message())?
|
||||
{
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.await
|
||||
.map_err(|err| FsCommandError::from(err).to_list_message())?;
|
||||
|
||||
let entry_path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
let mut is_directory = file_type.is_dir();
|
||||
let is_symlink = file_type.is_symlink();
|
||||
|
||||
if !is_directory && is_symlink {
|
||||
if let Ok(link_meta) = fs::metadata(&entry_path).await {
|
||||
is_directory = link_meta.is_dir();
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&entry_path).await.ok();
|
||||
let size = metadata
|
||||
.as_ref()
|
||||
.filter(|meta| meta.is_file())
|
||||
.map(|meta| meta.len());
|
||||
let modified_time = metadata
|
||||
.and_then(|meta| meta.modified().ok())
|
||||
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis() as i64);
|
||||
|
||||
entries.push(FileListEntry {
|
||||
name,
|
||||
path: normalize_path(&entry_path),
|
||||
is_directory,
|
||||
is_file: file_type.is_file(),
|
||||
is_symbolic_link: is_symlink,
|
||||
size,
|
||||
modified_time,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(DirectoryListResult {
|
||||
directory: normalize_path(&resolved_path),
|
||||
path: normalize_path(&resolved_path),
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_files(
|
||||
directory: Option<String>,
|
||||
query: Option<String>,
|
||||
max_results: Option<usize>,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<SearchFilesResponse, String> {
|
||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_search_message())?;
|
||||
|
||||
let limit = clamp_search_limit(max_results);
|
||||
let normalized_query = query.unwrap_or_default().trim().to_lowercase();
|
||||
let match_all = normalized_query.is_empty();
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut queue = VecDeque::new();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
queue.push_back(resolved_root.clone());
|
||||
visited.insert(resolved_root.clone());
|
||||
|
||||
while !queue.is_empty() && files.len() < limit {
|
||||
for _ in 0..FILE_SEARCH_MAX_CONCURRENCY {
|
||||
let Some(dir) = queue.pop_front() else {
|
||||
break;
|
||||
};
|
||||
|
||||
let mut entries = match fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let Ok(file_type) = entry.file_type().await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.is_empty() || name_str.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry_path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
if should_skip_directory(&name_str) {
|
||||
continue;
|
||||
}
|
||||
if visited.insert(entry_path.clone()) && files.len() < limit {
|
||||
queue.push_back(entry_path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path = relative_path(&resolved_root, &entry_path);
|
||||
if !match_all {
|
||||
let lowercase_name = name_str.to_lowercase();
|
||||
let lowercase_path = relative_path.to_lowercase();
|
||||
if !lowercase_name.contains(&normalized_query)
|
||||
&& !lowercase_path.contains(&normalized_query)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let extension = entry_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_lowercase());
|
||||
|
||||
files.push(FileSearchHit {
|
||||
name: name_str.to_string(),
|
||||
path: normalize_path(&entry_path),
|
||||
relative_path: relative_path.replace('\\', "/"),
|
||||
extension,
|
||||
});
|
||||
|
||||
if files.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if files.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SearchFilesResponse {
|
||||
root: normalize_path(&resolved_root),
|
||||
count: files.len(),
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_directory(
|
||||
path: String,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<CreateDirectoryResponse, String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Path is required".to_string());
|
||||
}
|
||||
|
||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_create_message())?;
|
||||
|
||||
fs::create_dir_all(&resolved_path)
|
||||
.await
|
||||
.map_err(|err| FsCommandError::from(err).to_create_message())?;
|
||||
|
||||
Ok(CreateDirectoryResponse {
|
||||
success: true,
|
||||
path: normalize_path(&resolved_path),
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_sandboxed_path(
|
||||
path: Option<String>,
|
||||
workspace_root: Option<&PathBuf>,
|
||||
) -> Result<PathBuf, FsCommandError> {
|
||||
let candidate_input = path
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let candidate_path = match (candidate_input, workspace_root) {
|
||||
(Some(value), _) => PathBuf::from(value),
|
||||
(None, Some(root)) => root.clone(),
|
||||
(None, None) => default_home_directory(),
|
||||
};
|
||||
|
||||
let resolved = if candidate_path.is_absolute() {
|
||||
candidate_path
|
||||
} else if let Some(root) = workspace_root {
|
||||
root.join(candidate_path)
|
||||
} else {
|
||||
default_home_directory().join(candidate_path)
|
||||
};
|
||||
|
||||
let canonicalized = fs::canonicalize(&resolved)
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
|
||||
if let Some(root) = workspace_root {
|
||||
if !canonicalized.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(canonicalized)
|
||||
}
|
||||
|
||||
async fn resolve_creatable_path(
|
||||
path: &str,
|
||||
workspace_root: Option<&PathBuf>,
|
||||
) -> Result<PathBuf, FsCommandError> {
|
||||
let candidate = PathBuf::from(path);
|
||||
if candidate.as_os_str().is_empty() {
|
||||
return Err(FsCommandError::Other("Path is required".to_string()));
|
||||
}
|
||||
|
||||
let absolute = if candidate.is_absolute() {
|
||||
candidate
|
||||
} else if let Some(root) = workspace_root {
|
||||
root.join(candidate)
|
||||
} else {
|
||||
default_home_directory().join(candidate)
|
||||
};
|
||||
|
||||
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
|
||||
|
||||
let canonical_parent = fs::canonicalize(parent)
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
|
||||
if let Some(root) = workspace_root {
|
||||
if !canonical_parent.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(absolute)
|
||||
}
|
||||
|
||||
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> {
|
||||
if let Ok(Some(last_dir)) = settings.last_directory().await {
|
||||
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await {
|
||||
return Some(canonicalized);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn default_home_directory() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
}
|
||||
|
||||
fn clamp_search_limit(value: Option<usize>) -> usize {
|
||||
let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT);
|
||||
limit.clamp(1, MAX_FILE_SEARCH_LIMIT)
|
||||
}
|
||||
|
||||
fn should_skip_directory(name: &str) -> bool {
|
||||
if name.starts_with('.') {
|
||||
return true;
|
||||
}
|
||||
FILE_SEARCH_EXCLUDED_DIRS
|
||||
.iter()
|
||||
.any(|dir| dir.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn relative_path(root: &Path, target: &Path) -> String {
|
||||
target
|
||||
.strip_prefix(root)
|
||||
.map(|relative| normalize_path(relative))
|
||||
.unwrap_or_else(|_| normalize_path(target))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
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("desktop.log")
|
||||
.to_string();
|
||||
|
||||
Ok(DesktopLogFile { file_name, content })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
pub mod logs;
|
||||
pub mod permissions;
|
||||
pub mod settings;
|
||||
pub mod terminal;
|
||||
pub mod notifications;
|
||||
@@ -0,0 +1,37 @@
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
|
||||
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>,
|
||||
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 with lastDirectory
|
||||
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
|
||||
#[tauri::command]
|
||||
pub async fn process_directory_selection(
|
||||
path: String,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryPermissionResult, String> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Validate directory exists
|
||||
let path_buf = PathBuf::from(&path);
|
||||
if !path_buf.exists() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Update settings with lastDirectory
|
||||
let mut settings = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert(
|
||||
"lastDirectory".to_string(),
|
||||
serde_json::Value::String(path.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
.settings()
|
||||
.save(settings)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
||||
|
||||
info!(
|
||||
"[permissions] Updated settings with lastDirectory: {}",
|
||||
path
|
||||
);
|
||||
|
||||
Ok(DirectoryPermissionResult {
|
||||
success: true,
|
||||
path: Some(path),
|
||||
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,
|
||||
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 path_buf = std::path::PathBuf::from(&path);
|
||||
if !path_buf.exists() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: 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(path),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: 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(())
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use tauri::State;
|
||||
|
||||
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 (matches Express handler behavior)
|
||||
#[tauri::command]
|
||||
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
||||
let settings = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
|
||||
Ok(SettingsLoadResult {
|
||||
settings,
|
||||
source: "desktop".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save settings to disk with merge logic matching Express implementation
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
changes: Value,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<Value, String> {
|
||||
// Load current settings
|
||||
let current = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load current settings: {}", e))?;
|
||||
|
||||
// Sanitize incoming changes
|
||||
let sanitized_changes = sanitize_settings_update(&changes);
|
||||
|
||||
// Merge changes into current settings
|
||||
let merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||
|
||||
// Save merged settings
|
||||
state
|
||||
.settings()
|
||||
.save(merged.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
// Format response
|
||||
Ok(format_settings_response(&merged))
|
||||
}
|
||||
|
||||
/// Restart OpenCode CLI (matches Express /api/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 })
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
result_obj.insert("lastDirectory".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("homeDirectory") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("homeDirectory".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));
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean fields
|
||||
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));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 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 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![]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use log::error;
|
||||
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, Mutex},
|
||||
thread,
|
||||
};
|
||||
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");
|
||||
|
||||
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().unwrap().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 sessions = state.sessions.lock().unwrap();
|
||||
let Some(session) = sessions.get(&session_id) else {
|
||||
return Err("Terminal session not found".to_string());
|
||||
};
|
||||
|
||||
let mut writer = session
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| "Terminal busy".to_string())?;
|
||||
writer
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush terminal input: {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().unwrap();
|
||||
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 = {
|
||||
let mut sessions = state.sessions.lock().unwrap();
|
||||
sessions.remove(&session_id)
|
||||
};
|
||||
|
||||
if let Some(session) = session {
|
||||
if let Ok(mut child) = session.child.lock() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) {
|
||||
thread::spawn(move || {
|
||||
let mut buffer = [0u8; 4096];
|
||||
let event_name = format!("terminal://{}", session_id);
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
window.emit(&event_name, serde_json::json!({ "type": "data", "data": data }))
|
||||
{
|
||||
error!("Failed to emit terminal data: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
error!("Terminal read error: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = {
|
||||
let mut guard = child.lock().expect("terminal child poisoned");
|
||||
guard.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);
|
||||
|
||||
let mut sessions = sessions.lock().unwrap();
|
||||
sessions.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(|value| PathBuf::from(value))
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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("desktop.log");
|
||||
Some(dir)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,794 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
static PROMPT_FILE_PATTERN: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex"));
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SourceInfo {
|
||||
pub exists: bool,
|
||||
pub path: Option<String>,
|
||||
pub fields: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigSources {
|
||||
pub md: SourceInfo,
|
||||
pub json: SourceInfo,
|
||||
}
|
||||
|
||||
/// Get OpenCode config directory path
|
||||
fn get_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("Cannot determine home directory")
|
||||
.join(".config")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
/// Get agent directory path
|
||||
fn get_agent_dir() -> PathBuf {
|
||||
get_config_dir().join("agent")
|
||||
}
|
||||
|
||||
/// Get command directory path
|
||||
fn get_command_dir() -> PathBuf {
|
||||
get_config_dir().join("command")
|
||||
}
|
||||
|
||||
/// Get config file path
|
||||
fn get_config_file() -> PathBuf {
|
||||
get_config_dir().join("opencode.json")
|
||||
}
|
||||
|
||||
/// Ensure required directories exist
|
||||
async fn ensure_dirs() -> Result<()> {
|
||||
let config_dir = get_config_dir();
|
||||
let agent_dir = get_agent_dir();
|
||||
let command_dir = get_command_dir();
|
||||
|
||||
fs::create_dir_all(&config_dir).await?;
|
||||
fs::create_dir_all(&agent_dir).await?;
|
||||
fs::create_dir_all(&command_dir).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a value is a prompt file reference like {file:./prompts/agent.txt}
|
||||
fn is_prompt_file_reference(value: &str) -> bool {
|
||||
PROMPT_FILE_PATTERN.is_match(value.trim())
|
||||
}
|
||||
|
||||
/// Resolve a prompt file reference to an absolute path
|
||||
fn resolve_prompt_file_path(reference: &str) -> Option<PathBuf> {
|
||||
let trimmed = reference.trim();
|
||||
let captures = PROMPT_FILE_PATTERN.captures(trimmed)?;
|
||||
let target = captures.get(1)?.as_str().trim();
|
||||
|
||||
if target.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = if target.starts_with("./") {
|
||||
get_config_dir().join(&target[2..])
|
||||
} else if Path::new(target).is_absolute() {
|
||||
PathBuf::from(target)
|
||||
} else {
|
||||
get_config_dir().join(target)
|
||||
};
|
||||
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Write content to a prompt file
|
||||
async fn write_prompt_file(file_path: &Path, content: &str) -> Result<()> {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
fs::write(file_path, content).await?;
|
||||
info!("Updated prompt file: {}", file_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip JSON comments from content
|
||||
fn strip_json_comments(content: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_string = false;
|
||||
let mut escape_next = false;
|
||||
let mut chars = content.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if escape_next {
|
||||
result.push(ch);
|
||||
escape_next = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '\\' && in_string {
|
||||
result.push(ch);
|
||||
escape_next = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
in_string = !in_string;
|
||||
result.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_string {
|
||||
if ch == '/' {
|
||||
if let Some(&next_ch) = chars.peek() {
|
||||
if next_ch == '/' {
|
||||
// Line comment - skip until end of line
|
||||
chars.next(); // consume the second '/'
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\n' {
|
||||
result.push('\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
} else if next_ch == '*' {
|
||||
// Block comment - skip until */
|
||||
chars.next(); // consume the '*'
|
||||
let mut prev = ' ';
|
||||
while let Some(c) = chars.next() {
|
||||
if prev == '*' && c == '/' {
|
||||
break;
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(ch);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Read opencode.json configuration file
|
||||
pub async fn read_config() -> Result<Value> {
|
||||
let config_file = get_config_file();
|
||||
|
||||
if !config_file.exists() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_file).await?;
|
||||
let normalized = strip_json_comments(&content).trim().to_string();
|
||||
|
||||
if normalized.is_empty() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e))
|
||||
}
|
||||
|
||||
/// Write opencode.json configuration file with backup
|
||||
pub async fn write_config(config: &Value) -> Result<()> {
|
||||
let config_file = get_config_file();
|
||||
|
||||
// Create/overwrite single backup before writing
|
||||
if config_file.exists() {
|
||||
let file_name = config_file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("Invalid config file name"))?;
|
||||
|
||||
let backup_path = config_file.with_file_name(format!("{file_name}.openchamber.backup"));
|
||||
fs::copy(&config_file, &backup_path).await?;
|
||||
info!("Created config backup: {}", backup_path.display());
|
||||
}
|
||||
|
||||
let json_string = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&config_file, json_string).await?;
|
||||
info!("Successfully wrote config file");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Markdown file data
|
||||
#[derive(Debug)]
|
||||
struct MdData {
|
||||
frontmatter: HashMap<String, Value>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
/// Parse markdown file with YAML frontmatter
|
||||
async fn parse_md_file(file_path: &Path) -> Result<MdData> {
|
||||
let content = fs::read_to_string(file_path).await?;
|
||||
|
||||
// Match YAML frontmatter: ---\n...\n---\n
|
||||
let re = Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$").expect("valid regex");
|
||||
|
||||
if let Some(captures) = re.captures(&content) {
|
||||
let yaml_str = captures.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
let body = captures.get(2).map(|m| m.as_str()).unwrap_or("").trim();
|
||||
|
||||
let frontmatter: HashMap<String, Value> =
|
||||
serde_yaml::from_str(yaml_str).unwrap_or_default();
|
||||
|
||||
Ok(MdData {
|
||||
frontmatter,
|
||||
body: body.to_string(),
|
||||
})
|
||||
} else {
|
||||
// No frontmatter, treat entire content as body
|
||||
Ok(MdData {
|
||||
frontmatter: HashMap::new(),
|
||||
body: content.trim().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Write markdown file with YAML frontmatter
|
||||
async fn write_md_file(
|
||||
file_path: &Path,
|
||||
frontmatter: &HashMap<String, Value>,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
let yaml_str = serde_yaml::to_string(frontmatter)?;
|
||||
let content = format!("---\n{}---\n\n{}", yaml_str, body);
|
||||
|
||||
fs::write(file_path, content).await?;
|
||||
info!("Successfully wrote markdown file: {}", file_path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get information about where agent configuration is stored
|
||||
pub async fn get_agent_sources(agent_name: &str) -> Result<ConfigSources> {
|
||||
ensure_dirs().await?;
|
||||
|
||||
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||
let md_exists = md_path.exists();
|
||||
|
||||
let mut md_fields = Vec::new();
|
||||
if md_exists {
|
||||
let md_data = parse_md_file(&md_path).await?;
|
||||
md_fields.extend(md_data.frontmatter.keys().cloned());
|
||||
if !md_data.body.trim().is_empty() {
|
||||
md_fields.push("prompt".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let config = read_config().await?;
|
||||
let json_section = config
|
||||
.get("agent")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|obj| obj.get(agent_name));
|
||||
|
||||
let json_fields = json_section
|
||||
.and_then(|value| value.as_object())
|
||||
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
let sources = ConfigSources {
|
||||
md: SourceInfo {
|
||||
exists: md_exists,
|
||||
path: md_exists.then(|| md_path.display().to_string()),
|
||||
fields: md_fields,
|
||||
},
|
||||
json: SourceInfo {
|
||||
exists: json_section.is_some(),
|
||||
path: Some(get_config_file().display().to_string()),
|
||||
fields: json_fields,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Create new agent as .md file
|
||||
pub async fn create_agent(agent_name: &str, config: &HashMap<String, Value>) -> Result<()> {
|
||||
ensure_dirs().await?;
|
||||
|
||||
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||
|
||||
// Check if agent already exists
|
||||
if md_path.exists() {
|
||||
return Err(anyhow!("Agent {} already exists as .md file", agent_name));
|
||||
}
|
||||
|
||||
let existing_config = read_config().await?;
|
||||
if let Some(agents) = existing_config.get("agent").and_then(|v| v.as_object()) {
|
||||
if agents.contains_key(agent_name) {
|
||||
return Err(anyhow!(
|
||||
"Agent {} already exists in opencode.json",
|
||||
agent_name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract prompt from config
|
||||
let mut frontmatter = config.clone();
|
||||
let prompt = frontmatter
|
||||
.remove("prompt")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Write .md file
|
||||
write_md_file(&md_path, &frontmatter, &prompt).await?;
|
||||
info!("Created new agent: {}", agent_name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update existing agent using field-level logic
|
||||
pub async fn update_agent(agent_name: &str, updates: &HashMap<String, Value>) -> Result<()> {
|
||||
ensure_dirs().await?;
|
||||
|
||||
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||
let md_exists = md_path.exists();
|
||||
|
||||
let mut md_data = if md_exists {
|
||||
Some(parse_md_file(&md_path).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut config = read_config().await?;
|
||||
let mut existing_agent = config
|
||||
.get("agent")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|obj| obj.get(agent_name))
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_else(Map::new);
|
||||
let had_json_fields = !existing_agent.is_empty();
|
||||
|
||||
let mut md_modified = false;
|
||||
let mut json_modified = false;
|
||||
|
||||
for (field, value) in updates.iter() {
|
||||
// Handle explicit removals (null payload) for scalar/frontmatter/JSON fields
|
||||
if value.is_null() {
|
||||
if md_exists {
|
||||
if let Some(ref mut data) = md_data {
|
||||
if data.frontmatter.remove(field).is_some() {
|
||||
md_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if existing_agent.remove(field).is_some() {
|
||||
json_modified = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for prompt field
|
||||
if field == "prompt" {
|
||||
let normalized_value = value.as_str().unwrap_or("").to_string();
|
||||
|
||||
if md_exists {
|
||||
if let Some(ref mut data) = md_data {
|
||||
data.body = normalized_value.clone();
|
||||
md_modified = true;
|
||||
}
|
||||
} else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str())
|
||||
{
|
||||
if is_prompt_file_reference(prompt_ref) {
|
||||
if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) {
|
||||
write_prompt_file(&prompt_file_path, &normalized_value).await?;
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Invalid prompt file reference for agent {}",
|
||||
agent_name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Write prompt directly to JSON entry (file ref or inline string)
|
||||
existing_agent.insert("prompt".to_string(), Value::String(normalized_value));
|
||||
json_modified = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check where field is currently defined
|
||||
let in_md = md_data
|
||||
.as_ref()
|
||||
.map(|data| data.frontmatter.contains_key(field))
|
||||
.unwrap_or(false);
|
||||
let in_json = existing_agent.contains_key(field);
|
||||
|
||||
if in_md {
|
||||
// Update in .md frontmatter
|
||||
if let Some(ref mut data) = md_data {
|
||||
data.frontmatter.insert(field.clone(), value.clone());
|
||||
md_modified = true;
|
||||
}
|
||||
} else if in_json {
|
||||
// Update in opencode.json while preserving existing fields
|
||||
existing_agent.insert(field.clone(), value.clone());
|
||||
json_modified = true;
|
||||
} else {
|
||||
// Field not defined - apply priority rules
|
||||
if md_exists && !existing_agent.is_empty() {
|
||||
// Both exist → add to opencode.json (higher priority) without dropping other keys
|
||||
existing_agent.insert(field.clone(), value.clone());
|
||||
json_modified = true;
|
||||
} else if md_exists {
|
||||
// Only .md exists → add to frontmatter
|
||||
if let Some(ref mut data) = md_data {
|
||||
data.frontmatter.insert(field.clone(), value.clone());
|
||||
md_modified = true;
|
||||
}
|
||||
} else {
|
||||
// Only JSON or built-in → add/create section in opencode.json
|
||||
existing_agent.insert(field.clone(), value.clone());
|
||||
json_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write changes
|
||||
if md_modified {
|
||||
if let Some(data) = md_data {
|
||||
write_md_file(&md_path, &data.frontmatter, &data.body).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if json_modified {
|
||||
// Avoid creating a new JSON section for agents that already live exclusively in .md
|
||||
if md_exists && !had_json_fields {
|
||||
json_modified = false;
|
||||
}
|
||||
}
|
||||
|
||||
if json_modified {
|
||||
if !config.is_object() {
|
||||
config = Value::Object(Map::new());
|
||||
}
|
||||
|
||||
let config_obj = config.as_object_mut().unwrap();
|
||||
let agents_entry = config_obj
|
||||
.entry("agent".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
|
||||
if !agents_entry.is_object() {
|
||||
*agents_entry = Value::Object(Map::new());
|
||||
}
|
||||
|
||||
let agents_obj = agents_entry.as_object_mut().unwrap();
|
||||
agents_obj.insert(agent_name.to_string(), Value::Object(existing_agent));
|
||||
|
||||
write_config(&config).await?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Updated agent: {} (md: {}, json: {})",
|
||||
agent_name, md_modified, json_modified
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete agent configuration
|
||||
pub async fn delete_agent(agent_name: &str) -> Result<()> {
|
||||
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
|
||||
let mut deleted = false;
|
||||
|
||||
// 1. Delete .md file if exists
|
||||
if md_path.exists() {
|
||||
fs::remove_file(&md_path).await?;
|
||||
info!("Deleted agent .md file: {}", md_path.display());
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
// 2. Remove section from opencode.json if exists
|
||||
let mut config = read_config().await?;
|
||||
if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) {
|
||||
if agents.remove(agent_name).is_some() {
|
||||
write_config(&config).await?;
|
||||
info!("Removed agent from opencode.json: {}", agent_name);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If nothing was deleted (built-in agent), disable it
|
||||
if !deleted {
|
||||
if !config.is_object() {
|
||||
config = Value::Object(serde_json::Map::new());
|
||||
}
|
||||
let config_obj = config.as_object_mut().unwrap();
|
||||
if !config_obj.contains_key("agent") {
|
||||
config_obj.insert("agent".to_string(), Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
let agents = config_obj.get_mut("agent").unwrap();
|
||||
if !agents.is_object() {
|
||||
*agents = Value::Object(serde_json::Map::new());
|
||||
}
|
||||
let mut disable_obj = serde_json::Map::new();
|
||||
disable_obj.insert("disable".to_string(), Value::Bool(true));
|
||||
agents
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert(agent_name.to_string(), Value::Object(disable_obj));
|
||||
write_config(&config).await?;
|
||||
info!("Disabled built-in agent: {}", agent_name);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get information about where command configuration is stored
|
||||
pub async fn get_command_sources(command_name: &str) -> Result<ConfigSources> {
|
||||
ensure_dirs().await?;
|
||||
|
||||
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||
let md_exists = md_path.exists();
|
||||
|
||||
let mut md_fields = Vec::new();
|
||||
if md_exists {
|
||||
let md_data = parse_md_file(&md_path).await?;
|
||||
md_fields.extend(md_data.frontmatter.keys().cloned());
|
||||
if !md_data.body.trim().is_empty() {
|
||||
md_fields.push("template".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let config = read_config().await?;
|
||||
let json_section = config
|
||||
.get("command")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|obj| obj.get(command_name));
|
||||
|
||||
let json_fields = json_section
|
||||
.and_then(|value| value.as_object())
|
||||
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
let sources = ConfigSources {
|
||||
md: SourceInfo {
|
||||
exists: md_exists,
|
||||
path: md_exists.then(|| md_path.display().to_string()),
|
||||
fields: md_fields,
|
||||
},
|
||||
json: SourceInfo {
|
||||
exists: json_section.is_some(),
|
||||
path: Some(get_config_file().display().to_string()),
|
||||
fields: json_fields,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
/// Create new command as .md file
|
||||
pub async fn create_command(command_name: &str, config: &HashMap<String, Value>) -> Result<()> {
|
||||
ensure_dirs().await?;
|
||||
|
||||
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||
|
||||
// Check if command already exists
|
||||
if md_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"Command {} already exists as .md file",
|
||||
command_name
|
||||
));
|
||||
}
|
||||
|
||||
let existing_config = read_config().await?;
|
||||
if let Some(commands) = existing_config.get("command").and_then(|v| v.as_object()) {
|
||||
if commands.contains_key(command_name) {
|
||||
return Err(anyhow!(
|
||||
"Command {} already exists in opencode.json",
|
||||
command_name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract template from config
|
||||
let mut frontmatter = config.clone();
|
||||
let template = frontmatter
|
||||
.remove("template")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Write .md file
|
||||
write_md_file(&md_path, &frontmatter, &template).await?;
|
||||
info!("Created new command: {}", command_name);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update existing command using field-level logic
|
||||
pub async fn update_command(
|
||||
command_name: &str,
|
||||
updates: &HashMap<String, Value>,
|
||||
) -> Result<()> {
|
||||
ensure_dirs().await?;
|
||||
|
||||
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||
let md_exists = md_path.exists();
|
||||
|
||||
let mut md_data = if md_exists {
|
||||
Some(parse_md_file(&md_path).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut config = read_config().await?;
|
||||
let mut existing_command = config
|
||||
.get("command")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|obj| obj.get(command_name))
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_else(Map::new);
|
||||
let had_json_fields = !existing_command.is_empty();
|
||||
|
||||
let mut md_modified = false;
|
||||
let mut json_modified = false;
|
||||
|
||||
for (field, value) in updates.iter() {
|
||||
// Handle explicit removals (null payload) for scalar/frontmatter/JSON fields
|
||||
if value.is_null() {
|
||||
if md_exists {
|
||||
if let Some(ref mut data) = md_data {
|
||||
if data.frontmatter.remove(field).is_some() {
|
||||
md_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if existing_command.remove(field).is_some() {
|
||||
json_modified = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for template field
|
||||
if field == "template" {
|
||||
let normalized_value = value.as_str().unwrap_or("").to_string();
|
||||
|
||||
if md_exists {
|
||||
if let Some(ref mut data) = md_data {
|
||||
data.body = normalized_value.clone();
|
||||
md_modified = true;
|
||||
}
|
||||
continue;
|
||||
} else if let Some(template_ref) = existing_command.get("template").and_then(|v| v.as_str()) {
|
||||
if is_prompt_file_reference(template_ref) {
|
||||
if let Some(template_file_path) = resolve_prompt_file_path(template_ref) {
|
||||
write_prompt_file(&template_file_path, &normalized_value).await?;
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Invalid template file reference for command {}",
|
||||
command_name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Write template directly to JSON entry (file ref or inline string)
|
||||
existing_command.insert("template".to_string(), Value::String(normalized_value));
|
||||
json_modified = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check where field is currently defined
|
||||
let in_md = md_data
|
||||
.as_ref()
|
||||
.map(|data| data.frontmatter.contains_key(field))
|
||||
.unwrap_or(false);
|
||||
let in_json = existing_command.contains_key(field);
|
||||
|
||||
if in_md {
|
||||
// Update in .md frontmatter
|
||||
if let Some(ref mut data) = md_data {
|
||||
data.frontmatter.insert(field.clone(), value.clone());
|
||||
md_modified = true;
|
||||
}
|
||||
} else if in_json {
|
||||
// Update in opencode.json while preserving existing fields
|
||||
existing_command.insert(field.clone(), value.clone());
|
||||
json_modified = true;
|
||||
} else {
|
||||
// Field not defined - apply priority rules
|
||||
if md_exists && !existing_command.is_empty() {
|
||||
// Both exist → add to opencode.json (higher priority)
|
||||
existing_command.insert(field.clone(), value.clone());
|
||||
json_modified = true;
|
||||
} else if md_exists {
|
||||
// Only .md exists → add to frontmatter
|
||||
if let Some(ref mut data) = md_data {
|
||||
data.frontmatter.insert(field.clone(), value.clone());
|
||||
md_modified = true;
|
||||
}
|
||||
} else {
|
||||
// Only JSON or built-in → add/create section in opencode.json
|
||||
existing_command.insert(field.clone(), value.clone());
|
||||
json_modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write changes
|
||||
if md_modified {
|
||||
if let Some(data) = md_data {
|
||||
write_md_file(&md_path, &data.frontmatter, &data.body).await?;
|
||||
}
|
||||
}
|
||||
|
||||
if json_modified {
|
||||
// Avoid creating a new JSON section for commands that already live exclusively in .md
|
||||
if md_exists && !had_json_fields {
|
||||
json_modified = false;
|
||||
}
|
||||
}
|
||||
|
||||
if json_modified {
|
||||
if !config.is_object() {
|
||||
config = Value::Object(Map::new());
|
||||
}
|
||||
|
||||
let config_obj = config.as_object_mut().unwrap();
|
||||
let commands_entry = config_obj
|
||||
.entry("command".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
|
||||
if !commands_entry.is_object() {
|
||||
*commands_entry = Value::Object(Map::new());
|
||||
}
|
||||
|
||||
let commands_obj = commands_entry.as_object_mut().unwrap();
|
||||
commands_obj.insert(command_name.to_string(), Value::Object(existing_command));
|
||||
|
||||
write_config(&config).await?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Updated command: {} (md: {}, json: {})",
|
||||
command_name, md_modified, json_modified
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete command configuration
|
||||
pub async fn delete_command(command_name: &str) -> Result<()> {
|
||||
let md_path = get_command_dir().join(format!("{}.md", command_name));
|
||||
let mut deleted = false;
|
||||
|
||||
// 1. Delete .md file if exists
|
||||
if md_path.exists() {
|
||||
fs::remove_file(&md_path).await?;
|
||||
info!("Deleted command .md file: {}", md_path.display());
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
// 2. Remove section from opencode.json if exists
|
||||
let mut config = read_config().await?;
|
||||
if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) {
|
||||
if commands.remove(command_name).is_some() {
|
||||
write_config(&config).await?;
|
||||
info!("Removed command from opencode.json: {}", command_name);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If nothing was deleted, throw error
|
||||
if !deleted {
|
||||
return Err(anyhow!("Command \"{}\" not found", command_name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
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 = initial_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(5))
|
||||
.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
|
||||
}
|
||||
|
||||
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
|
||||
*self.working_dir.write() = new_dir;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 empty prefix first (OpenCode default), then /api (some installations)
|
||||
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 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 /health, /config, /agent endpoints
|
||||
match self.check_endpoints(port, &api_prefix).await {
|
||||
Ok(()) => {
|
||||
// Once ready, attempt to detect and persist the API prefix for proxying
|
||||
let _ = self.detect_api_prefix().await;
|
||||
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}");
|
||||
|
||||
// Check /health
|
||||
let health_url = format!("{base_url}/health");
|
||||
let health_resp = self.http_client.get(&health_url).send().await?;
|
||||
if !health_resp.status().is_success() {
|
||||
return Err(anyhow!("/health returned {}", health_resp.status()));
|
||||
}
|
||||
|
||||
// Check /config
|
||||
let config_url = format!("{base_url}/config");
|
||||
let config_resp = self.http_client.get(&config_url).send().await?;
|
||||
if !config_resp.status().is_success() {
|
||||
return Err(anyhow!("/config returned {}", config_resp.status()));
|
||||
}
|
||||
|
||||
// Check /agent
|
||||
let agent_url = format!("{base_url}/agent");
|
||||
let agent_resp = self.http_client.get(&agent_url).send().await?;
|
||||
if !agent_resp.status().is_success() {
|
||||
return Err(anyhow!("/agent returned {}", agent_resp.status()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn graceful_stop(&self) -> Result<()> {
|
||||
let mut guard = self.child.lock().await;
|
||||
let Some(mut child) = guard.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if child.try_wait()?.is_some() {
|
||||
// Already exited
|
||||
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");
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("[desktop:opencode] did not exit after SIGTERM, sending SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
// SIGKILL
|
||||
let _ = child.kill().await;
|
||||
|
||||
// Wait up to 5 seconds for hard kill
|
||||
match timeout(Duration::from_secs(5), child.wait()).await {
|
||||
Ok(_) => {
|
||||
info!("[desktop:opencode] exited after SIGKILL");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("[desktop:opencode] unresponsive after SIGKILL, continuing anyway");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
// Check explicit override
|
||||
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: {}", value);
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Find in PATH
|
||||
if let Ok(output) = std::process::Command::new("which")
|
||||
.arg("opencode")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
info!("[desktop:opencode] found binary in PATH: {}", path);
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("[desktop:opencode] opencode binary not found in PATH");
|
||||
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, ¤t));
|
||||
}
|
||||
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(":")
|
||||
}
|
||||
|
||||
fn detect_login_shell_path() -> Result<String> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Err(anyhow!("login shell path unsupported"))
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into());
|
||||
let output = Command::new(&shell)
|
||||
.arg("-lic")
|
||||
.arg("echo -n $PATH")
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(anyhow!("shell PATH detection failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
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::DesktopRuntime;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EventEnvelope {
|
||||
#[serde(rename = "type")]
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
properties: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ActivityPhase {
|
||||
Idle,
|
||||
Busy,
|
||||
Cooldown,
|
||||
}
|
||||
|
||||
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 mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
||||
|
||||
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
|
||||
let mut parsed = reqwest::Url::parse(&url)?;
|
||||
parsed
|
||||
.query_pairs_mut()
|
||||
.append_pair("directory", &dir);
|
||||
url = parsed.to_string();
|
||||
}
|
||||
|
||||
debug!("[desktop:activity] Connecting SSE for activity phases: {url}");
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
"[desktop:activity] SSE response status={} headers={:?}",
|
||||
response.status(),
|
||||
response.headers()
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
warn!(
|
||||
"[desktop:activity] SSE connect failed with status {}",
|
||||
response.status()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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 reader.read_until(b'\n', &mut buf).await {
|
||||
Ok(n) => n,
|
||||
Err(err) => {
|
||||
warn!("[desktop:activity] 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: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 serde_json::from_str::<EventEnvelope>(&raw) {
|
||||
Ok(event) => 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(())
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
"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 {
|
||||
// If current phase is busy, move to cooldown for 2s then idle
|
||||
let current = { phases.lock().await.get(&id).cloned() };
|
||||
if matches!(current, Some(ActivityPhase::Busy)) {
|
||||
set_phase(app, &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 = id.clone();
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Store cooldown handle to cancel if phase changes earlier
|
||||
let mut cd = cooldowns.lock().await;
|
||||
if let Some(prev) = cd.remove(&id) {
|
||||
prev.abort();
|
||||
}
|
||||
cd.insert(id, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"productName": "OpenChamber",
|
||||
"version": "1.0.0",
|
||||
"identifier": "ai.opencode.openchamber",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"devUrl": "http://127.0.0.1:1421",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "OpenChamber",
|
||||
"transparent": true,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"trafficLightPosition": {
|
||||
"x": 17,
|
||||
"y": 26
|
||||
},
|
||||
"visible": true,
|
||||
"backgroundThrottling": "disabled"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"macOSPrivateApi": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"icon": [
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png"
|
||||
],
|
||||
"macOS": {
|
||||
"exceptionDomain": "localhost",
|
||||
"minimumSystemVersion": "14.0",
|
||||
"signingIdentity": null,
|
||||
"infoPlist": "Info.plist"
|
||||
},
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://github.com/btriapitsyn/openchamber/releases/latest/download/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0NjI5NDJGNEU0QzFEMTYKUldRV0hVeE9MNVJpNUdRemdsbm8wQ2YxQkU4KzBOOEg3TkpXZzIzb244N3Y0R3I4N2FtUk1NMUEK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
|
||||
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 : 'desktop.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');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
|
||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
|
||||
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): Promise<DirectoryListResult> {
|
||||
try {
|
||||
const result = await safeInvoke<ListDirectoryResponse>('list_directory', {
|
||||
path: normalizePath(path),
|
||||
includeHidden: 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,
|
||||
max_results: payload.maxResults || 100
|
||||
}, {
|
||||
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');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
|
||||
import { safeInvoke } from '../lib/tauriCallbackManager';
|
||||
import type {
|
||||
GitAPI,
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
GetGitDiffOptions,
|
||||
GitFileDiffResponse,
|
||||
GitBranch,
|
||||
GitDeleteBranchPayload,
|
||||
GitDeleteRemoteBranchPayload,
|
||||
GeneratedCommitMessage,
|
||||
GitWorktreeInfo,
|
||||
GitAddWorktreePayload,
|
||||
GitRemoveWorktreePayload,
|
||||
CreateGitCommitOptions,
|
||||
GitCommitResult,
|
||||
GitPushResult,
|
||||
GitPullResult,
|
||||
GitLogOptions,
|
||||
GitLogResponse,
|
||||
GitCommitFilesResponse,
|
||||
GitIdentitySummary,
|
||||
GitIdentityProfile
|
||||
} 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 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
|
||||
});
|
||||
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> {
|
||||
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 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 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 });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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';
|
||||
|
||||
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, label: 'tauri-bootstrap' },
|
||||
terminal: wrappedTerminalAPI,
|
||||
git: createDesktopGitAPI(),
|
||||
files: createDesktopFilesAPI(),
|
||||
settings: createDesktopSettingsAPI(),
|
||||
permissions: createDesktopPermissionsAPI(),
|
||||
notifications: createDesktopNotificationsAPI(),
|
||||
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`);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
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) };
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
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 = await check();
|
||||
cachedUpdate = update;
|
||||
|
||||
if (!update) {
|
||||
return {
|
||||
available: false,
|
||||
currentVersion: await getCurrentVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
version: update.version,
|
||||
currentVersion: await getCurrentVersion(),
|
||||
body: 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (callback.type === 'listen' && this.config.listenTimeout > 0) {
|
||||
const timeout = setTimeout(() => {
|
||||
this.cleanupCallback(callback.id, 'timeout');
|
||||
}, this.config.listenTimeout);
|
||||
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) => {
|
||||
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,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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()),
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
||||
__OPENCHAMBER_HOME__?: string;
|
||||
opencodeDesktop?: DesktopApi;
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
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,
|
||||
async getServerInfo() {
|
||||
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: true,
|
||||
cliAvailable: server?.cliAvailable ?? false,
|
||||
};
|
||||
},
|
||||
async getSettings(): Promise<DesktopSettings> {
|
||||
try {
|
||||
const result = await invoke<{ settings: DesktopSettings; source: string }>('load_settings');
|
||||
return result.settings;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error loading settings:', error);
|
||||
return {} as DesktopSettings;
|
||||
}
|
||||
},
|
||||
async updateSettings(changes: Partial<DesktopSettings>): Promise<DesktopSettings> {
|
||||
try {
|
||||
const result = await invoke<DesktopSettings>('save_settings', { changes });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[desktop] Error updating settings:', error);
|
||||
return {};
|
||||
}
|
||||
},
|
||||
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 };
|
||||
},
|
||||
markRendererReady() {
|
||||
|
||||
},
|
||||
async requestDirectoryAccess() {
|
||||
try {
|
||||
|
||||
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; 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();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { themeStoragePlugin } from '../../vite-theme-plugin';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: path.resolve(__dirname, '.'),
|
||||
plugins: [react(), themeStoragePlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@desktop': path.resolve(__dirname, './src'),
|
||||
'@openchamber/ui': path.resolve(__dirname, '../ui/src'),
|
||||
'@': path.resolve(__dirname, '../ui/src'),
|
||||
'@opencode-ai/sdk': path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/client.js'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
'process.env': {},
|
||||
'process.platform': JSON.stringify('darwin'),
|
||||
'process.version': JSON.stringify('v20.0.0'),
|
||||
'process.versions': JSON.stringify({}),
|
||||
global: 'globalThis',
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@opencode-ai/sdk'],
|
||||
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}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user