refactor(desktop): make Tauri thin shell running web sidecar (#273)
## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models
This commit is contained in:
committed by
GitHub
parent
b733f26aed
commit
83ffb1af34
@@ -1,631 +0,0 @@
|
||||
use std::{collections::{HashMap, HashSet}, path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::TryStreamExt;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tokio::{io::AsyncBufReadExt, sync::Mutex};
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EventEnvelope {
|
||||
#[serde(rename = "type")]
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
properties: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MultiplexedEventEnvelope {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
directory: Option<String>,
|
||||
payload: EventEnvelope,
|
||||
}
|
||||
|
||||
pub fn spawn_assistant_notifications(
|
||||
app: AppHandle,
|
||||
runtime: DesktopRuntime,
|
||||
) -> tauri::async_runtime::JoinHandle<()> {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let client = Client::builder()
|
||||
// Give SSE a very long overall timeout so idle periods don't abort the stream.
|
||||
.timeout(Duration::from_secs(24 * 60 * 60))
|
||||
.tcp_keepalive(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
|
||||
let mut shutdown_rx = runtime.subscribe_shutdown();
|
||||
let notified_messages = Mutex::new(HashSet::<String>::new());
|
||||
let notified_questions = Mutex::new(HashSet::<String>::new());
|
||||
let session_parent_cache = Mutex::new(HashMap::<String, Option<String>>::new());
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("[desktop:notify] Shutdown received, stopping SSE listener");
|
||||
break;
|
||||
}
|
||||
_ = async {
|
||||
if let Err(err) = run_once(
|
||||
&app,
|
||||
&runtime,
|
||||
&client,
|
||||
¬ified_messages,
|
||||
¬ified_questions,
|
||||
&session_parent_cache,
|
||||
).await {
|
||||
warn!("[desktop:notify] SSE loop error: {err:?}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
} => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_once(
|
||||
app: &AppHandle,
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
notified_messages: &Mutex<HashSet<String>>,
|
||||
notified_questions: &Mutex<HashSet<String>>,
|
||||
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
|
||||
) -> Result<()> {
|
||||
let opencode = runtime.opencode_manager();
|
||||
|
||||
let port = match opencode.current_port() {
|
||||
Some(port) => port,
|
||||
None => {
|
||||
warn!("[desktop:notify] OpenCode port unavailable; will retry");
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let prefix = opencode.api_prefix();
|
||||
let base = format!("http://127.0.0.1:{port}{prefix}");
|
||||
let response = connect_notifications_sse(runtime, client, &base).await?;
|
||||
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
|
||||
let mut reader = StreamReader::new(stream);
|
||||
let mut buf = Vec::new();
|
||||
let mut data_lines: Vec<String> = Vec::new();
|
||||
|
||||
loop {
|
||||
buf.clear();
|
||||
let bytes_read = match reader.read_until(b'\n', &mut buf).await {
|
||||
Ok(n) => n,
|
||||
Err(err) => {
|
||||
warn!("[desktop:notify] Read error in SSE stream: {err:?}");
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let line = match std::str::from_utf8(&buf) {
|
||||
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
|
||||
Err(err) => {
|
||||
warn!("[desktop:notify] Non-UTF8 SSE chunk: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if line.is_empty() {
|
||||
if data_lines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let raw = data_lines.join("\n");
|
||||
data_lines.clear();
|
||||
|
||||
match parse_event_envelope(&raw) {
|
||||
Ok(event) => {
|
||||
handle_event(
|
||||
app,
|
||||
runtime,
|
||||
client,
|
||||
&base,
|
||||
event,
|
||||
notified_messages,
|
||||
notified_questions,
|
||||
session_parent_cache,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
data_lines.push(rest.trim_start().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_event_envelope(raw: &str) -> Result<EventEnvelope> {
|
||||
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
|
||||
return Ok(event);
|
||||
}
|
||||
|
||||
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
|
||||
Ok(multiplexed.payload)
|
||||
}
|
||||
|
||||
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
||||
let settings = runtime.settings().load().await.ok()?;
|
||||
|
||||
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
||||
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
||||
if let Some(path) = projects.iter().find_map(|entry| {
|
||||
let id = entry.get("id").and_then(Value::as_str)?;
|
||||
if id != active_id {
|
||||
return None;
|
||||
}
|
||||
entry.get("path").and_then(Value::as_str)
|
||||
}) {
|
||||
return Some(expand_tilde_path(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings
|
||||
.get("lastDirectory")
|
||||
.and_then(Value::as_str)
|
||||
.map(expand_tilde_path)
|
||||
}
|
||||
|
||||
async fn connect_notifications_sse(
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
base: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
let global_url = format!("{base}/global/event");
|
||||
match try_connect_sse(client, &global_url, "[desktop:notify]").await {
|
||||
Ok(response) => {
|
||||
debug!("[desktop:notify] Using SSE endpoint: {global_url}");
|
||||
return Ok(response);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"[desktop:notify] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let event_url = format!("{base}/event");
|
||||
match try_connect_sse(client, &event_url, "[desktop:notify]").await {
|
||||
Ok(response) => {
|
||||
debug!("[desktop:notify] Using SSE endpoint: {event_url}");
|
||||
return Ok(response);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"[desktop:notify] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
||||
anyhow::bail!("No project directory available for SSE fallback");
|
||||
};
|
||||
let directory = working_dir.to_string_lossy().to_string();
|
||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
||||
parsed
|
||||
.query_pairs_mut()
|
||||
.append_pair("directory", &directory);
|
||||
let directory_url = parsed.to_string();
|
||||
|
||||
let response = try_connect_sse(client, &directory_url, "[desktop:notify]").await?;
|
||||
debug!("[desktop:notify] Using directory-scoped SSE endpoint: {directory_url}");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn try_connect_sse(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
log_prefix: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
debug!("{log_prefix} Connecting SSE: {url}");
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
"{log_prefix} SSE response status={} headers={:?}",
|
||||
response.status(),
|
||||
response.headers()
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("SSE connect failed with status {}", response.status());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
app: &AppHandle,
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
base: &str,
|
||||
event: EventEnvelope,
|
||||
notified_messages: &Mutex<HashSet<String>>,
|
||||
notified_questions: &Mutex<HashSet<String>>,
|
||||
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
|
||||
) {
|
||||
match event.event_type.as_str() {
|
||||
"message.updated" => {
|
||||
handle_message_updated(
|
||||
app,
|
||||
runtime,
|
||||
client,
|
||||
base,
|
||||
&event.properties,
|
||||
notified_messages,
|
||||
session_parent_cache,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
"question.asked" => {
|
||||
handle_question_asked(app, &event.properties, notified_questions).await;
|
||||
}
|
||||
"permission.asked" => {
|
||||
handle_permission_asked(app, &event.properties, notified_questions).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_session_parent_id(
|
||||
client: &Client,
|
||||
base: &str,
|
||||
session_id: &str,
|
||||
cache: &Mutex<HashMap<String, Option<String>>>,
|
||||
) -> Option<Option<String>> {
|
||||
{
|
||||
let locked = cache.lock().await;
|
||||
if let Some(existing) = locked.get(session_id) {
|
||||
return Some(existing.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fail open: on any error, return None (unknown)
|
||||
let sessions_url = format!("{base}/session");
|
||||
let response = match tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
client.get(&sessions_url).header("accept", "application/json").send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(resp)) => resp,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let data: Value = match response.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let parent = data
|
||||
.as_array()
|
||||
.and_then(|arr| {
|
||||
arr.iter().find_map(|entry| {
|
||||
let id = entry.get("id").and_then(Value::as_str)?;
|
||||
if id != session_id {
|
||||
return None;
|
||||
}
|
||||
let parent = entry.get("parentID").and_then(Value::as_str);
|
||||
Some(parent.filter(|s| !s.is_empty()).map(|s| s.to_string()))
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
|
||||
{
|
||||
let mut locked = cache.lock().await;
|
||||
locked.insert(session_id.to_string(), parent.clone());
|
||||
}
|
||||
Some(parent)
|
||||
}
|
||||
|
||||
async fn handle_question_asked(
|
||||
app: &AppHandle,
|
||||
properties: &Value,
|
||||
notified_questions: &Mutex<HashSet<String>>,
|
||||
) {
|
||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
||||
let question_id = properties.get("id").and_then(Value::as_str);
|
||||
|
||||
let (session_id, question_id) = match (session_id, question_id) {
|
||||
(Some(s), Some(q)) => (s, q),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let key = format!("{}:{}", session_id, question_id);
|
||||
{
|
||||
let mut notified = notified_questions.lock().await;
|
||||
if notified.contains(&key) {
|
||||
return;
|
||||
}
|
||||
notified.insert(key);
|
||||
}
|
||||
|
||||
let should_notify = app
|
||||
.get_webview_window("main")
|
||||
.map(|window| {
|
||||
let focused = window.is_focused().unwrap_or(false);
|
||||
let minimized = window.is_minimized().unwrap_or(false);
|
||||
!focused || minimized
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_notify {
|
||||
let (title, body) = properties
|
||||
.get("questions")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|questions| questions.first())
|
||||
.and_then(Value::as_object)
|
||||
.map(|first| {
|
||||
let header = first
|
||||
.get("header")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let question = first
|
||||
.get("question")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
let title = if header.to_ascii_lowercase().contains("plan mode") {
|
||||
"Switch to plan mode".to_string()
|
||||
} else if header.to_ascii_lowercase().contains("build agent") {
|
||||
"Switch to build mode".to_string()
|
||||
} else if !header.is_empty() {
|
||||
header.to_string()
|
||||
} else {
|
||||
"Input needed".to_string()
|
||||
};
|
||||
|
||||
let body = if !question.is_empty() {
|
||||
question.to_string()
|
||||
} else {
|
||||
"Agent is waiting for your response".to_string()
|
||||
};
|
||||
|
||||
(title, body)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
"Input needed".to_string(),
|
||||
"Agent is waiting for your response".to_string(),
|
||||
)
|
||||
});
|
||||
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(body)
|
||||
.sound("Glass")
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_permission_asked(
|
||||
app: &AppHandle,
|
||||
properties: &Value,
|
||||
notified_requests: &Mutex<HashSet<String>>,
|
||||
) {
|
||||
let session_id = properties.get("sessionID").and_then(Value::as_str);
|
||||
let request_id = properties.get("id").and_then(Value::as_str);
|
||||
|
||||
let (session_id, request_id) = match (session_id, request_id) {
|
||||
(Some(s), Some(r)) => (s, r),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let key = format!("{}:{}", session_id, request_id);
|
||||
{
|
||||
let mut notified = notified_requests.lock().await;
|
||||
if notified.contains(&key) {
|
||||
return;
|
||||
}
|
||||
notified.insert(key);
|
||||
}
|
||||
|
||||
let permission = properties
|
||||
.get("permission")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("Agent requested permission");
|
||||
|
||||
let should_notify = app
|
||||
.get_webview_window("main")
|
||||
.map(|window| {
|
||||
let focused = window.is_focused().unwrap_or(false);
|
||||
let minimized = window.is_minimized().unwrap_or(false);
|
||||
!focused || minimized
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_notify {
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Permission required")
|
||||
.body(permission)
|
||||
.sound("Glass")
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message_updated(
|
||||
app: &AppHandle,
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
base: &str,
|
||||
properties: &Value,
|
||||
notified_messages: &Mutex<HashSet<String>>,
|
||||
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
|
||||
) {
|
||||
let Some(info) = properties.get("info") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
||||
if role != "assistant" {
|
||||
return;
|
||||
}
|
||||
|
||||
let finish = info.get("finish").and_then(Value::as_str);
|
||||
if finish != Some("stop") {
|
||||
return;
|
||||
}
|
||||
|
||||
let message_id = match info.get("id").and_then(Value::as_str) {
|
||||
Some(id) => id.to_string(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Subtask filtering (fail open)
|
||||
let notify_on_subtasks = runtime
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|settings| settings.get("notifyOnSubtasks").and_then(Value::as_bool))
|
||||
.unwrap_or(true);
|
||||
|
||||
if !notify_on_subtasks {
|
||||
let session_id = info
|
||||
.get("sessionID")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| properties.get("sessionID").and_then(Value::as_str))
|
||||
.or_else(|| properties.get("sessionId").and_then(Value::as_str));
|
||||
|
||||
if let Some(session_id) = session_id {
|
||||
if let Some(parent) = resolve_session_parent_id(client, base, session_id, session_parent_cache).await {
|
||||
if parent.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut notified = notified_messages.lock().await;
|
||||
if notified.contains(&message_id) {
|
||||
return;
|
||||
}
|
||||
notified.insert(message_id.clone());
|
||||
}
|
||||
|
||||
let raw_mode = info
|
||||
.get("mode")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("agent");
|
||||
let raw_model = info
|
||||
.get("modelID")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("assistant");
|
||||
|
||||
let title = format!("{} agent is ready", format_mode(raw_mode));
|
||||
let body = format!("{} completed the task", format_model_id(raw_model));
|
||||
|
||||
let should_notify = app
|
||||
.get_webview_window("main")
|
||||
.map(|window| {
|
||||
let focused = window.is_focused().unwrap_or(false);
|
||||
let minimized = window.is_minimized().unwrap_or(false);
|
||||
// Only notify when the app is not in the foreground or is minimized
|
||||
!focused || minimized
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_notify {
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(body)
|
||||
.sound("Glass")
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mode(raw: &str) -> String {
|
||||
if raw.is_empty() {
|
||||
return "Agent".to_string();
|
||||
}
|
||||
raw.split(&['-', '_', ' '][..])
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(capitalize)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn format_model_id(raw: &str) -> String {
|
||||
if raw.is_empty() {
|
||||
return "Assistant".to_string();
|
||||
}
|
||||
|
||||
let tokens: Vec<&str> = raw.split(&['-', '_'][..]).collect();
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < tokens.len() {
|
||||
let current = tokens[i];
|
||||
|
||||
if current.chars().all(|c| c.is_ascii_digit()) {
|
||||
if i + 1 < tokens.len() && tokens[i + 1].chars().all(|c| c.is_ascii_digit()) {
|
||||
let combined = format!("{}.{}", current, tokens[i + 1]);
|
||||
result.push(combined);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current.to_string());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
result
|
||||
.into_iter()
|
||||
.map(|part| capitalize(&part))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
use crate::logging::log_file_path;
|
||||
use serde::Serialize;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopLogFile {
|
||||
pub file_name: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_desktop_logs() -> Result<DesktopLogFile, String> {
|
||||
let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?;
|
||||
let content = fs::read_to_string(&path)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read log file: {err}"))?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("openchamber.log")
|
||||
.to_string();
|
||||
|
||||
Ok(DesktopLogFile { file_name, content })
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod logs;
|
||||
pub mod notifications;
|
||||
pub mod permissions;
|
||||
pub mod settings;
|
||||
pub mod terminal;
|
||||
@@ -1,37 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationPayload {
|
||||
pub title: Option<String>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_notify<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: Option<NotificationPayload>,
|
||||
) -> Result<bool, String> {
|
||||
let title = payload
|
||||
.as_ref()
|
||||
.and_then(|p| p.title.as_deref())
|
||||
.unwrap_or("OpenChamber");
|
||||
let body = payload
|
||||
.as_ref()
|
||||
.and_then(|p| p.body.as_deref())
|
||||
.unwrap_or("Task completed");
|
||||
|
||||
match app
|
||||
.notification()
|
||||
.builder()
|
||||
.title(title)
|
||||
.body(body)
|
||||
.sound("Glass")
|
||||
.show()
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
use chrono::Utc;
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryPermissionRequest {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectoryPermissionResult {
|
||||
success: bool,
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
project_id: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartAccessingResult {
|
||||
success: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Process directory selection from frontend.
|
||||
/// Updates settings (projects, activeProjectId, lastDirectory).
|
||||
#[tauri::command]
|
||||
pub async fn process_directory_selection(
|
||||
path: String,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryPermissionResult, String> {
|
||||
// Validate directory exists
|
||||
let mut path_buf = expand_tilde_path(&path);
|
||||
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
|
||||
path_buf = canonicalized;
|
||||
}
|
||||
let normalized_path = path_buf.to_string_lossy().to_string();
|
||||
if !path_buf.exists() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Update settings with projects + activeProjectId + lastDirectory
|
||||
let now = Utc::now().timestamp_millis();
|
||||
let normalized_path_for_update = normalized_path.clone();
|
||||
|
||||
let (_, project_id) = state
|
||||
.settings()
|
||||
.update_with(|mut settings| {
|
||||
if !settings.is_object() {
|
||||
settings = json!({});
|
||||
}
|
||||
|
||||
let project_id = {
|
||||
let obj = settings.as_object_mut().unwrap();
|
||||
|
||||
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
||||
if !projects_value.is_array() {
|
||||
*projects_value = json!([]);
|
||||
}
|
||||
|
||||
let projects = projects_value.as_array_mut().unwrap();
|
||||
|
||||
let existing_index = projects.iter().position(|entry| {
|
||||
entry
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value == normalized_path_for_update)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if let Some(index) = existing_index {
|
||||
let entry = projects
|
||||
.get_mut(index)
|
||||
.and_then(|value| value.as_object_mut());
|
||||
if let Some(entry) = entry {
|
||||
entry.insert("lastOpenedAt".to_string(), json!(now));
|
||||
if let Some(id) = entry.get("id").and_then(|value| value.as_str()) {
|
||||
id.to_string()
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
entry.insert("id".to_string(), json!(id));
|
||||
id
|
||||
}
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
projects[index] = json!({
|
||||
"id": id,
|
||||
"path": normalized_path_for_update,
|
||||
"addedAt": now,
|
||||
"lastOpenedAt": now
|
||||
});
|
||||
id
|
||||
}
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
projects.push(json!({
|
||||
"id": id,
|
||||
"path": normalized_path_for_update,
|
||||
"addedAt": now,
|
||||
"lastOpenedAt": now
|
||||
}));
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
|
||||
obj.insert(
|
||||
"lastDirectory".to_string(),
|
||||
json!(normalized_path_for_update),
|
||||
);
|
||||
}
|
||||
|
||||
(settings, project_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
||||
|
||||
info!(
|
||||
"[permissions] Updated settings with active project {}: {}",
|
||||
project_id, normalized_path
|
||||
);
|
||||
|
||||
Ok(DirectoryPermissionResult {
|
||||
success: true,
|
||||
path: Some(normalized_path),
|
||||
project_id: Some(project_id),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy directory picker command (frontend handles actual dialog)
|
||||
#[tauri::command]
|
||||
pub async fn pick_directory(
|
||||
_app_handle: AppHandle,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryPermissionResult, String> {
|
||||
Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some(
|
||||
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Request directory access (desktop implementation)
|
||||
/// For unsandboxed apps, just validates the path is accessible
|
||||
#[tauri::command]
|
||||
pub async fn request_directory_access(
|
||||
request: DirectoryPermissionRequest,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryPermissionResult, String> {
|
||||
let path = request.path;
|
||||
|
||||
let mut path_buf = expand_tilde_path(&path);
|
||||
if let Ok(canonicalized) = std::fs::canonicalize(&path_buf) {
|
||||
path_buf = canonicalized;
|
||||
}
|
||||
let normalized_path = path_buf.to_string_lossy().to_string();
|
||||
if !path_buf.exists() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// For unsandboxed apps, no bookmark needed - just verify access
|
||||
match std::fs::read_dir(&path_buf) {
|
||||
Ok(_) => Ok(DirectoryPermissionResult {
|
||||
success: true,
|
||||
path: Some(normalized_path),
|
||||
project_id: None,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some(format!("Cannot access directory: {}", e)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start accessing directory (desktop implementation)
|
||||
#[tauri::command]
|
||||
pub async fn start_accessing_directory(
|
||||
path: String,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<StartAccessingResult, String> {
|
||||
// Check if directory exists and is accessible
|
||||
let path_buf = std::path::PathBuf::from(&path);
|
||||
|
||||
if !path_buf.exists() {
|
||||
return Ok(StartAccessingResult {
|
||||
success: false,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !path_buf.is_dir() {
|
||||
return Ok(StartAccessingResult {
|
||||
success: false,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Try to read the directory to verify access
|
||||
match std::fs::read_dir(&path_buf) {
|
||||
Ok(_) => {
|
||||
info!("Successfully started accessing directory: {}", path);
|
||||
Ok(StartAccessingResult {
|
||||
success: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to access directory {}: {}", path, e);
|
||||
Ok(StartAccessingResult {
|
||||
success: false,
|
||||
error: Some(format!("Failed to access directory: {}", e)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop accessing directory (desktop implementation)
|
||||
#[tauri::command]
|
||||
pub async fn stop_accessing_directory(
|
||||
_path: String,
|
||||
_state: State<'_, DesktopRuntime>,
|
||||
) -> Result<StartAccessingResult, String> {
|
||||
// For Stage 1, just confirm the operation
|
||||
// Full implementation would call stopAccessingSecurityScopedResource
|
||||
info!("Stopped accessing directory");
|
||||
Ok(StartAccessingResult {
|
||||
success: true,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Restore bookmarks on app startup (no-op for unsandboxed apps)
|
||||
#[tauri::command]
|
||||
pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> {
|
||||
// For unsandboxed apps, no bookmarks needed
|
||||
// Directory access is restored from settings.lastDirectory
|
||||
info!("[permissions] Bookmark restore not needed for unsandboxed app");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SettingsLoadResult {
|
||||
settings: Value,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RestartResult {
|
||||
restarted: bool,
|
||||
}
|
||||
|
||||
/// Load settings from disk.
|
||||
#[tauri::command]
|
||||
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
||||
let (settings, _) = state
|
||||
.settings()
|
||||
.update_with(|mut settings| {
|
||||
migrate_legacy_project_settings(&mut settings);
|
||||
migrate_legacy_theme_settings(&mut settings);
|
||||
normalize_project_selection(&mut settings);
|
||||
(settings, ())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
|
||||
Ok(SettingsLoadResult {
|
||||
settings: format_settings_response(&settings),
|
||||
source: "desktop".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save settings to disk with merge logic.
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
changes: Value,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<Value, String> {
|
||||
let sanitized_changes = sanitize_settings_update(&changes);
|
||||
|
||||
let (merged, _) = state
|
||||
.settings()
|
||||
.update_with(|current| {
|
||||
let mut merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||
migrate_legacy_theme_settings(&mut merged);
|
||||
normalize_project_selection(&mut merged);
|
||||
(merged, ())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
Ok(format_settings_response(&merged))
|
||||
}
|
||||
|
||||
/// Restart the backend process (config reload).
|
||||
#[tauri::command]
|
||||
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
|
||||
state
|
||||
.opencode
|
||||
.restart()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to restart OpenCode: {}", e))?;
|
||||
|
||||
Ok(RestartResult { restarted: true })
|
||||
}
|
||||
|
||||
fn sanitize_projects(value: &Value) -> Option<Value> {
|
||||
let arr = value.as_array()?;
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut seen_paths = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
for entry in arr {
|
||||
let Some(obj) = entry.as_object() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let raw_path = obj
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if id.is_empty() || raw_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let expanded = expand_tilde_path(raw_path).to_string_lossy().to_string();
|
||||
let normalized = if expanded == "/" {
|
||||
expanded
|
||||
} else {
|
||||
expanded.trim_end_matches('/').replace('\\', "/")
|
||||
};
|
||||
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if seen_ids.contains(id) || seen_paths.contains(&normalized) {
|
||||
continue;
|
||||
}
|
||||
seen_ids.insert(id.to_string());
|
||||
seen_paths.insert(normalized.clone());
|
||||
|
||||
let mut project = serde_json::Map::new();
|
||||
project.insert("id".to_string(), json!(id));
|
||||
project.insert("path".to_string(), json!(normalized));
|
||||
|
||||
if let Some(Value::String(label)) = obj.get("label") {
|
||||
if !label.trim().is_empty() {
|
||||
project.insert("label".to_string(), json!(label.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(num)) = obj.get("addedAt") {
|
||||
if let Some(value) = num.as_i64() {
|
||||
if value >= 0 {
|
||||
project.insert("addedAt".to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(num)) = obj.get("lastOpenedAt") {
|
||||
if let Some(value) = num.as_i64() {
|
||||
if value >= 0 {
|
||||
project.insert("lastOpenedAt".to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve worktreeDefaults
|
||||
if let Some(Value::Object(wt)) = obj.get("worktreeDefaults") {
|
||||
let mut defaults = serde_json::Map::new();
|
||||
if let Some(Value::String(s)) = wt.get("branchPrefix") {
|
||||
if !s.trim().is_empty() {
|
||||
defaults.insert("branchPrefix".to_string(), json!(s.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = wt.get("baseBranch") {
|
||||
if !s.trim().is_empty() {
|
||||
defaults.insert("baseBranch".to_string(), json!(s.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Bool(b)) = wt.get("autoCreateWorktree") {
|
||||
defaults.insert("autoCreateWorktree".to_string(), json!(b));
|
||||
}
|
||||
if !defaults.is_empty() {
|
||||
project.insert("worktreeDefaults".to_string(), Value::Object(defaults));
|
||||
}
|
||||
}
|
||||
|
||||
result.push(Value::Object(project));
|
||||
}
|
||||
|
||||
if arr.is_empty() {
|
||||
return Some(Value::Array(vec![]));
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Array(result))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
|
||||
fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
|
||||
if let Some(obj) = payload.as_object() {
|
||||
let result_obj = result.as_object_mut().unwrap();
|
||||
|
||||
// String fields
|
||||
if let Some(Value::String(s)) = obj.get("themeId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("themeId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("themeVariant") {
|
||||
if s == "light" || s == "dark" {
|
||||
result_obj.insert("themeVariant".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("lightThemeId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("lightThemeId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("darkThemeId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("darkThemeId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("lastDirectory") {
|
||||
if !s.is_empty() {
|
||||
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
|
||||
result_obj.insert("lastDirectory".to_string(), json!(expanded));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("homeDirectory") {
|
||||
if !s.is_empty() {
|
||||
let expanded = expand_tilde_path(s).to_string_lossy().to_string();
|
||||
result_obj.insert("homeDirectory".to_string(), json!(expanded));
|
||||
}
|
||||
}
|
||||
if let Some(projects) = obj.get("projects").and_then(sanitize_projects) {
|
||||
result_obj.insert("projects".to_string(), projects);
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("activeProjectId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("activeProjectId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("uiFont") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("uiFont".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("monoFont") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("monoFont".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("markdownDisplayMode") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub OAuth config (non-secret)
|
||||
if let Some(Value::String(s)) = obj.get("githubClientId") {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
result_obj.insert("githubClientId".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("githubScopes") {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
result_obj.insert("githubScopes".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("defaultModel") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
result_obj.insert("defaultModel".to_string(), Value::Null);
|
||||
} else {
|
||||
result_obj.insert("defaultModel".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("defaultVariant") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
result_obj.insert("defaultVariant".to_string(), Value::Null);
|
||||
} else {
|
||||
result_obj.insert("defaultVariant".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("defaultAgent") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
result_obj.insert("defaultAgent".to_string(), Value::Null);
|
||||
} else {
|
||||
result_obj.insert("defaultAgent".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("defaultGitIdentityId") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
result_obj.insert("defaultGitIdentityId".to_string(), Value::Null);
|
||||
} else {
|
||||
result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
// Boolean fields
|
||||
if let Some(Value::Bool(b)) = obj.get("gitmojiEnabled") {
|
||||
result_obj.insert("gitmojiEnabled".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
|
||||
result_obj.insert("useSystemTheme".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
||||
result_obj.insert("showReasoningTraces".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("showTextJustificationActivity") {
|
||||
result_obj.insert("showTextJustificationActivity".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("nativeNotificationsEnabled") {
|
||||
result_obj.insert("nativeNotificationsEnabled".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("notifyOnSubtasks") {
|
||||
result_obj.insert("notifyOnSubtasks".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("usageAutoRefresh") {
|
||||
result_obj.insert("usageAutoRefresh".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("notificationMode") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed == "always" || trimmed == "hidden-only" {
|
||||
result_obj.insert("notificationMode".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("autoDeleteEnabled") {
|
||||
result_obj.insert("autoDeleteEnabled".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("queueModeEnabled") {
|
||||
result_obj.insert("queueModeEnabled".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("autoCreateWorktree") {
|
||||
result_obj.insert("autoCreateWorktree".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("toolCallExpansion") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed == "collapsed" || trimmed == "activity" || trimmed == "detailed" {
|
||||
result_obj.insert("toolCallExpansion".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
// Number fields
|
||||
if let Some(Value::Number(n)) = obj.get("autoDeleteAfterDays") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| {
|
||||
n.as_i64()
|
||||
.and_then(|value| if value >= 0 { Some(value as u64) } else { None })
|
||||
})
|
||||
.or_else(|| n.as_f64().map(|value| value.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(1).min(365);
|
||||
result_obj.insert("autoDeleteAfterDays".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Value::Number(n)) = obj.get("fontSize") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(50).min(200);
|
||||
result_obj.insert("fontSize".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("padding") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(50).min(200);
|
||||
result_obj.insert("padding".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("cornerRadius") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(0).min(32);
|
||||
result_obj.insert("cornerRadius".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("inputBarOffset") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(0).min(100);
|
||||
result_obj.insert("inputBarOffset".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("usageRefreshIntervalMs") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| n.as_i64().and_then(|v| if v >= 0 { Some(v as u64) } else { None }))
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(30000).min(300000);
|
||||
result_obj.insert("usageRefreshIntervalMs".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
|
||||
// Memory limit fields
|
||||
if let Some(Value::Number(n)) = obj.get("memoryLimitHistorical") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| {
|
||||
n.as_i64()
|
||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
||||
})
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(10).min(500);
|
||||
result_obj.insert("memoryLimitHistorical".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("memoryLimitViewport") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| {
|
||||
n.as_i64()
|
||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
||||
})
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(20).min(500);
|
||||
result_obj.insert("memoryLimitViewport".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(n)) = obj.get("memoryLimitActiveSession") {
|
||||
let parsed = n
|
||||
.as_u64()
|
||||
.or_else(|| {
|
||||
n.as_i64()
|
||||
.and_then(|v| if v >= 0 { Some(v as u64) } else { None })
|
||||
})
|
||||
.or_else(|| n.as_f64().map(|v| v.round().max(0.0) as u64));
|
||||
if let Some(value) = parsed {
|
||||
let clamped = value.max(30).min(1000);
|
||||
result_obj.insert("memoryLimitActiveSession".to_string(), json!(clamped));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Value::String(s)) = obj.get("diffLayoutPreference") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed == "dynamic" || trimmed == "inline" || trimmed == "side-by-side" {
|
||||
result_obj.insert("diffLayoutPreference".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("diffViewMode") {
|
||||
let trimmed = s.trim();
|
||||
if trimmed == "single" || trimmed == "stacked" {
|
||||
result_obj.insert("diffViewMode".to_string(), json!(trimmed));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("directoryShowHidden") {
|
||||
result_obj.insert("directoryShowHidden".to_string(), json!(b));
|
||||
}
|
||||
if let Some(Value::Bool(b)) = obj.get("filesViewShowGitignored") {
|
||||
result_obj.insert("filesViewShowGitignored".to_string(), json!(b));
|
||||
}
|
||||
|
||||
// Array fields
|
||||
if let Some(arr) = obj.get("approvedDirectories") {
|
||||
result_obj.insert(
|
||||
"approvedDirectories".to_string(),
|
||||
normalize_string_array(arr),
|
||||
);
|
||||
}
|
||||
if let Some(arr) = obj.get("securityScopedBookmarks") {
|
||||
result_obj.insert(
|
||||
"securityScopedBookmarks".to_string(),
|
||||
normalize_string_array(arr),
|
||||
);
|
||||
}
|
||||
if let Some(arr) = obj.get("pinnedDirectories") {
|
||||
result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr));
|
||||
}
|
||||
|
||||
// Typography sizes object (partial)
|
||||
if let Some(typo) = obj.get("typographySizes") {
|
||||
if let Some(sanitized) = sanitize_typography_sizes_partial(typo) {
|
||||
result_obj.insert("typographySizes".to_string(), sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
// Skill catalogs (array of objects)
|
||||
if let Some(Value::Array(arr)) = obj.get("skillCatalogs") {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut catalogs: Vec<Value> = vec![];
|
||||
|
||||
for entry in arr {
|
||||
let Some(obj) = entry.as_object() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let label = obj
|
||||
.get("label")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let source = obj
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let subpath = obj
|
||||
.get("subpath")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let git_identity_id = obj
|
||||
.get("gitIdentityId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if id.is_empty() || label.is_empty() || source.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if seen.contains(id) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(id.to_string());
|
||||
|
||||
let mut catalog = serde_json::Map::new();
|
||||
catalog.insert("id".to_string(), json!(id));
|
||||
catalog.insert("label".to_string(), json!(label));
|
||||
catalog.insert("source".to_string(), json!(source));
|
||||
if !subpath.is_empty() {
|
||||
catalog.insert("subpath".to_string(), json!(subpath));
|
||||
}
|
||||
if !git_identity_id.is_empty() {
|
||||
catalog.insert("gitIdentityId".to_string(), json!(git_identity_id));
|
||||
}
|
||||
|
||||
catalogs.push(Value::Object(catalog));
|
||||
}
|
||||
|
||||
if !catalogs.is_empty() {
|
||||
result_obj.insert("skillCatalogs".to_string(), Value::Array(catalogs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn migrate_legacy_project_settings(settings: &mut Value) {
|
||||
if !settings.is_object() {
|
||||
*settings = json!({});
|
||||
}
|
||||
|
||||
let now = Utc::now().timestamp_millis();
|
||||
let obj = settings.as_object_mut().unwrap();
|
||||
|
||||
let has_projects = obj
|
||||
.get("projects")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_projects {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_directory = obj
|
||||
.get("lastDirectory")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(expand_tilde_path);
|
||||
|
||||
let Some(mut last_directory) = last_directory else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(canonicalized) = std::fs::canonicalize(&last_directory) {
|
||||
last_directory = canonicalized;
|
||||
}
|
||||
|
||||
let Ok(stats) = std::fs::metadata(&last_directory) else {
|
||||
return;
|
||||
};
|
||||
if !stats.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let normalized_path = last_directory.to_string_lossy().to_string();
|
||||
if normalized_path.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let project_id = Uuid::new_v4().to_string();
|
||||
let active_project_id = project_id.clone();
|
||||
let project_path = normalized_path.clone();
|
||||
|
||||
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
||||
*projects_value = json!([
|
||||
{
|
||||
"id": project_id,
|
||||
"path": project_path,
|
||||
"addedAt": now,
|
||||
"lastOpenedAt": now
|
||||
}
|
||||
]);
|
||||
|
||||
obj.insert("activeProjectId".to_string(), json!(active_project_id));
|
||||
|
||||
// Ensure approvedDirectories includes the migrated project root.
|
||||
let approved_value = obj
|
||||
.entry("approvedDirectories")
|
||||
.or_insert_with(|| json!([]));
|
||||
if !approved_value.is_array() {
|
||||
*approved_value = json!([]);
|
||||
}
|
||||
|
||||
if let Some(array) = approved_value.as_array_mut() {
|
||||
array.push(json!(normalized_path.clone()));
|
||||
array.retain(|entry| entry.as_str().is_some_and(|value| !value.trim().is_empty()));
|
||||
let mut seen = HashSet::new();
|
||||
array.retain(|entry| {
|
||||
let Some(value) = entry.as_str() else {
|
||||
return false;
|
||||
};
|
||||
if seen.contains(value) {
|
||||
return false;
|
||||
}
|
||||
seen.insert(value.to_string());
|
||||
true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_legacy_theme_settings(settings: &mut Value) {
|
||||
if !settings.is_object() {
|
||||
*settings = json!({});
|
||||
}
|
||||
|
||||
let obj = settings.as_object_mut().unwrap();
|
||||
|
||||
let theme_id = obj
|
||||
.get("themeId")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let theme_variant = obj
|
||||
.get("themeVariant")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| *value == "light" || *value == "dark")
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let has_light = obj
|
||||
.get("lightThemeId")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
let has_dark = obj
|
||||
.get("darkThemeId")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
|
||||
if has_light && has_dark {
|
||||
return;
|
||||
}
|
||||
|
||||
let default_light = "flexoki-light".to_string();
|
||||
let default_dark = "flexoki-dark".to_string();
|
||||
|
||||
if !has_light {
|
||||
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
|
||||
if variant == "light" {
|
||||
id.clone()
|
||||
} else {
|
||||
default_light.clone()
|
||||
}
|
||||
} else {
|
||||
default_light.clone()
|
||||
};
|
||||
obj.insert("lightThemeId".to_string(), json!(next));
|
||||
}
|
||||
|
||||
if !has_dark {
|
||||
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
|
||||
if variant == "dark" {
|
||||
id.clone()
|
||||
} else {
|
||||
default_dark.clone()
|
||||
}
|
||||
} else {
|
||||
default_dark.clone()
|
||||
};
|
||||
obj.insert("darkThemeId".to_string(), json!(next));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_project_selection(settings: &mut Value) {
|
||||
let Some(obj) = settings.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(projects) = obj.get("projects").and_then(|value| value.as_array()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if projects.is_empty() {
|
||||
obj.remove("activeProjectId");
|
||||
return;
|
||||
}
|
||||
|
||||
let current_active = obj
|
||||
.get("activeProjectId")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let has_active = projects.iter().any(|entry| {
|
||||
entry
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|id| id == current_active)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if has_active {
|
||||
return;
|
||||
}
|
||||
|
||||
let first_id = projects
|
||||
.first()
|
||||
.and_then(|entry| entry.get("id"))
|
||||
.and_then(|value| value.as_str());
|
||||
|
||||
if let Some(id) = first_id {
|
||||
obj.insert("activeProjectId".to_string(), json!(id));
|
||||
} else {
|
||||
obj.remove("activeProjectId");
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge persisted settings (port of Express mergePersistedSettings)
|
||||
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
||||
let mut result = current.clone();
|
||||
|
||||
if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) {
|
||||
// First apply all changes
|
||||
for (key, value) in changes_obj {
|
||||
result_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// Build approvedDirectories from base + additional
|
||||
let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") {
|
||||
extract_string_vec(arr)
|
||||
} else if let Some(arr) = current.get("approvedDirectories") {
|
||||
extract_string_vec(arr)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut additional_approved = vec![];
|
||||
if let Some(Value::String(s)) = changes_obj.get("lastDirectory") {
|
||||
if !s.is_empty() {
|
||||
additional_approved.push(s.clone());
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = changes_obj.get("homeDirectory") {
|
||||
if !s.is_empty() {
|
||||
additional_approved.push(s.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let project_source = if let Some(Value::Array(arr)) = changes_obj.get("projects") {
|
||||
Some(arr)
|
||||
} else {
|
||||
current.get("projects").and_then(|v| v.as_array())
|
||||
};
|
||||
|
||||
if let Some(entries) = project_source {
|
||||
for entry in entries {
|
||||
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
||||
if !path.trim().is_empty() {
|
||||
additional_approved.push(path.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
|
||||
for item in additional_approved {
|
||||
approved_set.insert(item);
|
||||
}
|
||||
let approved_vec: Vec<String> = approved_set.into_iter().collect();
|
||||
result_obj.insert("approvedDirectories".to_string(), json!(approved_vec));
|
||||
|
||||
// Security scoped bookmarks
|
||||
let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") {
|
||||
extract_string_vec(arr)
|
||||
} else if let Some(arr) = current.get("securityScopedBookmarks") {
|
||||
extract_string_vec(arr)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let bookmarks_set: HashSet<String> = base_bookmarks.into_iter().collect();
|
||||
let bookmarks_vec: Vec<String> = bookmarks_set.into_iter().collect();
|
||||
result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec));
|
||||
|
||||
// Merge typography sizes if present
|
||||
if changes_obj.contains_key("typographySizes") {
|
||||
let current_typo = current
|
||||
.get("typographySizes")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let changes_typo = changes_obj
|
||||
.get("typographySizes")
|
||||
.and_then(|v| v.as_object())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut merged_typo = current_typo;
|
||||
for (key, value) in changes_typo {
|
||||
merged_typo.insert(key, value);
|
||||
}
|
||||
result_obj.insert("typographySizes".to_string(), json!(merged_typo));
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Format settings response (port of Express formatSettingsResponse)
|
||||
fn format_settings_response(settings: &Value) -> Value {
|
||||
let mut result = sanitize_settings_update(settings);
|
||||
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
// Ensure array fields are normalized
|
||||
obj.insert(
|
||||
"approvedDirectories".to_string(),
|
||||
normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))),
|
||||
);
|
||||
obj.insert(
|
||||
"securityScopedBookmarks".to_string(),
|
||||
normalize_string_array(
|
||||
settings
|
||||
.get("securityScopedBookmarks")
|
||||
.unwrap_or(&json!([])),
|
||||
),
|
||||
);
|
||||
obj.insert(
|
||||
"pinnedDirectories".to_string(),
|
||||
normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))),
|
||||
);
|
||||
|
||||
// Typography sizes
|
||||
if let Some(sanitized_typo) = sanitize_typography_sizes_partial(
|
||||
settings.get("typographySizes").unwrap_or(&json!(null)),
|
||||
) {
|
||||
obj.insert("typographySizes".to_string(), sanitized_typo);
|
||||
}
|
||||
|
||||
// showReasoningTraces with fallback
|
||||
let show_reasoning = settings
|
||||
.get("showReasoningTraces")
|
||||
.and_then(|v| v.as_bool())
|
||||
.or_else(|| {
|
||||
// Get showReasoningTraces from sanitized result instead of the current mutable borrow
|
||||
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
|
||||
Some(*b)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
obj.insert("showReasoningTraces".to_string(), json!(show_reasoning));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalize string array helper
|
||||
fn normalize_string_array(input: &Value) -> Value {
|
||||
if let Some(arr) = input.as_array() {
|
||||
let strings: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let unique: HashSet<String> = strings.into_iter().collect();
|
||||
json!(unique.into_iter().collect::<Vec<_>>())
|
||||
} else {
|
||||
json!([])
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize typography sizes partial helper
|
||||
fn sanitize_typography_sizes_partial(input: &Value) -> Option<Value> {
|
||||
if let Some(obj) = input.as_object() {
|
||||
let mut result = serde_json::Map::new();
|
||||
let mut populated = false;
|
||||
|
||||
for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] {
|
||||
if let Some(Value::String(s)) = obj.get(*key) {
|
||||
if !s.is_empty() {
|
||||
result.insert(key.to_string(), json!(s));
|
||||
populated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if populated {
|
||||
Some(json!(result))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract string vector from JSON value
|
||||
fn extract_string_vec(value: &Value) -> Vec<String> {
|
||||
if let Some(arr) = value.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
use log::error;
|
||||
use parking_lot::Mutex;
|
||||
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
env,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{Emitter, State, Window};
|
||||
|
||||
const DEFAULT_SHELL: &str = "/bin/zsh";
|
||||
const DEFAULT_TERM: &str = "xterm-256color";
|
||||
const DEFAULT_COLORTERM: &str = "truecolor";
|
||||
const DEFAULT_LOCALE: &str = "en_US.UTF-8";
|
||||
const TERM_PROGRAM_NAME: &str = "OpenChamber";
|
||||
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// Emit at most ~60fps and avoid tiny payload spam.
|
||||
const EMIT_INTERVAL: Duration = Duration::from_millis(16);
|
||||
const EMIT_MAX_BUFFER_BYTES: usize = 64 * 1024;
|
||||
|
||||
pub struct TerminalSession {
|
||||
pub master: Box<dyn MasterPty + Send>,
|
||||
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
|
||||
pub child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
||||
}
|
||||
|
||||
pub struct TerminalState {
|
||||
pub sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||
}
|
||||
|
||||
impl TerminalState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTerminalPayload {
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateTerminalResponse {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_terminal_session(
|
||||
payload: CreateTerminalPayload,
|
||||
state: State<'_, TerminalState>,
|
||||
window: Window,
|
||||
) -> Result<CreateTerminalResponse, String> {
|
||||
let pty_system = NativePtySystem::default();
|
||||
let size = PtySize {
|
||||
rows: payload.rows,
|
||||
cols: payload.cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
};
|
||||
|
||||
let working_dir = resolve_working_directory(payload.cwd.as_deref())?;
|
||||
let shell_path = resolve_shell();
|
||||
|
||||
let mut cmd = CommandBuilder::new(&shell_path);
|
||||
if shell_accepts_login_flag(&shell_path) {
|
||||
cmd.arg("-l");
|
||||
}
|
||||
if let Some(cwd) = working_dir.to_str() {
|
||||
cmd.cwd(cwd);
|
||||
}
|
||||
apply_terminal_environment(&mut cmd, &shell_path);
|
||||
|
||||
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||
let writer = Arc::new(Mutex::new(
|
||||
pair.master
|
||||
.take_writer()
|
||||
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
|
||||
));
|
||||
let master = pair.master;
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
state.sessions.lock().insert(
|
||||
session_id.clone(),
|
||||
TerminalSession {
|
||||
master,
|
||||
writer: writer.clone(),
|
||||
child: child.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
spawn_reader_thread(reader, window.clone(), session_id.clone());
|
||||
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
|
||||
|
||||
Ok(CreateTerminalResponse { session_id })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_terminal_input(
|
||||
session_id: String,
|
||||
data: String,
|
||||
state: State<'_, TerminalState>,
|
||||
) -> Result<(), String> {
|
||||
let writer = {
|
||||
let sessions = state.sessions.lock();
|
||||
let Some(session) = sessions.get(&session_id) else {
|
||||
return Err("Terminal session not found".to_string());
|
||||
};
|
||||
session.writer.clone()
|
||||
};
|
||||
|
||||
let mut guard = writer.lock();
|
||||
guard
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resize_terminal(
|
||||
session_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
state: State<'_, TerminalState>,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = state.sessions.lock();
|
||||
let Some(session) = sessions.get_mut(&session_id) else {
|
||||
return Err("Terminal session not found".to_string());
|
||||
};
|
||||
|
||||
session
|
||||
.master
|
||||
.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_terminal(
|
||||
session_id: String,
|
||||
state: State<'_, TerminalState>,
|
||||
) -> Result<(), String> {
|
||||
let session = { state.sessions.lock().remove(&session_id) };
|
||||
|
||||
if let Some(session) = session {
|
||||
let _ = session.child.lock().kill();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RestartTerminalPayload {
|
||||
pub session_id: String,
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
pub cwd: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn restart_terminal_session(
|
||||
payload: RestartTerminalPayload,
|
||||
state: State<'_, TerminalState>,
|
||||
window: Window,
|
||||
) -> Result<CreateTerminalResponse, String> {
|
||||
{
|
||||
let session = state.sessions.lock().remove(&payload.session_id);
|
||||
if let Some(session) = session {
|
||||
let _ = session.child.lock().kill();
|
||||
}
|
||||
}
|
||||
|
||||
let pty_system = NativePtySystem::default();
|
||||
let size = PtySize {
|
||||
rows: payload.rows,
|
||||
cols: payload.cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
};
|
||||
|
||||
let working_dir = resolve_working_directory(Some(&payload.cwd))?;
|
||||
let shell_path = resolve_shell();
|
||||
|
||||
let mut cmd = CommandBuilder::new(&shell_path);
|
||||
if shell_accepts_login_flag(&shell_path) {
|
||||
cmd.arg("-l");
|
||||
}
|
||||
if let Some(cwd) = working_dir.to_str() {
|
||||
cmd.cwd(cwd);
|
||||
}
|
||||
apply_terminal_environment(&mut cmd, &shell_path);
|
||||
|
||||
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
|
||||
drop(pair.slave);
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
|
||||
let writer = Arc::new(Mutex::new(
|
||||
pair.master
|
||||
.take_writer()
|
||||
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
|
||||
));
|
||||
let master = pair.master;
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
state.sessions.lock().insert(
|
||||
session_id.clone(),
|
||||
TerminalSession {
|
||||
master,
|
||||
writer: writer.clone(),
|
||||
child: child.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
spawn_reader_thread(reader, window.clone(), session_id.clone());
|
||||
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
|
||||
|
||||
Ok(CreateTerminalResponse { session_id })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ForceKillPayload {
|
||||
pub session_id: Option<String>,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn force_kill_terminal(
|
||||
payload: ForceKillPayload,
|
||||
state: State<'_, TerminalState>,
|
||||
) -> Result<(), String> {
|
||||
let mut sessions = state.sessions.lock();
|
||||
|
||||
if let Some(session_id) = payload.session_id {
|
||||
if let Some(session) = sessions.remove(&session_id) {
|
||||
let _ = session.child.lock().kill();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Current API ignores cwd; keep behavior but avoid holding poisoned locks.
|
||||
let _ = payload.cwd;
|
||||
|
||||
let ids: Vec<String> = sessions.keys().cloned().collect();
|
||||
for id in ids {
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
let _ = session.child.lock().kill();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_reader_thread(reader: Box<dyn Read + Send>, window: Window, session_id: String) {
|
||||
thread::spawn(move || {
|
||||
use std::sync::mpsc;
|
||||
|
||||
let event_name = format!("terminal://{}", session_id);
|
||||
let (tx, rx) = mpsc::channel::<Vec<u8>>();
|
||||
|
||||
// Dedicated blocking reader thread.
|
||||
let reader_handle = thread::spawn(move || {
|
||||
let mut reader = reader;
|
||||
let mut buffer = [0u8; 16384];
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
if tx.send(buffer[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut pending = String::new();
|
||||
let mut pending_bytes: Vec<u8> = Vec::new();
|
||||
|
||||
let flush = |pending: &mut String| -> bool {
|
||||
if pending.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let payload_data = std::mem::take(pending);
|
||||
let payload = serde_json::json!({ "type": "data", "data": payload_data });
|
||||
|
||||
match window.emit(&event_name, payload) {
|
||||
Ok(_) => true,
|
||||
Err(error) => {
|
||||
error!("Failed to emit terminal data: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let decode_pending = |pending_bytes: &mut Vec<u8>, pending: &mut String| {
|
||||
loop {
|
||||
match std::str::from_utf8(pending_bytes) {
|
||||
Ok(text) => {
|
||||
if !text.is_empty() {
|
||||
pending.push_str(text);
|
||||
}
|
||||
pending_bytes.clear();
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
let valid = error.valid_up_to();
|
||||
if valid > 0 {
|
||||
let text = std::str::from_utf8(&pending_bytes[..valid]).unwrap_or("");
|
||||
if !text.is_empty() {
|
||||
pending.push_str(text);
|
||||
}
|
||||
pending_bytes.drain(..valid);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Incomplete UTF-8 at end; wait for more bytes.
|
||||
if error.error_len().is_none() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Invalid leading byte; consume 1 byte and replace.
|
||||
if !pending_bytes.is_empty() {
|
||||
pending_bytes.drain(..1);
|
||||
pending.push('\u{FFFD}');
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
match rx.recv_timeout(EMIT_INTERVAL) {
|
||||
Ok(bytes) => {
|
||||
pending_bytes.extend_from_slice(&bytes);
|
||||
decode_pending(&mut pending_bytes, &mut pending);
|
||||
|
||||
if pending.len() >= EMIT_MAX_BUFFER_BYTES {
|
||||
if !flush(&mut pending) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
// Flush any buffered output even if the PTY is idle.
|
||||
if !pending_bytes.is_empty() {
|
||||
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
|
||||
pending_bytes.clear();
|
||||
}
|
||||
if !flush(&mut pending) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if !pending_bytes.is_empty() {
|
||||
pending.push_str(&String::from_utf8_lossy(&pending_bytes));
|
||||
pending_bytes.clear();
|
||||
}
|
||||
let _ = flush(&mut pending);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = reader_handle.join();
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_exit_watcher(
|
||||
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
|
||||
window: Window,
|
||||
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
|
||||
session_id: String,
|
||||
) {
|
||||
thread::spawn(move || {
|
||||
let status = { child.lock().wait() };
|
||||
|
||||
let (exit_code, signal) = match status {
|
||||
Ok(status) => (
|
||||
status.exit_code() as i32,
|
||||
status.signal().map(|sig| sig.to_string()),
|
||||
),
|
||||
Err(err) => {
|
||||
error!("Failed to wait for terminal exit: {err}");
|
||||
(1, Some("Terminal crashed".to_string()))
|
||||
}
|
||||
};
|
||||
|
||||
let event_name = format!("terminal://{}", session_id);
|
||||
let payload = serde_json::json!({
|
||||
"type": "exit",
|
||||
"exitCode": exit_code,
|
||||
"signal": signal
|
||||
});
|
||||
let _ = window.emit(&event_name, payload);
|
||||
|
||||
sessions.lock().remove(&session_id);
|
||||
});
|
||||
}
|
||||
|
||||
fn resolve_shell() -> String {
|
||||
env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_SHELL.to_string())
|
||||
}
|
||||
|
||||
fn shell_accepts_login_flag(shell_path: &str) -> bool {
|
||||
let shell_name = Path::new(shell_path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(shell_path)
|
||||
.to_lowercase();
|
||||
|
||||
matches!(
|
||||
shell_name.as_str(),
|
||||
name if name.contains("zsh")
|
||||
|| name.contains("bash")
|
||||
|| name.contains("sh")
|
||||
|| name.contains("fish")
|
||||
|| name.contains("ksh")
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
|
||||
let maybe_path = input.map(PathBuf::from).or_else(|| dirs::home_dir());
|
||||
|
||||
let Some(path) = maybe_path else {
|
||||
return Err("Unable to determine working directory".to_string());
|
||||
};
|
||||
|
||||
if !path.exists() || !path.is_dir() {
|
||||
return Err(format!(
|
||||
"Working directory is not accessible: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) {
|
||||
cmd.env(
|
||||
"TERM",
|
||||
env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()),
|
||||
);
|
||||
cmd.env(
|
||||
"COLORTERM",
|
||||
env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()),
|
||||
);
|
||||
cmd.env(
|
||||
"LC_ALL",
|
||||
env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
||||
);
|
||||
cmd.env(
|
||||
"LANG",
|
||||
env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
|
||||
);
|
||||
cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME);
|
||||
cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION);
|
||||
cmd.env("OPENCHAMBER_DESKTOP", "1");
|
||||
cmd.env("SHELL", shell_path);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const PLATFORM_LOG_SEGMENTS: &[&str] = &["Library", "Logs", "OpenChamber"];
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PLATFORM_LOG_SEGMENTS: &[&str] = &[".config", "openchamber", "logs"];
|
||||
|
||||
pub fn log_directory() -> Option<PathBuf> {
|
||||
let mut path = dirs::home_dir()?;
|
||||
for segment in PLATFORM_LOG_SEGMENTS {
|
||||
path.push(segment);
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
pub fn log_file_path() -> Option<PathBuf> {
|
||||
let mut dir = log_directory()?;
|
||||
dir.push("openchamber.log");
|
||||
Some(dir)
|
||||
}
|
||||
+1104
-2694
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
/// Get OpenCode data directory path (~/.local/share/opencode)
|
||||
fn get_data_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("Cannot determine home directory")
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
/// Get auth file path
|
||||
fn get_auth_file() -> PathBuf {
|
||||
get_data_dir().join("auth.json")
|
||||
}
|
||||
|
||||
/// Ensure data directory exists
|
||||
async fn ensure_data_dir() -> Result<()> {
|
||||
let data_dir = get_data_dir();
|
||||
fs::create_dir_all(&data_dir).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read auth.json file
|
||||
pub async fn read_auth() -> Result<Value> {
|
||||
let auth_file = get_auth_file();
|
||||
|
||||
if !auth_file.exists() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&auth_file).await?;
|
||||
let trimmed = content.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
serde_json::from_str(trimmed).map_err(|e| anyhow!("Failed to parse auth file: {}", e))
|
||||
}
|
||||
|
||||
/// Write auth.json file with backup
|
||||
pub async fn write_auth(auth: &Value) -> Result<()> {
|
||||
ensure_data_dir().await?;
|
||||
|
||||
let auth_file = get_auth_file();
|
||||
|
||||
// Create backup before writing
|
||||
if auth_file.exists() {
|
||||
let file_name = auth_file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("Invalid auth file name"))?;
|
||||
|
||||
let backup_path = auth_file.with_file_name(format!("{file_name}.openchamber.backup"));
|
||||
fs::copy(&auth_file, &backup_path).await?;
|
||||
info!("Created auth backup: {}", backup_path.display());
|
||||
}
|
||||
|
||||
let json_string = serde_json::to_string_pretty(auth)?;
|
||||
fs::write(&auth_file, json_string).await?;
|
||||
info!("Successfully wrote auth file");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get provider auth entry from auth.json
|
||||
pub async fn get_provider_auth(provider_id: &str) -> Result<Option<Value>> {
|
||||
if provider_id.is_empty() {
|
||||
return Err(anyhow!("Provider ID is required"));
|
||||
}
|
||||
|
||||
let auth = read_auth().await?;
|
||||
let auth_obj = auth
|
||||
.as_object()
|
||||
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
|
||||
Ok(auth_obj.get(provider_id).cloned())
|
||||
}
|
||||
|
||||
/// Remove provider auth entry from auth.json
|
||||
pub async fn remove_provider_auth(provider_id: &str) -> Result<bool> {
|
||||
if provider_id.is_empty() {
|
||||
return Err(anyhow!("Provider ID is required"));
|
||||
}
|
||||
|
||||
let mut auth = read_auth().await?;
|
||||
|
||||
let auth_obj = auth
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?;
|
||||
|
||||
if !auth_obj.contains_key(provider_id) {
|
||||
info!(
|
||||
"Provider {} not found in auth file, nothing to remove",
|
||||
provider_id
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
auth_obj.remove(provider_id);
|
||||
write_auth(&auth).await?;
|
||||
info!("Removed provider auth: {}", provider_id);
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,751 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use log::{debug, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::RwLock;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
process::{Child, Command},
|
||||
sync::Mutex,
|
||||
time::timeout,
|
||||
};
|
||||
|
||||
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"https?://[^:\s]+:(?P<port>\d+)(?P<path>/[^\s"']*)?"#).expect("valid regex")
|
||||
});
|
||||
|
||||
const FIRST_SIGNAL_TIMEOUT_MS: u64 = 750;
|
||||
const READY_CHECK_TIMEOUT_MS: u64 = 20000;
|
||||
const READY_CHECK_INTERVAL_MS: u64 = 400;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OpenCodeManager {
|
||||
binary: Option<String>,
|
||||
args: Vec<String>,
|
||||
env: HashMap<String, String>,
|
||||
working_dir: Arc<RwLock<PathBuf>>,
|
||||
desired_port: u16,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
port: Arc<RwLock<Option<u16>>>,
|
||||
api_prefix: Arc<RwLock<String>>,
|
||||
is_ready: Arc<AtomicBool>,
|
||||
shutting_down: Arc<AtomicBool>,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
fn normalize_api_prefix(prefix: &str) -> String {
|
||||
let trimmed = prefix.trim();
|
||||
if trimmed.is_empty() || trimmed == "/" {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut normalized = trimmed.trim_end_matches('/').to_string();
|
||||
if !normalized.starts_with('/') {
|
||||
normalized.insert(0, '/');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
impl OpenCodeManager {
|
||||
pub fn new_with_directory(_initial_dir: Option<PathBuf>) -> Self {
|
||||
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let binary = resolve_opencode_binary();
|
||||
|
||||
if let Some(ref bin) = binary {
|
||||
if !Path::new(bin).is_absolute() {
|
||||
info!("[desktop:opencode] using PATH-resolved binary: {}", bin);
|
||||
} else {
|
||||
info!("[desktop:opencode] using binary: {}", bin);
|
||||
}
|
||||
} else {
|
||||
warn!("[desktop:opencode] OpenCode CLI not found - app will run in limited mode");
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
"serve".to_string(),
|
||||
"--port".to_string(),
|
||||
desired_port.to_string(),
|
||||
];
|
||||
if let Ok(config) = std::env::var("OPENCHAMBER_OPENCODE_CONFIG") {
|
||||
if !config.is_empty() {
|
||||
args.push("--config".to_string());
|
||||
args.push(config);
|
||||
}
|
||||
}
|
||||
|
||||
let env = build_augmented_env();
|
||||
let working_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
|
||||
info!(
|
||||
"[desktop:opencode] Initial working directory: {:?}",
|
||||
working_dir
|
||||
);
|
||||
|
||||
Self {
|
||||
binary,
|
||||
args,
|
||||
env,
|
||||
working_dir: Arc::new(RwLock::new(working_dir)),
|
||||
desired_port,
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
port: Arc::new(RwLock::new(None)),
|
||||
api_prefix: Arc::new(RwLock::new(String::new())),
|
||||
is_ready: Arc::new(AtomicBool::new(false)),
|
||||
shutting_down: Arc::new(AtomicBool::new(false)),
|
||||
http_client: Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_cli_available(&self) -> bool {
|
||||
self.binary.is_some()
|
||||
}
|
||||
|
||||
pub async fn ensure_running(&self) -> Result<()> {
|
||||
if self.binary.is_none() {
|
||||
return Err(anyhow!("OpenCode CLI is not available"));
|
||||
}
|
||||
|
||||
let mut guard = self.child.lock().await;
|
||||
if let Some(child) = guard.as_mut() {
|
||||
if child.try_wait()?.is_none() && self.is_ready.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
self.is_ready.store(false, Ordering::SeqCst);
|
||||
let child = self.spawn_process().await?;
|
||||
*guard = Some(child);
|
||||
drop(guard);
|
||||
|
||||
// Wait for port detection from logs
|
||||
if self.desired_port == 0 {
|
||||
self.wait_for_port_detection().await?;
|
||||
}
|
||||
|
||||
// Detect API prefix early so proxy can forward correctly
|
||||
let _ = self.detect_api_prefix().await;
|
||||
|
||||
// Wait for OpenCode to become ready by polling endpoints
|
||||
self.wait_for_ready().await?;
|
||||
|
||||
self.is_ready.store(true, Ordering::SeqCst);
|
||||
if let Some(port) = self.current_port() {
|
||||
info!("[desktop:opencode] ready on port {port}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restart(&self) -> Result<()> {
|
||||
info!("[desktop:opencode] restarting...");
|
||||
self.is_ready.store(false, Ordering::SeqCst);
|
||||
|
||||
self.graceful_stop().await?;
|
||||
|
||||
// Brief delay to let OS release resources
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
|
||||
// Reset state
|
||||
if self.desired_port == 0 {
|
||||
*self.port.write() = None;
|
||||
}
|
||||
*self.api_prefix.write() = String::new();
|
||||
|
||||
self.ensure_running().await
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> Result<()> {
|
||||
self.shutting_down.store(true, Ordering::SeqCst);
|
||||
self.is_ready.store(false, Ordering::SeqCst);
|
||||
self.graceful_stop().await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
|
||||
*self.working_dir.write() = new_dir;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_working_directory(&self) -> PathBuf {
|
||||
self.working_dir.read().clone()
|
||||
}
|
||||
|
||||
async fn detect_api_prefix(&self) -> Result<()> {
|
||||
let Some(port) = self.current_port() else {
|
||||
return Err(anyhow!("Cannot detect API prefix without port"));
|
||||
};
|
||||
|
||||
// Try no prefix first, then /api (compatibility).
|
||||
let candidates = ["", "/api"];
|
||||
for candidate in candidates {
|
||||
let base = if candidate.is_empty() {
|
||||
format!("http://127.0.0.1:{port}")
|
||||
} else {
|
||||
format!("http://127.0.0.1:{port}{candidate}")
|
||||
};
|
||||
|
||||
let url = format!("{base}/config");
|
||||
match self.http_client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
// Validate it's actually JSON config, not HTML
|
||||
if let Ok(text) = resp.text().await {
|
||||
if text.trim().starts_with('{') || text.trim().starts_with('[') {
|
||||
info!("[desktop:opencode] Detected API prefix: {:?}", candidate);
|
||||
*self.api_prefix.write() = normalize_api_prefix(candidate);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
info!("[desktop:opencode] No API prefix detected, using empty prefix");
|
||||
*self.api_prefix.write() = String::new();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn current_port(&self) -> Option<u16> {
|
||||
*self.port.read()
|
||||
}
|
||||
|
||||
pub fn api_prefix(&self) -> String {
|
||||
self.api_prefix.read().clone()
|
||||
}
|
||||
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.is_ready.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn is_shutting_down(&self) -> bool {
|
||||
self.shutting_down.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub async fn is_child_running(&self) -> Result<bool> {
|
||||
let mut guard = self.child.lock().await;
|
||||
if let Some(child) = guard.as_mut() {
|
||||
match child.try_wait()? {
|
||||
None => return Ok(true),
|
||||
Some(_status) => {
|
||||
*guard = None;
|
||||
self.is_ready.store(false, Ordering::SeqCst);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn rewrite_path(&self, incoming_path: &str) -> String {
|
||||
// Strip /api prefix to get OpenCode path
|
||||
let result = incoming_path
|
||||
.strip_prefix("/api")
|
||||
.map(|rest| if rest.is_empty() { "/" } else { rest })
|
||||
.unwrap_or(incoming_path)
|
||||
.to_string();
|
||||
|
||||
debug!(
|
||||
"[opencode_manager] rewrite_path: '{}' -> '{}'",
|
||||
incoming_path, result
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
async fn spawn_process(&self) -> Result<Child> {
|
||||
let binary = self
|
||||
.binary
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Cannot spawn process: OpenCode CLI is not available"))?;
|
||||
|
||||
info!("[desktop:opencode] launching {} {:?}", binary, self.args);
|
||||
|
||||
let working_dir = self.working_dir.read().clone();
|
||||
let mut cmd = Command::new(binary);
|
||||
cmd.args(&self.args)
|
||||
.current_dir(&working_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(false);
|
||||
|
||||
for (key, value) in &self.env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
anyhow!(
|
||||
"OpenCode binary '{}' not found. Set OPENCODE_BINARY or ensure it's in PATH.",
|
||||
binary
|
||||
)
|
||||
} else {
|
||||
anyhow!("Failed to spawn OpenCode: {}", e)
|
||||
}
|
||||
})?;
|
||||
|
||||
// Set port immediately if pre-configured
|
||||
if self.desired_port > 0 {
|
||||
*self.port.write() = Some(self.desired_port);
|
||||
}
|
||||
|
||||
// Wait for first signal (stdout/stderr) within 750ms to confirm startup
|
||||
let first_signal_received = Arc::new(AtomicBool::new(false));
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let signal_flag = first_signal_received.clone();
|
||||
self.spawn_output_reader(stdout, "stdout", move || {
|
||||
signal_flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let signal_flag = first_signal_received.clone();
|
||||
self.spawn_output_reader(stderr, "stderr", move || {
|
||||
signal_flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for first signal or timeout
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < Duration::from_millis(FIRST_SIGNAL_TIMEOUT_MS) {
|
||||
if first_signal_received.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(_)) = child.try_wait() {
|
||||
return Err(anyhow!("OpenCode process exited immediately after spawn"));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
fn spawn_output_reader<F>(
|
||||
&self,
|
||||
stream: impl tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
label: &'static str,
|
||||
on_first_line: F,
|
||||
) where
|
||||
F: FnOnce() + Send + 'static,
|
||||
{
|
||||
let manager = self.clone();
|
||||
let first_line_flag = Arc::new(Mutex::new(Some(on_first_line)));
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let reader = BufReader::new(stream);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
// Trigger first signal callback
|
||||
if let Some(callback) = first_line_flag.lock().await.take() {
|
||||
callback();
|
||||
}
|
||||
|
||||
debug!("[opencode:{label}] {line}");
|
||||
manager.ingest_output_line(&line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn ingest_output_line(&self, line: &str) {
|
||||
if let Some(captures) = URL_REGEX.captures(line) {
|
||||
if let Some(port_match) = captures
|
||||
.name("port")
|
||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||
{
|
||||
*self.port.write() = Some(port_match);
|
||||
}
|
||||
|
||||
if let Some(path_match) = captures.name("path") {
|
||||
let value = path_match.as_str();
|
||||
if !value.is_empty() && value != "/" {
|
||||
*self.api_prefix.write() = value.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_port_detection(&self) -> Result<()> {
|
||||
let start = std::time::Instant::now();
|
||||
let timeout_duration = Duration::from_secs(15);
|
||||
|
||||
while start.elapsed() < timeout_duration {
|
||||
if self.current_port().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
Err(anyhow!("OpenCode did not report port within 15 seconds"))
|
||||
}
|
||||
|
||||
async fn wait_for_ready(&self) -> Result<()> {
|
||||
let Some(port) = self.current_port() else {
|
||||
return Err(anyhow!("Cannot check readiness without port"));
|
||||
};
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_millis(READY_CHECK_TIMEOUT_MS);
|
||||
let mut last_error: Option<String> = None;
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
let api_prefix = self.api_prefix();
|
||||
|
||||
// Try /config, /agent endpoints
|
||||
match self.check_endpoints(port, &api_prefix).await {
|
||||
Ok(()) => {
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(READY_CHECK_INTERVAL_MS)).await;
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"OpenCode not ready after {}ms: {}",
|
||||
READY_CHECK_TIMEOUT_MS,
|
||||
last_error.unwrap_or_else(|| "no error details".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
async fn check_endpoints(&self, port: u16, prefix: &str) -> Result<()> {
|
||||
let base_url = format!("http://127.0.0.1:{port}{prefix}");
|
||||
|
||||
let config_url = format!("{base_url}/config");
|
||||
let agent_url = format!("{base_url}/agent");
|
||||
|
||||
let (config_resp, agent_resp) = tokio::join!(
|
||||
self.http_client.get(&config_url).send(),
|
||||
self.http_client.get(&agent_url).send()
|
||||
);
|
||||
|
||||
let config_resp = config_resp?;
|
||||
if !config_resp.status().is_success() {
|
||||
return Err(anyhow!("/config returned {}", config_resp.status()));
|
||||
}
|
||||
|
||||
let agent_resp = agent_resp?;
|
||||
if !agent_resp.status().is_success() {
|
||||
return Err(anyhow!("/agent returned {}", agent_resp.status()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn graceful_stop(&self) -> Result<()> {
|
||||
let port_to_kill = self.current_port();
|
||||
|
||||
let mut guard = self.child.lock().await;
|
||||
let Some(mut child) = guard.take() else {
|
||||
// No child, but still kill by port in case of orphaned processes
|
||||
drop(guard);
|
||||
kill_process_on_port(port_to_kill);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if child.try_wait()?.is_some() {
|
||||
// Already exited, but still clean up by port
|
||||
drop(guard);
|
||||
kill_process_on_port(port_to_kill);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// SIGTERM
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::{
|
||||
sys::signal::{kill, Signal},
|
||||
unistd::Pid,
|
||||
};
|
||||
if let Some(id) = child.id() {
|
||||
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
|
||||
info!("[desktop:opencode] sent SIGTERM");
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
|
||||
// Wait 3 seconds for graceful exit
|
||||
match timeout(Duration::from_secs(3), child.wait()).await {
|
||||
Ok(_) => {
|
||||
info!("[desktop:opencode] exited gracefully");
|
||||
drop(guard);
|
||||
kill_process_on_port(port_to_kill);
|
||||
return Ok(());
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("[desktop:opencode] did not exit after SIGTERM, sending SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
// SIGKILL
|
||||
let _ = child.kill().await;
|
||||
|
||||
match timeout(Duration::from_secs(2), child.wait()).await {
|
||||
Ok(_) => {
|
||||
info!("[desktop:opencode] exited after SIGKILL");
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("[desktop:opencode] unresponsive after SIGKILL, continuing anyway");
|
||||
}
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
kill_process_on_port(port_to_kill);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_process_on_port(port: Option<u16>) {
|
||||
let Some(port) = port else { return };
|
||||
|
||||
// Kill any process listening on our port to clean up orphaned children.
|
||||
// The opencode CLI is a Node wrapper that spawns the actual binary as a child.
|
||||
// Killing the wrapper doesn't kill the child, so we kill by port.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
// First get PIDs, then kill them separately to avoid xargs issues
|
||||
if let Ok(output) = Command::new("lsof")
|
||||
.args(["-ti", &format!(":{}", port)])
|
||||
.output()
|
||||
{
|
||||
let pids = String::from_utf8_lossy(&output.stdout);
|
||||
for pid in pids.split_whitespace() {
|
||||
if let Ok(pid_num) = pid.trim().parse::<i32>() {
|
||||
// Don't kill our own process
|
||||
if pid_num != std::process::id() as i32 {
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", &pid_num.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if CLI binary exists (can be called dynamically for polling)
|
||||
pub fn check_cli_exists() -> bool {
|
||||
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
|
||||
return false;
|
||||
}
|
||||
resolve_opencode_binary().is_some()
|
||||
}
|
||||
|
||||
fn resolve_opencode_binary() -> Option<String> {
|
||||
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
|
||||
if !value.is_empty() && Path::new(&value).exists() {
|
||||
info!(
|
||||
"[desktop:opencode] using binary from OPENCODE_BINARY env: {}",
|
||||
value
|
||||
);
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
let shell_env = detect_shell_env();
|
||||
|
||||
if let Some(ref binary) = shell_env.opencode_binary {
|
||||
if Path::new(binary).exists() {
|
||||
info!(
|
||||
"[desktop:opencode] using binary from shell OPENCODE_BINARY: {}",
|
||||
binary
|
||||
);
|
||||
return Some(binary.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref login_path) = shell_env.path {
|
||||
for dir in login_path.split(':') {
|
||||
let candidate = format!("{}/opencode", dir);
|
||||
if Path::new(&candidate).exists() {
|
||||
info!("[desktop:opencode] found binary in PATH: {}", candidate);
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let fallback = home.join(".opencode/bin/opencode");
|
||||
if fallback.exists() {
|
||||
info!(
|
||||
"[desktop:opencode] found binary in fallback location: {:?}",
|
||||
fallback
|
||||
);
|
||||
return Some(fallback.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
warn!("[desktop:opencode] opencode binary not found");
|
||||
None
|
||||
}
|
||||
|
||||
fn build_augmented_env() -> HashMap<String, String> {
|
||||
let mut env: HashMap<String, String> = std::env::vars().collect();
|
||||
if let Ok(login_path) = detect_login_shell_path() {
|
||||
let current = env.get("PATH").cloned().unwrap_or_default();
|
||||
env.insert("PATH".to_string(), merge_paths(&login_path, ¤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(":")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ShellEnv {
|
||||
path: Option<String>,
|
||||
opencode_binary: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_user_shell() -> Option<String> {
|
||||
use std::process::Command;
|
||||
|
||||
let username =
|
||||
dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?;
|
||||
|
||||
let output = Command::new("dscl")
|
||||
.args([".", "-read", &format!("/Users/{}", username), "UserShell"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout.split(':').nth(1).map(|s| s.trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn get_user_shell() -> Option<String> {
|
||||
std::env::var("SHELL").ok()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn get_user_shell() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn build_shell_env_command(shell: &str) -> Vec<String> {
|
||||
let shell_name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("sh");
|
||||
|
||||
match shell_name {
|
||||
"nu" | "nushell" => vec![
|
||||
"-l".to_string(),
|
||||
"-i".to_string(),
|
||||
"-c".to_string(),
|
||||
"echo $\"__PATH__=($env.PATH | str join (char esep))\"; echo $\"__OPENCODE_BINARY__=($env.OPENCODE_BINARY? | default '')\"".to_string(),
|
||||
],
|
||||
"bash" => vec![
|
||||
"-lic".to_string(),
|
||||
"source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(),
|
||||
],
|
||||
_ => vec![
|
||||
"-lic".to_string(),
|
||||
"echo \"__PATH__=$PATH\"; echo \"__OPENCODE_BINARY__=$OPENCODE_BINARY\"".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_shell_env() -> ShellEnv {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
ShellEnv::default()
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into());
|
||||
info!("[desktop:opencode] detected user shell: {}", shell);
|
||||
let args = build_shell_env_command(&shell);
|
||||
info!("[desktop:opencode] shell args: {:?}", args);
|
||||
|
||||
let output = match Command::new(&shell).args(&args).output() {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
warn!("[desktop:opencode] failed to run shell {}: {}", shell, e);
|
||||
return ShellEnv::default();
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(
|
||||
"[desktop:opencode] shell env detection failed for {}, stderr: {}",
|
||||
shell, stderr
|
||||
);
|
||||
return ShellEnv::default();
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
info!("[desktop:opencode] shell stdout length: {}", stdout.len());
|
||||
let mut env = ShellEnv::default();
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(path) = line.strip_prefix("__PATH__=") {
|
||||
if !path.is_empty() {
|
||||
env.path = Some(path.to_string());
|
||||
}
|
||||
} else if let Some(binary) = line.strip_prefix("__OPENCODE_BINARY__=") {
|
||||
if !binary.is_empty() {
|
||||
env.opencode_binary = Some(binary.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"[desktop:opencode] parsed path exists: {}",
|
||||
env.path.is_some()
|
||||
);
|
||||
env
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_login_shell_path() -> Result<String> {
|
||||
detect_shell_env()
|
||||
.path
|
||||
.ok_or_else(|| anyhow!("shell PATH detection failed"))
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn expand_tilde_path(value: &str) -> PathBuf {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||
|
||||
if trimmed == "~" {
|
||||
return home;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("~/") || trimmed.starts_with("~\\") {
|
||||
return home.join(&trimmed[2..]);
|
||||
}
|
||||
|
||||
PathBuf::from(trimmed)
|
||||
}
|
||||
@@ -1,930 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Local, TimeZone};
|
||||
use log::warn;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::opencode_auth;
|
||||
|
||||
const OPENCODE_CONFIG_DIR: &str = ".config/opencode";
|
||||
const OPENCODE_DATA_DIR: &str = ".local/share/opencode";
|
||||
|
||||
const GOOGLE_CLIENT_ID: &str =
|
||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
|
||||
const GOOGLE_CLIENT_SECRET: &str = "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";
|
||||
const DEFAULT_PROJECT_ID: &str = "rising-fact-p41fc";
|
||||
const GOOGLE_WINDOW_SECONDS: i64 = 5 * 60 * 60;
|
||||
|
||||
const GOOGLE_ENDPOINTS: [&str; 3] = [
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
];
|
||||
|
||||
const GOOGLE_USER_AGENT: &str = "antigravity/1.11.5 windows/amd64";
|
||||
const GOOGLE_API_CLIENT: &str = "google-cloud-sdk vscode_cloudshelleditor/0.1";
|
||||
const GOOGLE_CLIENT_METADATA: &str =
|
||||
"{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct AuthEntry {
|
||||
token: Option<String>,
|
||||
access: Option<String>,
|
||||
refresh: Option<String>,
|
||||
expires: Option<i64>,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct GoogleAuth {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
expires: Option<i64>,
|
||||
project_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderResult {
|
||||
provider_id: String,
|
||||
provider_name: String,
|
||||
ok: bool,
|
||||
configured: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
usage: Option<ProviderUsage>,
|
||||
fetched_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProviderUsage {
|
||||
windows: HashMap<String, UsageWindow>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
models: Option<HashMap<String, ProviderUsage>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UsageWindow {
|
||||
used_percent: Option<f64>,
|
||||
remaining_percent: Option<f64>,
|
||||
window_seconds: Option<i64>,
|
||||
reset_after_seconds: Option<i64>,
|
||||
reset_at: Option<i64>,
|
||||
reset_at_formatted: Option<String>,
|
||||
reset_after_formatted: Option<String>,
|
||||
}
|
||||
|
||||
fn get_home_dir() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
}
|
||||
|
||||
fn opencode_config_dir() -> PathBuf {
|
||||
get_home_dir().join(OPENCODE_CONFIG_DIR)
|
||||
}
|
||||
|
||||
fn opencode_data_dir() -> PathBuf {
|
||||
get_home_dir().join(OPENCODE_DATA_DIR)
|
||||
}
|
||||
|
||||
fn antigravity_accounts_paths() -> [PathBuf; 2] {
|
||||
[
|
||||
opencode_config_dir().join("antigravity-accounts.json"),
|
||||
opencode_data_dir().join("antigravity-accounts.json"),
|
||||
]
|
||||
}
|
||||
|
||||
async fn read_json_file(path: &PathBuf) -> Option<Value> {
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
let raw = tokio::fs::read_to_string(path).await.ok()?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(trimmed).map_err(|err| {
|
||||
warn!("Failed to read JSON file {}: {}", path.display(), err);
|
||||
err
|
||||
}).ok()
|
||||
}
|
||||
|
||||
fn get_auth_entry<'a>(auth: &'a serde_json::Map<String, Value>, aliases: &[&str]) -> Option<&'a Value> {
|
||||
for alias in aliases {
|
||||
if let Some(value) = auth.get(*alias) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_auth_entry(value: Option<&Value>) -> Option<AuthEntry> {
|
||||
let value = value?;
|
||||
match value {
|
||||
Value::String(token) => Some(AuthEntry {
|
||||
token: Some(token.clone()),
|
||||
..AuthEntry::default()
|
||||
}),
|
||||
Value::Object(map) => {
|
||||
let token = map.get("token").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let access = map.get("access").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let refresh = map.get("refresh").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let key = map.get("key").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let expires = map
|
||||
.get("expires")
|
||||
.and_then(|v| v.as_i64())
|
||||
.or_else(|| map.get("expires").and_then(|v| v.as_f64()).map(|v| v.round() as i64));
|
||||
|
||||
Some(AuthEntry {
|
||||
token,
|
||||
access,
|
||||
refresh,
|
||||
expires,
|
||||
key,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reset_time(timestamp_ms: i64) -> Option<String> {
|
||||
let reset_dt = Local.timestamp_millis_opt(timestamp_ms).single()?;
|
||||
let now = Local::now();
|
||||
let is_today = reset_dt.date_naive() == now.date_naive();
|
||||
|
||||
if is_today {
|
||||
// Same day: show time only (e.g., "9:56 PM")
|
||||
Some(reset_dt.format("%-I:%M %p").to_string())
|
||||
} else {
|
||||
// Different day: show date + weekday + time (e.g., "Feb 2, Sun 9:56 PM")
|
||||
Some(reset_dt.format("%b %-d, %a %-I:%M %p").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_reset_after_seconds(reset_at: Option<i64>) -> Option<i64> {
|
||||
let reset_at = reset_at?;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let delta = (reset_at - now_ms) / 1000;
|
||||
Some(delta.max(0))
|
||||
}
|
||||
|
||||
fn to_usage_window(used_percent: Option<f64>, window_seconds: Option<i64>, reset_at: Option<i64>) -> UsageWindow {
|
||||
let remaining_percent = used_percent.map(|value| (100.0 - value).max(0.0));
|
||||
let reset_after_seconds = calculate_reset_after_seconds(reset_at);
|
||||
let reset_formatted = reset_at.and_then(format_reset_time);
|
||||
|
||||
UsageWindow {
|
||||
used_percent,
|
||||
remaining_percent,
|
||||
window_seconds,
|
||||
reset_after_seconds,
|
||||
reset_at,
|
||||
reset_at_formatted: reset_formatted.clone(),
|
||||
reset_after_formatted: reset_formatted,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_result(
|
||||
provider_id: &str,
|
||||
provider_name: &str,
|
||||
ok: bool,
|
||||
configured: bool,
|
||||
usage: Option<ProviderUsage>,
|
||||
error: Option<String>,
|
||||
) -> ProviderResult {
|
||||
ProviderResult {
|
||||
provider_id: provider_id.to_string(),
|
||||
provider_name: provider_name.to_string(),
|
||||
ok,
|
||||
configured,
|
||||
error,
|
||||
usage,
|
||||
fetched_at: chrono::Utc::now().timestamp_millis(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_auth_map() -> Result<serde_json::Map<String, Value>> {
|
||||
let auth = opencode_auth::read_auth().await?;
|
||||
auth.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))
|
||||
}
|
||||
|
||||
async fn has_antigravity_accounts() -> bool {
|
||||
for path in antigravity_accounts_paths() {
|
||||
if let Some(data) = read_json_file(&path).await {
|
||||
if data
|
||||
.get("accounts")
|
||||
.and_then(|value| value.as_array())
|
||||
.is_some_and(|accounts| !accounts.is_empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn list_configured_quota_providers() -> Result<Vec<String>> {
|
||||
let auth = load_auth_map().await?;
|
||||
let mut configured: HashSet<String> = HashSet::new();
|
||||
|
||||
let openai_auth = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"]));
|
||||
if let Some(entry) = openai_auth {
|
||||
if entry.access.is_some() || entry.token.is_some() {
|
||||
configured.insert("openai".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let google_auth = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"]));
|
||||
if let Some(entry) = google_auth {
|
||||
if entry.access.is_some() || entry.token.is_some() || entry.refresh.is_some() {
|
||||
configured.insert("google".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let zai_auth =
|
||||
normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"]));
|
||||
if let Some(entry) = zai_auth {
|
||||
if entry.key.is_some() || entry.token.is_some() {
|
||||
configured.insert("zai-coding-plan".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let github_copilot_auth =
|
||||
normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"]));
|
||||
if let Some(entry) = github_copilot_auth {
|
||||
if entry.access.is_some() || entry.token.is_some() {
|
||||
configured.insert("github-copilot".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if has_antigravity_accounts().await {
|
||||
configured.insert("google".to_string());
|
||||
}
|
||||
|
||||
Ok(configured.into_iter().collect())
|
||||
}
|
||||
|
||||
fn parse_number(value: Option<&Value>) -> Option<f64> {
|
||||
let value = value?;
|
||||
value.as_f64().or_else(|| value.as_i64().map(|v| v as f64))
|
||||
}
|
||||
|
||||
async fn fetch_openai_quota(client: &Client) -> Result<ProviderResult> {
|
||||
let auth = load_auth_map().await?;
|
||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["openai", "codex", "chatgpt"]));
|
||||
let access_token = entry
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.access.clone().or(entry.token.clone()));
|
||||
|
||||
let Some(access_token) = access_token else {
|
||||
return Ok(build_result(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Not configured".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let response = client
|
||||
.get("https://chatgpt.com/backend-api/wham/usage")
|
||||
.bearer_auth(access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let response = match response {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Ok(build_result(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(err.to_string()),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(build_result(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(format!("API error: {}", response.status().as_u16())),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = match response.json().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return Ok(build_result(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(err.to_string()),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let primary = payload
|
||||
.get("rate_limit")
|
||||
.and_then(|value| value.get("primary_window"));
|
||||
let secondary = payload
|
||||
.get("rate_limit")
|
||||
.and_then(|value| value.get("secondary_window"));
|
||||
|
||||
let mut windows: HashMap<String, UsageWindow> = HashMap::new();
|
||||
|
||||
if let Some(primary) = primary {
|
||||
let used_percent = parse_number(primary.get("used_percent"));
|
||||
let window_seconds = primary
|
||||
.get("limit_window_seconds")
|
||||
.and_then(|value| value.as_i64());
|
||||
let reset_at = primary
|
||||
.get("reset_at")
|
||||
.and_then(|value| value.as_i64())
|
||||
.map(|value| value * 1000);
|
||||
windows.insert(
|
||||
"5h".to_string(),
|
||||
to_usage_window(used_percent, window_seconds, reset_at),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(secondary) = secondary {
|
||||
let used_percent = parse_number(secondary.get("used_percent"));
|
||||
let window_seconds = secondary
|
||||
.get("limit_window_seconds")
|
||||
.and_then(|value| value.as_i64());
|
||||
let reset_at = secondary
|
||||
.get("reset_at")
|
||||
.and_then(|value| value.as_i64())
|
||||
.map(|value| value * 1000);
|
||||
windows.insert(
|
||||
"weekly".to_string(),
|
||||
to_usage_window(used_percent, window_seconds, reset_at),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(build_result(
|
||||
"openai",
|
||||
"OpenAI",
|
||||
true,
|
||||
true,
|
||||
Some(ProviderUsage {
|
||||
windows,
|
||||
models: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
async fn resolve_google_auth() -> Result<Option<GoogleAuth>> {
|
||||
let auth = load_auth_map().await?;
|
||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["google", "antigravity"]));
|
||||
|
||||
if let Some(entry) = entry {
|
||||
let mut refresh = entry.refresh.clone();
|
||||
let mut project_id = None;
|
||||
if let Some(value) = entry.refresh.clone() {
|
||||
if let Some((first, second)) = value.split_once('|') {
|
||||
refresh = Some(first.to_string());
|
||||
project_id = Some(second.to_string());
|
||||
}
|
||||
}
|
||||
return Ok(Some(GoogleAuth {
|
||||
access_token: entry.access.or(entry.token),
|
||||
refresh_token: refresh,
|
||||
expires: entry.expires,
|
||||
project_id,
|
||||
}));
|
||||
}
|
||||
|
||||
for path in antigravity_accounts_paths() {
|
||||
let data = match read_json_file(&path).await {
|
||||
Some(data) => data,
|
||||
None => continue,
|
||||
};
|
||||
let accounts = data.get("accounts").and_then(|value| value.as_array());
|
||||
if let Some(accounts) = accounts {
|
||||
if accounts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let index = data
|
||||
.get("activeIndex")
|
||||
.and_then(|value| value.as_i64())
|
||||
.unwrap_or(0)
|
||||
.max(0) as usize;
|
||||
let account = accounts.get(index).or_else(|| accounts.first());
|
||||
if let Some(account) = account {
|
||||
let refresh_token = account
|
||||
.get("refreshToken")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string());
|
||||
if refresh_token.is_none() {
|
||||
continue;
|
||||
}
|
||||
let project_id = account
|
||||
.get("projectId")
|
||||
.and_then(|value| value.as_str())
|
||||
.or_else(|| {
|
||||
account
|
||||
.get("managedProjectId")
|
||||
.and_then(|value| value.as_str())
|
||||
})
|
||||
.map(|value| value.to_string());
|
||||
|
||||
return Ok(Some(GoogleAuth {
|
||||
access_token: None,
|
||||
refresh_token,
|
||||
expires: None,
|
||||
project_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh_google_access_token(client: &Client, refresh_token: &str) -> Result<Option<String>> {
|
||||
let body = format!(
|
||||
"client_id={}&client_secret={}&refresh_token={}&grant_type=refresh_token",
|
||||
urlencoding::encode(GOOGLE_CLIENT_ID),
|
||||
urlencoding::encode(GOOGLE_CLIENT_SECRET),
|
||||
urlencoding::encode(refresh_token)
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(body)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let response = match response {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
warn!("Failed to refresh Google token: {}", err);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let payload: Value = response.json().await.unwrap_or(Value::Null);
|
||||
Ok(payload
|
||||
.get("access_token")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_google_models(client: &Client, access_token: &str, project_id: Option<&str>) -> Option<Value> {
|
||||
let body = if let Some(project_id) = project_id {
|
||||
serde_json::json!({ "project": project_id })
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
for endpoint in GOOGLE_ENDPOINTS {
|
||||
let response = client
|
||||
.post(format!("{}/v1internal:fetchAvailableModels", endpoint))
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", GOOGLE_USER_AGENT)
|
||||
.header("X-Goog-Api-Client", GOOGLE_API_CLIENT)
|
||||
.header("Client-Metadata", GOOGLE_CLIENT_METADATA)
|
||||
.json(&body)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let response = match response {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if response.status().is_success() {
|
||||
if let Ok(payload) = response.json::<Value>().await {
|
||||
return Some(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_reset_time(value: Option<&Value>) -> Option<i64> {
|
||||
let value = value?;
|
||||
if let Some(num) = value.as_i64() {
|
||||
if num > 0 {
|
||||
return Some(num);
|
||||
}
|
||||
}
|
||||
if let Some(text) = value.as_str() {
|
||||
if let Ok(parsed) = DateTime::parse_from_rfc3339(text) {
|
||||
return Some(parsed.timestamp_millis());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_google_quota(client: &Client) -> Result<ProviderResult> {
|
||||
let auth = resolve_google_auth().await?;
|
||||
let Some(auth) = auth else {
|
||||
return Ok(build_result(
|
||||
"google",
|
||||
"Google",
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Not configured".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let mut access_token = auth.access_token;
|
||||
if access_token.is_none()
|
||||
|| auth
|
||||
.expires
|
||||
.is_some_and(|expires| expires <= now)
|
||||
{
|
||||
let Some(refresh_token) = auth.refresh_token.as_ref() else {
|
||||
return Ok(build_result(
|
||||
"google",
|
||||
"Google",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some("Missing refresh token".to_string()),
|
||||
));
|
||||
};
|
||||
access_token = refresh_google_access_token(client, refresh_token).await?;
|
||||
}
|
||||
|
||||
let Some(access_token) = access_token else {
|
||||
return Ok(build_result(
|
||||
"google",
|
||||
"Google",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some("Failed to refresh OAuth token".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let project_id = auth.project_id.unwrap_or_else(|| DEFAULT_PROJECT_ID.to_string());
|
||||
let payload = fetch_google_models(client, &access_token, Some(project_id.as_str())).await;
|
||||
let Some(payload) = payload else {
|
||||
return Ok(build_result(
|
||||
"google",
|
||||
"Google",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some("Failed to fetch models".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let mut models: HashMap<String, ProviderUsage> = HashMap::new();
|
||||
if let Some(model_map) = payload.get("models").and_then(|value| value.as_object()) {
|
||||
for (model_name, model_data) in model_map {
|
||||
let remaining_fraction = parse_number(model_data.get("quotaInfo").and_then(|v| v.get("remainingFraction")));
|
||||
let remaining_percent = remaining_fraction.map(|value| (value * 100.0).round());
|
||||
let used_percent = remaining_percent.map(|value| (100.0 - value).max(0.0));
|
||||
let reset_at = parse_reset_time(model_data.get("quotaInfo").and_then(|v| v.get("resetTime")));
|
||||
|
||||
let mut windows = HashMap::new();
|
||||
windows.insert(
|
||||
"5h".to_string(),
|
||||
to_usage_window(used_percent, Some(GOOGLE_WINDOW_SECONDS), reset_at),
|
||||
);
|
||||
models.insert(
|
||||
model_name.to_string(),
|
||||
ProviderUsage {
|
||||
windows,
|
||||
models: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(build_result(
|
||||
"google",
|
||||
"Google",
|
||||
true,
|
||||
true,
|
||||
Some(ProviderUsage {
|
||||
windows: HashMap::new(),
|
||||
models: if models.is_empty() { None } else { Some(models) },
|
||||
}),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_timestamp(value: Option<&Value>) -> Option<i64> {
|
||||
let value = value?;
|
||||
if let Some(num) = value.as_i64() {
|
||||
if num < 1_000_000_000_000 {
|
||||
return Some(num * 1000);
|
||||
}
|
||||
return Some(num);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_window_seconds(limit: &Value) -> Option<i64> {
|
||||
let number = limit.get("number").and_then(|value| value.as_i64())?;
|
||||
let unit = limit.get("unit").and_then(|value| value.as_i64())?;
|
||||
let unit_seconds = match unit {
|
||||
3 => Some(3600),
|
||||
_ => None,
|
||||
}?;
|
||||
Some(unit_seconds * number)
|
||||
}
|
||||
|
||||
fn resolve_window_label(window_seconds: Option<i64>) -> String {
|
||||
let Some(window_seconds) = window_seconds else {
|
||||
return "tokens".to_string();
|
||||
};
|
||||
if window_seconds % 86400 == 0 {
|
||||
let days = window_seconds / 86400;
|
||||
if days == 7 {
|
||||
return "weekly".to_string();
|
||||
}
|
||||
return format!("{}d", days);
|
||||
}
|
||||
if window_seconds % 3600 == 0 {
|
||||
return format!("{}h", window_seconds / 3600);
|
||||
}
|
||||
format!("{}s", window_seconds)
|
||||
}
|
||||
|
||||
async fn fetch_zai_quota(client: &Client) -> Result<ProviderResult> {
|
||||
let auth = load_auth_map().await?;
|
||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["zai-coding-plan", "zai", "z.ai"]));
|
||||
let api_key = entry
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.key.clone().or(entry.token.clone()));
|
||||
|
||||
let Some(api_key) = api_key else {
|
||||
return Ok(build_result(
|
||||
"zai-coding-plan",
|
||||
"z.ai",
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Not configured".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let response = client
|
||||
.get("https://api.z.ai/api/monitor/usage/quota/limit")
|
||||
.bearer_auth(api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let response = match response {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Ok(build_result(
|
||||
"zai-coding-plan",
|
||||
"z.ai",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(err.to_string()),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(build_result(
|
||||
"zai-coding-plan",
|
||||
"z.ai",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(format!("API error: {}", response.status().as_u16())),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = match response.json().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return Ok(build_result(
|
||||
"zai-coding-plan",
|
||||
"z.ai",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(err.to_string()),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let limits = payload
|
||||
.get("data")
|
||||
.and_then(|value| value.get("limits"))
|
||||
.and_then(|value| value.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let tokens_limit = limits
|
||||
.iter()
|
||||
.find(|limit| limit.get("type").and_then(|value| value.as_str()) == Some("TOKENS_LIMIT"));
|
||||
|
||||
let mut windows = HashMap::new();
|
||||
if let Some(limit) = tokens_limit {
|
||||
let window_seconds = resolve_window_seconds(limit);
|
||||
let window_label = resolve_window_label(window_seconds);
|
||||
let reset_at = normalize_timestamp(limit.get("nextResetTime"));
|
||||
let used_percent = parse_number(limit.get("percentage"));
|
||||
|
||||
windows.insert(
|
||||
window_label,
|
||||
to_usage_window(used_percent, window_seconds, reset_at),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(build_result(
|
||||
"zai-coding-plan",
|
||||
"z.ai",
|
||||
true,
|
||||
true,
|
||||
Some(ProviderUsage {
|
||||
windows,
|
||||
models: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_github_copilot_quota(client: &Client) -> Result<ProviderResult> {
|
||||
let auth = load_auth_map().await?;
|
||||
let entry = normalize_auth_entry(get_auth_entry(&auth, &["github-copilot"]));
|
||||
let access_token = entry
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.access.clone().or(entry.token.clone()));
|
||||
|
||||
let Some(access_token) = access_token else {
|
||||
return Ok(build_result(
|
||||
"github-copilot",
|
||||
"GitHub Copilot",
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Not configured".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let response = client
|
||||
.get("https://api.github.com/copilot_internal/user")
|
||||
.bearer_auth(access_token)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "OpenChamber")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let response = match response {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Ok(build_result(
|
||||
"github-copilot",
|
||||
"GitHub Copilot",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(err.to_string()),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(build_result(
|
||||
"github-copilot",
|
||||
"GitHub Copilot",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(format!("API error: {}", response.status().as_u16())),
|
||||
));
|
||||
}
|
||||
|
||||
let payload: Value = match response.json().await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return Ok(build_result(
|
||||
"github-copilot",
|
||||
"GitHub Copilot",
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
Some(err.to_string()),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Parse reset date
|
||||
let mut reset_at: Option<i64> = None;
|
||||
let reset_date_utc = payload
|
||||
.get("quota_reset_date_utc")
|
||||
.and_then(|v| v.as_str());
|
||||
let reset_date = payload
|
||||
.get("quota_reset_date")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
if let Some(date_str) = reset_date_utc {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
|
||||
reset_at = Some(dt.timestamp_millis());
|
||||
}
|
||||
} else if let Some(date_str) = reset_date {
|
||||
// Use the date as UTC midnight
|
||||
let full_date = format!("{}T00:00:00Z", date_str);
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(&full_date) {
|
||||
reset_at = Some(dt.timestamp_millis());
|
||||
}
|
||||
}
|
||||
|
||||
let mut windows: HashMap<String, UsageWindow> = HashMap::new();
|
||||
|
||||
// Get premium_interactions snapshot
|
||||
if let Some(snapshots) = payload.get("quota_snapshots") {
|
||||
if let Some(premium) = snapshots.get("premium_interactions") {
|
||||
let mut used_percent: Option<f64> = None;
|
||||
|
||||
let unlimited = premium
|
||||
.get("unlimited")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !unlimited {
|
||||
if let Some(percent_remaining) = premium.get("percent_remaining").and_then(|v| v.as_f64()) {
|
||||
used_percent = Some(100.0 - percent_remaining);
|
||||
} else if let Some(entitlement) = premium.get("entitlement").and_then(|v| v.as_f64()) {
|
||||
if entitlement > 0.0 {
|
||||
let remaining = premium
|
||||
.get("remaining")
|
||||
.and_then(|v| v.as_f64())
|
||||
.or_else(|| premium.get("quota_remaining").and_then(|v| v.as_f64()));
|
||||
|
||||
if let Some(rem) = remaining {
|
||||
used_percent = Some(((entitlement - rem) / entitlement) * 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
windows.insert(
|
||||
"premium_interactions".to_string(),
|
||||
to_usage_window(used_percent, None, reset_at),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(build_result(
|
||||
"github-copilot",
|
||||
"GitHub Copilot",
|
||||
true,
|
||||
true,
|
||||
Some(ProviderUsage {
|
||||
windows,
|
||||
models: None,
|
||||
}),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn fetch_quota_for_provider(client: &Client, provider_id: &str) -> Result<ProviderResult> {
|
||||
match provider_id {
|
||||
"openai" => fetch_openai_quota(client).await,
|
||||
"google" => fetch_google_quota(client).await,
|
||||
"zai-coding-plan" => fetch_zai_quota(client).await,
|
||||
"github-copilot" => fetch_github_copilot_quota(client).await,
|
||||
_ => Ok(build_result(
|
||||
provider_id,
|
||||
provider_id,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
Some("Unsupported provider".to_string()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::TryStreamExt;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EventEnvelope {
|
||||
#[serde(rename = "type")]
|
||||
event_type: String,
|
||||
#[serde(default)]
|
||||
properties: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MultiplexedEventEnvelope {
|
||||
#[serde(default)]
|
||||
directory: Option<String>,
|
||||
payload: EventEnvelope,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ActivityPhase {
|
||||
Idle,
|
||||
Busy,
|
||||
Cooldown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum SseScope {
|
||||
Global,
|
||||
Directory(std::path::PathBuf),
|
||||
}
|
||||
|
||||
pub fn spawn_session_activity_tracker(
|
||||
app: AppHandle,
|
||||
runtime: DesktopRuntime,
|
||||
) -> tauri::async_runtime::JoinHandle<()> {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(24 * 60 * 60))
|
||||
.tcp_keepalive(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.expect("failed to build reqwest client");
|
||||
|
||||
let mut shutdown_rx = runtime.subscribe_shutdown();
|
||||
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
|
||||
let cooldowns = Arc::new(Mutex::new(HashMap::<
|
||||
String,
|
||||
tauri::async_runtime::JoinHandle<()>,
|
||||
>::new()));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.recv() => {
|
||||
info!("[desktop:activity] Shutdown received, stopping SSE listener");
|
||||
break;
|
||||
}
|
||||
_ = async {
|
||||
// Reset stale phases to idle before connecting so UI doesn't stay stuck on "working" after wake.
|
||||
reset_and_emit_all_phases(&app, phases.clone(), cooldowns.clone()).await;
|
||||
|
||||
if let Err(err) = run_once(&app, &runtime, &client, phases.clone(), cooldowns.clone()).await {
|
||||
warn!("[desktop:activity] SSE loop error: {err:?}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
} => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_once(
|
||||
app: &AppHandle,
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||
) -> Result<()> {
|
||||
let opencode = runtime.opencode_manager();
|
||||
|
||||
let port = match opencode.current_port() {
|
||||
Some(port) => port,
|
||||
None => {
|
||||
warn!("[desktop:activity] OpenCode port unavailable; will retry");
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let prefix = opencode.api_prefix();
|
||||
let base = format!("http://127.0.0.1:{port}{prefix}");
|
||||
let (response, scope) = connect_activity_sse(runtime, client, &base).await?;
|
||||
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
|
||||
let mut reader = StreamReader::new(stream);
|
||||
let mut buf = Vec::new();
|
||||
let mut data_lines: Vec<String> = Vec::new();
|
||||
|
||||
loop {
|
||||
buf.clear();
|
||||
let bytes_read = match tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
reader.read_until(b'\n', &mut buf),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(err)) => {
|
||||
warn!("[desktop:activity] Read error in SSE stream: {err:?}");
|
||||
return Err(err.into());
|
||||
}
|
||||
Err(_) => {
|
||||
// No data received recently; if we are connected to a directory-scoped stream and the working directory
|
||||
// has changed, reconnect so activity tracking follows the new directory.
|
||||
if let SseScope::Directory(connected_dir) = &scope {
|
||||
if let Some(current_dir) =
|
||||
resolve_project_directory_from_settings(runtime).await
|
||||
{
|
||||
if current_dir != *connected_dir {
|
||||
debug!(
|
||||
"[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})",
|
||||
connected_dir, current_dir
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let line = match std::str::from_utf8(&buf) {
|
||||
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
|
||||
Err(err) => {
|
||||
warn!("[desktop:activity] Non-UTF8 SSE chunk: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if line.is_empty() {
|
||||
if data_lines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let raw = data_lines.join("\n");
|
||||
data_lines.clear();
|
||||
|
||||
match parse_event_envelope(&raw) {
|
||||
Ok((event, _directory)) => {
|
||||
handle_event(app, event, phases.clone(), cooldowns.clone()).await
|
||||
}
|
||||
Err(err) => warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}"),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
data_lines.push(rest.trim_start().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option<String>)> {
|
||||
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
|
||||
return Ok((event, None));
|
||||
}
|
||||
|
||||
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
|
||||
Ok((multiplexed.payload, multiplexed.directory))
|
||||
}
|
||||
|
||||
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
||||
let settings = runtime.settings().load().await.ok()?;
|
||||
|
||||
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
||||
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
||||
if let Some(path) = projects.iter().find_map(|entry| {
|
||||
let id = entry.get("id").and_then(Value::as_str)?;
|
||||
if id != active_id {
|
||||
return None;
|
||||
}
|
||||
entry.get("path").and_then(Value::as_str)
|
||||
}) {
|
||||
return Some(expand_tilde_path(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings
|
||||
.get("lastDirectory")
|
||||
.and_then(Value::as_str)
|
||||
.map(expand_tilde_path)
|
||||
}
|
||||
|
||||
async fn connect_activity_sse(
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
base: &str,
|
||||
) -> Result<(reqwest::Response, SseScope)> {
|
||||
let global_url = format!("{base}/global/event");
|
||||
match try_connect_sse(client, &global_url, "[desktop:activity]").await {
|
||||
Ok(response) => {
|
||||
debug!("[desktop:activity] Using SSE endpoint: {global_url}");
|
||||
return Ok((response, SseScope::Global));
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"[desktop:activity] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let event_url = format!("{base}/event");
|
||||
match try_connect_sse(client, &event_url, "[desktop:activity]").await {
|
||||
Ok(response) => {
|
||||
debug!("[desktop:activity] Using SSE endpoint: {event_url}");
|
||||
return Ok((response, SseScope::Global));
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"[desktop:activity] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
||||
anyhow::bail!("No project directory available for SSE fallback");
|
||||
};
|
||||
let directory = working_dir.to_string_lossy().to_string();
|
||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
||||
parsed
|
||||
.query_pairs_mut()
|
||||
.append_pair("directory", &directory);
|
||||
let directory_url = parsed.to_string();
|
||||
|
||||
let response = try_connect_sse(client, &directory_url, "[desktop:activity]").await?;
|
||||
debug!("[desktop:activity] Using directory-scoped SSE endpoint: {directory_url}");
|
||||
Ok((response, SseScope::Directory(working_dir)))
|
||||
}
|
||||
|
||||
async fn try_connect_sse(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
log_prefix: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
debug!("{log_prefix} Connecting SSE: {url}");
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
"{log_prefix} SSE response status={} headers={:?}",
|
||||
response.status(),
|
||||
response.headers()
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("SSE connect failed with status {}", response.status());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
app: &AppHandle,
|
||||
event: EventEnvelope,
|
||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||
) {
|
||||
match event.event_type.as_str() {
|
||||
"session.status" => {
|
||||
let session_id = event
|
||||
.properties
|
||||
.get("sessionID")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
let status = event
|
||||
.properties
|
||||
.get("status")
|
||||
.and_then(|s| s.get("type"))
|
||||
.and_then(Value::as_str);
|
||||
|
||||
if let (Some(id), Some(status_type)) = (session_id, status) {
|
||||
let phase = if status_type == "busy" || status_type == "retry" {
|
||||
ActivityPhase::Busy
|
||||
} else {
|
||||
ActivityPhase::Idle
|
||||
};
|
||||
set_phase(app, &id, phase, phases.clone(), cooldowns.clone()).await;
|
||||
}
|
||||
}
|
||||
"session.idle" => {
|
||||
let session_id = event
|
||||
.properties
|
||||
.get("sessionID")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
if let Some(id) = session_id {
|
||||
set_phase(
|
||||
app,
|
||||
&id,
|
||||
ActivityPhase::Idle,
|
||||
phases.clone(),
|
||||
cooldowns.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"message.updated" => {
|
||||
if let Some(info) = event.properties.get("info") {
|
||||
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
||||
if role != "assistant" {
|
||||
return;
|
||||
}
|
||||
|
||||
let finish = info.get("finish").and_then(Value::as_str);
|
||||
if finish != Some("stop") {
|
||||
return;
|
||||
}
|
||||
|
||||
let session_id = info
|
||||
.get("sessionID")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(id) = session_id {
|
||||
enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
"message.part.updated" => {
|
||||
let Some(info) = event.properties.get("info") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
|
||||
if role != "assistant" {
|
||||
return;
|
||||
}
|
||||
|
||||
let session_id = info
|
||||
.get("sessionID")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let Some(id) = session_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Mark session busy when we see assistant parts streaming (covers cases where session.status is missing).
|
||||
if is_streaming_assistant_part(&event.properties) {
|
||||
set_phase(
|
||||
app,
|
||||
&id,
|
||||
ActivityPhase::Busy,
|
||||
phases.clone(),
|
||||
cooldowns.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Derive cooldown from info.finish === 'stop' when present.
|
||||
if has_finish_stop(info) {
|
||||
enter_cooldown_if_busy(app, &id, phases.clone(), cooldowns.clone()).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_streaming_assistant_part(properties: &Value) -> bool {
|
||||
let Some(part) = properties.get("part") else {
|
||||
return false;
|
||||
};
|
||||
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
matches!(
|
||||
part_type,
|
||||
"step-start" | "text" | "tool" | "reasoning" | "file" | "patch"
|
||||
)
|
||||
}
|
||||
|
||||
fn has_finish_stop(info: &Value) -> bool {
|
||||
info.get("finish").and_then(Value::as_str) == Some("stop")
|
||||
}
|
||||
|
||||
async fn enter_cooldown_if_busy(
|
||||
app: &AppHandle,
|
||||
session_id: &str,
|
||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||
) {
|
||||
let current = { phases.lock().await.get(session_id).cloned() };
|
||||
if !matches!(current, Some(ActivityPhase::Busy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set_phase(
|
||||
app,
|
||||
session_id,
|
||||
ActivityPhase::Cooldown,
|
||||
phases.clone(),
|
||||
cooldowns.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let app_clone = app.clone();
|
||||
let phases_clone = phases.clone();
|
||||
let cooldowns_clone = cooldowns.clone();
|
||||
let id_clone = session_id.to_string();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let current = { phases_clone.lock().await.get(&id_clone).cloned() };
|
||||
if matches!(current, Some(ActivityPhase::Cooldown)) {
|
||||
set_phase(
|
||||
&app_clone,
|
||||
&id_clone,
|
||||
ActivityPhase::Idle,
|
||||
phases_clone,
|
||||
cooldowns_clone,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut cd = cooldowns.lock().await;
|
||||
if let Some(prev) = cd.remove(session_id) {
|
||||
prev.abort();
|
||||
}
|
||||
cd.insert(session_id.to_string(), handle);
|
||||
}
|
||||
|
||||
async fn set_phase(
|
||||
app: &AppHandle,
|
||||
session_id: &str,
|
||||
phase: ActivityPhase,
|
||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||
) {
|
||||
{
|
||||
let mut map = phases.lock().await;
|
||||
let current = map.get(session_id);
|
||||
if current == Some(&phase) {
|
||||
return;
|
||||
}
|
||||
map.insert(session_id.to_string(), phase.clone());
|
||||
|
||||
// Cancel cooldown timer when leaving cooldown
|
||||
if !matches!(phase, ActivityPhase::Cooldown) {
|
||||
if let Some(handle) = cooldowns.lock().await.remove(session_id) {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit to webview so UI stays in sync
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"phase": match phase {
|
||||
ActivityPhase::Idle => "idle",
|
||||
ActivityPhase::Busy => "busy",
|
||||
ActivityPhase::Cooldown => "cooldown",
|
||||
}
|
||||
});
|
||||
|
||||
let _ = app.emit("openchamber:session-activity", payload);
|
||||
}
|
||||
|
||||
async fn reset_and_emit_all_phases(
|
||||
app: &AppHandle,
|
||||
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
|
||||
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
|
||||
) {
|
||||
// Cancel any cooldown timers and set all phases to idle to avoid stale "busy" after wake.
|
||||
{
|
||||
let mut cd = cooldowns.lock().await;
|
||||
for handle in cd.values() {
|
||||
handle.abort();
|
||||
}
|
||||
cd.clear();
|
||||
}
|
||||
|
||||
let snapshot = {
|
||||
let mut guard = phases.lock().await;
|
||||
for value in guard.values_mut() {
|
||||
*value = ActivityPhase::Idle;
|
||||
}
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
if snapshot.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (session_id, phase) in snapshot {
|
||||
let payload = serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"phase": match phase {
|
||||
ActivityPhase::Idle => "idle",
|
||||
ActivityPhase::Busy => "busy",
|
||||
ActivityPhase::Cooldown => "cooldown",
|
||||
}
|
||||
});
|
||||
let _ = app.emit("openchamber:session-activity", payload);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,167 +0,0 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tauri::{LogicalPosition, LogicalSize, WebviewWindow, Window};
|
||||
use tokio::fs as async_fs;
|
||||
|
||||
const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WindowState {
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub is_maximized: bool,
|
||||
}
|
||||
|
||||
impl Default for WindowState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 1280.0,
|
||||
height: 800.0,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
is_maximized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct WindowStateFile {
|
||||
#[serde(rename = "windowState")]
|
||||
pub window_state: WindowState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WindowStateManager {
|
||||
inner: Arc<Mutex<WindowState>>,
|
||||
}
|
||||
|
||||
impl WindowStateManager {
|
||||
pub fn new(initial: WindowState) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(initial)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> WindowState {
|
||||
self.inner.lock().expect("window state poisoned").clone()
|
||||
}
|
||||
|
||||
pub fn update_position(&self, x: f64, y: f64, is_maximized: bool) {
|
||||
if is_maximized {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut state) = self.inner.lock() {
|
||||
if !state.is_maximized {
|
||||
state.x = x;
|
||||
state.y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_size(&self, width: f64, height: f64, is_maximized: bool) {
|
||||
if let Ok(mut state) = self.inner.lock() {
|
||||
if !is_maximized {
|
||||
state.width = width;
|
||||
state.height = height;
|
||||
}
|
||||
state.is_maximized = is_maximized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_file_path() -> Result<PathBuf> {
|
||||
let mut path = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?;
|
||||
path.push(".config");
|
||||
path.push("openchamber");
|
||||
path.push(WINDOW_STATE_FILE);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub async fn load_window_state() -> Result<Option<WindowState>> {
|
||||
let path = state_file_path()?;
|
||||
match async_fs::read(&path).await {
|
||||
Ok(bytes) => {
|
||||
let file: WindowStateFile = serde_json::from_slice(&bytes)?;
|
||||
Ok(Some(file.window_state))
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_window_state(state: &WindowState) -> Result<()> {
|
||||
let path = state_file_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
async_fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let payload = WindowStateFile {
|
||||
window_state: state.clone(),
|
||||
};
|
||||
let data = serde_json::to_vec_pretty(&payload)?;
|
||||
async_fs::write(&path, data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_window_state(window: &WebviewWindow, state: &WindowState) -> Result<()> {
|
||||
let mut normalized = state.clone();
|
||||
clamp_to_visible_region(window, &mut normalized);
|
||||
|
||||
if normalized.width > 0.0 && normalized.height > 0.0 {
|
||||
let _ = window.set_size(LogicalSize::new(normalized.width, normalized.height));
|
||||
}
|
||||
let _ = window.set_position(LogicalPosition::new(normalized.x, normalized.y));
|
||||
if state.is_maximized {
|
||||
let _ = window.maximize();
|
||||
} else {
|
||||
let _ = window.unmaximize();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn persist_window_state(window: &Window, manager: &WindowStateManager) -> Result<()> {
|
||||
let mut snapshot = manager.snapshot();
|
||||
let is_maximized = window.is_maximized().unwrap_or(snapshot.is_maximized);
|
||||
snapshot.is_maximized = is_maximized;
|
||||
|
||||
if !is_maximized {
|
||||
let scale_factor = window.scale_factor().unwrap_or(1.0);
|
||||
if let Ok(size) = window.outer_size() {
|
||||
let logical: LogicalSize<f64> = size.to_logical(scale_factor);
|
||||
snapshot.width = logical.width.max(200.0);
|
||||
snapshot.height = logical.height.max(200.0);
|
||||
}
|
||||
if let Ok(position) = window.outer_position() {
|
||||
let logical: LogicalPosition<f64> = position.to_logical(scale_factor);
|
||||
snapshot.x = logical.x;
|
||||
snapshot.y = logical.y;
|
||||
}
|
||||
}
|
||||
|
||||
save_window_state(&snapshot).await
|
||||
}
|
||||
|
||||
fn clamp_to_visible_region(window: &WebviewWindow, state: &mut WindowState) {
|
||||
let monitor = match window.current_monitor() {
|
||||
Ok(Some(monitor)) => monitor,
|
||||
_ => return,
|
||||
};
|
||||
let scale_factor = monitor.scale_factor();
|
||||
let monitor_size: LogicalSize<f64> = monitor.size().to_logical(scale_factor);
|
||||
let monitor_position: LogicalPosition<f64> = monitor.position().to_logical(scale_factor);
|
||||
|
||||
state.width = state.width.clamp(400.0, monitor_size.width);
|
||||
state.height = state.height.clamp(300.0, monitor_size.height);
|
||||
|
||||
let max_x = monitor_position.x + (monitor_size.width - state.width).max(0.0);
|
||||
let max_y = monitor_position.y + (monitor_size.height - state.height).max(0.0);
|
||||
|
||||
state.x = state.x.clamp(monitor_position.x, max_x);
|
||||
state.y = state.y.clamp(monitor_position.y, max_y);
|
||||
}
|
||||
Reference in New Issue
Block a user