Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
@@ -0,0 +1,280 @@
use std::{collections::HashSet, time::Duration};
use anyhow::Result;
use futures_util::TryStreamExt;
use log::{debug, info, warn};
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use tauri::{AppHandle, Manager};
use tauri_plugin_notification::NotificationExt;
use tokio::{io::AsyncBufReadExt, sync::Mutex};
use tokio_util::io::StreamReader;
use crate::DesktopRuntime;
#[derive(Deserialize)]
struct EventEnvelope {
#[serde(rename = "type")]
event_type: String,
#[serde(default)]
properties: Value,
}
pub fn spawn_assistant_notifications(
app: AppHandle,
runtime: DesktopRuntime,
) -> tauri::async_runtime::JoinHandle<()> {
tauri::async_runtime::spawn(async move {
let client = Client::builder()
// Give SSE a very long overall timeout so idle periods don't abort the stream.
.timeout(Duration::from_secs(24 * 60 * 60))
.tcp_keepalive(Some(Duration::from_secs(30)))
.build()
.expect("failed to build reqwest client");
let mut shutdown_rx = runtime.subscribe_shutdown();
let notified_messages = Mutex::new(HashSet::<String>::new());
loop {
tokio::select! {
_ = shutdown_rx.recv() => {
info!("[desktop:notify] Shutdown received, stopping SSE listener");
break;
}
_ = async {
if let Err(err) = run_once(&app, &runtime, &client, &notified_messages).await {
warn!("[desktop:notify] SSE loop error: {err:?}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
} => {}
}
}
})
}
async fn run_once(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
notified_messages: &Mutex<HashSet<String>>,
) -> Result<()> {
let opencode = runtime.opencode_manager();
let port = match opencode.current_port() {
Some(port) => port,
None => {
warn!("[desktop:notify] OpenCode port unavailable; will retry");
tokio::time::sleep(Duration::from_secs(2)).await;
return Ok(());
}
};
let prefix = opencode.api_prefix();
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
let mut parsed = reqwest::Url::parse(&url)?;
parsed
.query_pairs_mut()
.append_pair("directory", &dir);
url = parsed.to_string();
}
debug!("[desktop:notify] Connecting SSE for notifications: {url}");
let response = client
.get(&url)
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.send()
.await?;
debug!(
"[desktop:notify] SSE response status={} headers={:?}",
response.status(),
response.headers()
);
if !response.status().is_success() {
warn!(
"[desktop:notify] SSE connect failed with status {}",
response.status()
);
tokio::time::sleep(Duration::from_secs(2)).await;
return Ok(());
}
let stream = response
.bytes_stream()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
let mut reader = StreamReader::new(stream);
let mut buf = Vec::new();
let mut data_lines: Vec<String> = Vec::new();
loop {
buf.clear();
let bytes_read = match reader.read_until(b'\n', &mut buf).await {
Ok(n) => n,
Err(err) => {
warn!("[desktop:notify] Read error in SSE stream: {err:?}");
return Err(err.into());
}
};
if bytes_read == 0 {
break;
}
let line = match std::str::from_utf8(&buf) {
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
Err(err) => {
warn!("[desktop:notify] Non-UTF8 SSE chunk: {err}");
continue;
}
};
if line.is_empty() {
if data_lines.is_empty() {
continue;
}
let raw = data_lines.join("\n");
data_lines.clear();
match serde_json::from_str::<EventEnvelope>(&raw) {
Ok(event) => handle_event(app, event, notified_messages).await,
Err(err) => {
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
}
}
continue;
}
if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start().to_string());
}
}
Ok(())
}
async fn handle_event(
app: &AppHandle,
event: EventEnvelope,
notified_messages: &Mutex<HashSet<String>>,
) {
if event.event_type.as_str() != "message.updated" {
return;
}
let Some(info) = event.properties.get("info") else {
return;
};
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
if role != "assistant" {
return;
}
let finish = info.get("finish").and_then(Value::as_str);
if finish != Some("stop") {
return;
}
let message_id = match info.get("id").and_then(Value::as_str) {
Some(id) => id.to_string(),
None => return,
};
{
let mut notified = notified_messages.lock().await;
if notified.contains(&message_id) {
return;
}
notified.insert(message_id.clone());
}
let raw_mode = info
.get("mode")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("agent");
let raw_model = info
.get("modelID")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("assistant");
let title = format!("{} agent is ready", format_mode(raw_mode));
let body = format!("{} completed the task", format_model_id(raw_model));
let should_notify = app
.get_webview_window("main")
.map(|window| {
let focused = window.is_focused().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
// Only notify when the app is not in the foreground or is minimized
!focused || minimized
})
.unwrap_or(true);
if should_notify {
let _ = app
.notification()
.builder()
.title(title)
.body(body)
.sound("Glass")
.show();
}
}
fn format_mode(raw: &str) -> String {
if raw.is_empty() {
return "Agent".to_string();
}
raw.split(&['-', '_', ' '][..])
.filter(|s| !s.is_empty())
.map(capitalize)
.collect::<Vec<_>>()
.join(" ")
}
fn format_model_id(raw: &str) -> String {
if raw.is_empty() {
return "Assistant".to_string();
}
let tokens: Vec<&str> = raw.split(&['-', '_'][..]).collect();
let mut result: Vec<String> = Vec::new();
let mut i = 0;
while i < tokens.len() {
let current = tokens[i];
if current.chars().all(|c| c.is_ascii_digit()) {
if i + 1 < tokens.len() && tokens[i + 1].chars().all(|c| c.is_ascii_digit()) {
let combined = format!("{}.{}", current, tokens[i + 1]);
result.push(combined);
i += 2;
continue;
}
}
result.push(current.to_string());
i += 1;
}
result
.into_iter()
.map(|part| capitalize(&part))
.collect::<Vec<_>>()
.join(" ")
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
@@ -0,0 +1,443 @@
use crate::{DesktopRuntime, SettingsStore};
use serde::Serialize;
use std::{
collections::{HashSet, VecDeque},
path::{Path, PathBuf},
time::UNIX_EPOCH,
};
use tokio::fs;
const DEFAULT_FILE_SEARCH_LIMIT: usize = 60;
const MAX_FILE_SEARCH_LIMIT: usize = 400;
const FILE_SEARCH_MAX_CONCURRENCY: usize = 5;
const FILE_SEARCH_EXCLUDED_DIRS: &[&str] = &[
"node_modules",
".git",
"dist",
"build",
".next",
".turbo",
".cache",
"coverage",
"tmp",
"logs",
];
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileListEntry {
name: String,
path: String,
is_directory: bool,
is_file: bool,
is_symbolic_link: bool,
size: Option<u64>,
modified_time: Option<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryListResult {
directory: String,
path: String,
entries: Vec<FileListEntry>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectoryResponse {
success: bool,
path: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileSearchHit {
name: String,
path: String,
relative_path: String,
extension: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchFilesResponse {
root: String,
count: usize,
files: Vec<FileSearchHit>,
}
#[derive(Debug)]
enum FsCommandError {
NotFound,
AccessDenied,
NotDirectory,
OutsideWorkspace,
Other(String),
}
impl FsCommandError {
fn to_list_message(&self) -> String {
match self {
FsCommandError::NotFound => "Directory not found".to_string(),
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to list directory".to_string()
}
}
}
fn to_search_message(&self) -> String {
match self {
FsCommandError::NotFound => "Directory not found".to_string(),
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Specified path is not a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to search files".to_string()
}
}
}
fn to_create_message(&self) -> String {
match self {
FsCommandError::AccessDenied | FsCommandError::OutsideWorkspace => {
"Access to directory denied".to_string()
}
FsCommandError::NotDirectory => "Parent path must be a directory".to_string(),
FsCommandError::Other(message) => {
let _ = message;
"Failed to create directory".to_string()
}
FsCommandError::NotFound => "Parent directory not found".to_string(),
}
}
}
impl From<std::io::Error> for FsCommandError {
fn from(error: std::io::Error) -> Self {
match error.kind() {
std::io::ErrorKind::NotFound => FsCommandError::NotFound,
std::io::ErrorKind::PermissionDenied => FsCommandError::AccessDenied,
_ => FsCommandError::Other(error.to_string()),
}
}
}
#[tauri::command]
pub async fn list_directory(
path: Option<String>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<DirectoryListResult, String> {
let workspace_root = resolve_workspace_root(state.settings()).await;
let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref())
.await
.map_err(|err| err.to_list_message())?;
let metadata = fs::metadata(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
if !metadata.is_dir() {
return Err(FsCommandError::NotDirectory.to_list_message());
}
// Re-check boundary after canonicalization to guard against traversal
if let Some(root) = &workspace_root {
if !resolved_path.starts_with(root) {
return Err(FsCommandError::OutsideWorkspace.to_list_message());
}
}
let mut entries = Vec::new();
let mut dir_entries = fs::read_dir(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
while let Some(entry) = dir_entries
.next_entry()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?
{
let file_type = entry
.file_type()
.await
.map_err(|err| FsCommandError::from(err).to_list_message())?;
let entry_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let mut is_directory = file_type.is_dir();
let is_symlink = file_type.is_symlink();
if !is_directory && is_symlink {
if let Ok(link_meta) = fs::metadata(&entry_path).await {
is_directory = link_meta.is_dir();
}
}
let metadata = fs::metadata(&entry_path).await.ok();
let size = metadata
.as_ref()
.filter(|meta| meta.is_file())
.map(|meta| meta.len());
let modified_time = metadata
.and_then(|meta| meta.modified().ok())
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as i64);
entries.push(FileListEntry {
name,
path: normalize_path(&entry_path),
is_directory,
is_file: file_type.is_file(),
is_symbolic_link: is_symlink,
size,
modified_time,
});
}
Ok(DirectoryListResult {
directory: normalize_path(&resolved_path),
path: normalize_path(&resolved_path),
entries,
})
}
#[tauri::command]
pub async fn search_files(
directory: Option<String>,
query: Option<String>,
max_results: Option<usize>,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<SearchFilesResponse, String> {
let workspace_root = resolve_workspace_root(state.settings()).await;
let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref())
.await
.map_err(|err| err.to_search_message())?;
let limit = clamp_search_limit(max_results);
let normalized_query = query.unwrap_or_default().trim().to_lowercase();
let match_all = normalized_query.is_empty();
let mut files = Vec::new();
let mut queue = VecDeque::new();
let mut visited = HashSet::new();
queue.push_back(resolved_root.clone());
visited.insert(resolved_root.clone());
while !queue.is_empty() && files.len() < limit {
for _ in 0..FILE_SEARCH_MAX_CONCURRENCY {
let Some(dir) = queue.pop_front() else {
break;
};
let mut entries = match fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(_) => continue,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(file_type) = entry.file_type().await else {
continue;
};
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.is_empty() || name_str.starts_with('.') {
continue;
}
let entry_path = entry.path();
if file_type.is_dir() {
if should_skip_directory(&name_str) {
continue;
}
if visited.insert(entry_path.clone()) && files.len() < limit {
queue.push_back(entry_path);
}
continue;
}
if !file_type.is_file() {
continue;
}
let relative_path = relative_path(&resolved_root, &entry_path);
if !match_all {
let lowercase_name = name_str.to_lowercase();
let lowercase_path = relative_path.to_lowercase();
if !lowercase_name.contains(&normalized_query)
&& !lowercase_path.contains(&normalized_query)
{
continue;
}
}
let extension = entry_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase());
files.push(FileSearchHit {
name: name_str.to_string(),
path: normalize_path(&entry_path),
relative_path: relative_path.replace('\\', "/"),
extension,
});
if files.len() >= limit {
break;
}
}
if files.len() >= limit {
break;
}
}
}
Ok(SearchFilesResponse {
root: normalize_path(&resolved_root),
count: files.len(),
files,
})
}
#[tauri::command]
pub async fn create_directory(
path: String,
state: tauri::State<'_, DesktopRuntime>,
) -> Result<CreateDirectoryResponse, String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err("Path is required".to_string());
}
let workspace_root = resolve_workspace_root(state.settings()).await;
let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref())
.await
.map_err(|err| err.to_create_message())?;
fs::create_dir_all(&resolved_path)
.await
.map_err(|err| FsCommandError::from(err).to_create_message())?;
Ok(CreateDirectoryResponse {
success: true,
path: normalize_path(&resolved_path),
})
}
async fn resolve_sandboxed_path(
path: Option<String>,
workspace_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> {
let candidate_input = path
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty());
let candidate_path = match (candidate_input, workspace_root) {
(Some(value), _) => PathBuf::from(value),
(None, Some(root)) => root.clone(),
(None, None) => default_home_directory(),
};
let resolved = if candidate_path.is_absolute() {
candidate_path
} else if let Some(root) = workspace_root {
root.join(candidate_path)
} else {
default_home_directory().join(candidate_path)
};
let canonicalized = fs::canonicalize(&resolved)
.await
.map_err(FsCommandError::from)?;
if let Some(root) = workspace_root {
if !canonicalized.starts_with(root) {
return Err(FsCommandError::OutsideWorkspace);
}
}
Ok(canonicalized)
}
async fn resolve_creatable_path(
path: &str,
workspace_root: Option<&PathBuf>,
) -> Result<PathBuf, FsCommandError> {
let candidate = PathBuf::from(path);
if candidate.as_os_str().is_empty() {
return Err(FsCommandError::Other("Path is required".to_string()));
}
let absolute = if candidate.is_absolute() {
candidate
} else if let Some(root) = workspace_root {
root.join(candidate)
} else {
default_home_directory().join(candidate)
};
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
let canonical_parent = fs::canonicalize(parent)
.await
.map_err(FsCommandError::from)?;
if let Some(root) = workspace_root {
if !canonical_parent.starts_with(root) {
return Err(FsCommandError::OutsideWorkspace);
}
}
Ok(absolute)
}
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> {
if let Ok(Some(last_dir)) = settings.last_directory().await {
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await {
return Some(canonicalized);
}
}
None
}
fn default_home_directory() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
}
fn clamp_search_limit(value: Option<usize>) -> usize {
let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT);
limit.clamp(1, MAX_FILE_SEARCH_LIMIT)
}
fn should_skip_directory(name: &str) -> bool {
if name.starts_with('.') {
return true;
}
FILE_SEARCH_EXCLUDED_DIRS
.iter()
.any(|dir| dir.eq_ignore_ascii_case(name))
}
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn relative_path(root: &Path, target: &Path) -> String {
target
.strip_prefix(root)
.map(|relative| normalize_path(relative))
.unwrap_or_else(|_| normalize_path(target))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
use crate::logging::log_file_path;
use serde::Serialize;
use tokio::fs;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopLogFile {
pub file_name: String,
pub content: String,
}
#[tauri::command]
pub async fn fetch_desktop_logs() -> Result<DesktopLogFile, String> {
let path = log_file_path().ok_or_else(|| "Log location unavailable".to_string())?;
let content = fs::read_to_string(&path)
.await
.map_err(|err| format!("Failed to read log file: {err}"))?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("desktop.log")
.to_string();
Ok(DesktopLogFile { file_name, content })
}
@@ -0,0 +1,7 @@
pub mod files;
pub mod git;
pub mod logs;
pub mod permissions;
pub mod settings;
pub mod terminal;
pub mod notifications;
@@ -0,0 +1,37 @@
use serde::Deserialize;
use tauri::{AppHandle, Runtime};
use tauri_plugin_notification::NotificationExt;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPayload {
pub title: Option<String>,
pub body: Option<String>,
}
#[tauri::command]
pub async fn desktop_notify<R: Runtime>(
app: AppHandle<R>,
payload: Option<NotificationPayload>,
) -> Result<bool, String> {
let title = payload
.as_ref()
.and_then(|p| p.title.as_deref())
.unwrap_or("OpenChamber");
let body = payload
.as_ref()
.and_then(|p| p.body.as_deref())
.unwrap_or("Task completed");
match app
.notification()
.builder()
.title(title)
.body(body)
.sound("Glass")
.show()
{
Ok(_) => Ok(true),
Err(e) => Err(e.to_string()),
}
}
@@ -0,0 +1,209 @@
use log::{info, warn};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri::State;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryPermissionRequest {
path: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryPermissionResult {
success: bool,
path: Option<String>,
error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartAccessingResult {
success: bool,
error: Option<String>,
}
/// Process directory selection from frontend
/// Updates settings with lastDirectory
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
#[tauri::command]
pub async fn process_directory_selection(
path: String,
state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
use std::path::PathBuf;
// Validate directory exists
let path_buf = PathBuf::from(&path);
if !path_buf.exists() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Path is not a directory".to_string()),
});
}
// Update settings with lastDirectory
let mut settings = state
.settings()
.load()
.await
.map_err(|e| format!("Failed to load settings: {}", e))?;
if let Some(obj) = settings.as_object_mut() {
obj.insert(
"lastDirectory".to_string(),
serde_json::Value::String(path.clone()),
);
}
state
.settings()
.save(settings)
.await
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
info!(
"[permissions] Updated settings with lastDirectory: {}",
path
);
Ok(DirectoryPermissionResult {
success: true,
path: Some(path),
error: None,
})
}
/// Legacy directory picker command (frontend handles actual dialog)
#[tauri::command]
pub async fn pick_directory(
_app_handle: AppHandle,
_state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some(
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
),
})
}
/// Request directory access (desktop implementation)
/// For unsandboxed apps, just validates the path is accessible
#[tauri::command]
pub async fn request_directory_access(
request: DirectoryPermissionRequest,
_state: State<'_, DesktopRuntime>,
) -> Result<DirectoryPermissionResult, String> {
let path = request.path;
let path_buf = std::path::PathBuf::from(&path);
if !path_buf.exists() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some("Path is not a directory".to_string()),
});
}
// For unsandboxed apps, no bookmark needed - just verify access
match std::fs::read_dir(&path_buf) {
Ok(_) => Ok(DirectoryPermissionResult {
success: true,
path: Some(path),
error: None,
}),
Err(e) => Ok(DirectoryPermissionResult {
success: false,
path: None,
error: Some(format!("Cannot access directory: {}", e)),
}),
}
}
/// Start accessing directory (desktop implementation)
#[tauri::command]
pub async fn start_accessing_directory(
path: String,
_state: State<'_, DesktopRuntime>,
) -> Result<StartAccessingResult, String> {
// Check if directory exists and is accessible
let path_buf = std::path::PathBuf::from(&path);
if !path_buf.exists() {
return Ok(StartAccessingResult {
success: false,
error: Some("Directory does not exist".to_string()),
});
}
if !path_buf.is_dir() {
return Ok(StartAccessingResult {
success: false,
error: Some("Path is not a directory".to_string()),
});
}
// Try to read the directory to verify access
match std::fs::read_dir(&path_buf) {
Ok(_) => {
info!("Successfully started accessing directory: {}", path);
Ok(StartAccessingResult {
success: true,
error: None,
})
}
Err(e) => {
warn!("Failed to access directory {}: {}", path, e);
Ok(StartAccessingResult {
success: false,
error: Some(format!("Failed to access directory: {}", e)),
})
}
}
}
/// Stop accessing directory (desktop implementation)
#[tauri::command]
pub async fn stop_accessing_directory(
_path: String,
_state: State<'_, DesktopRuntime>,
) -> Result<StartAccessingResult, String> {
// For Stage 1, just confirm the operation
// Full implementation would call stopAccessingSecurityScopedResource
info!("Stopped accessing directory");
Ok(StartAccessingResult {
success: true,
error: None,
})
}
/// Restore bookmarks on app startup (no-op for unsandboxed apps)
#[tauri::command]
pub async fn restore_bookmarks_on_startup(_state: State<'_, DesktopRuntime>) -> Result<(), String> {
// For unsandboxed apps, no bookmarks needed
// Directory access is restored from settings.lastDirectory
info!("[permissions] Bookmark restore not needed for unsandboxed app");
Ok(())
}
@@ -0,0 +1,343 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashSet;
use tauri::State;
use crate::DesktopRuntime;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettingsLoadResult {
settings: Value,
source: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RestartResult {
restarted: bool,
}
/// Load settings from disk (matches Express handler behavior)
#[tauri::command]
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
let settings = state
.settings()
.load()
.await
.map_err(|e| format!("Failed to load settings: {}", e))?;
Ok(SettingsLoadResult {
settings,
source: "desktop".to_string(),
})
}
/// Save settings to disk with merge logic matching Express implementation
#[tauri::command]
pub async fn save_settings(
changes: Value,
state: State<'_, DesktopRuntime>,
) -> Result<Value, String> {
// Load current settings
let current = state
.settings()
.load()
.await
.map_err(|e| format!("Failed to load current settings: {}", e))?;
// Sanitize incoming changes
let sanitized_changes = sanitize_settings_update(&changes);
// Merge changes into current settings
let merged = merge_persisted_settings(&current, &sanitized_changes);
// Save merged settings
state
.settings()
.save(merged.clone())
.await
.map_err(|e| format!("Failed to save settings: {}", e))?;
// Format response
Ok(format_settings_response(&merged))
}
/// Restart OpenCode CLI (matches Express /api/config/reload)
#[tauri::command]
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
state
.opencode
.restart()
.await
.map_err(|e| format!("Failed to restart OpenCode: {}", e))?;
Ok(RestartResult { restarted: true })
}
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
fn sanitize_settings_update(payload: &Value) -> Value {
let mut result = json!({});
if let Some(obj) = payload.as_object() {
let result_obj = result.as_object_mut().unwrap();
// String fields
if let Some(Value::String(s)) = obj.get("themeId") {
if !s.is_empty() {
result_obj.insert("themeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("themeVariant") {
if s == "light" || s == "dark" {
result_obj.insert("themeVariant".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("lightThemeId") {
if !s.is_empty() {
result_obj.insert("lightThemeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("darkThemeId") {
if !s.is_empty() {
result_obj.insert("darkThemeId".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("lastDirectory") {
if !s.is_empty() {
result_obj.insert("lastDirectory".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("homeDirectory") {
if !s.is_empty() {
result_obj.insert("homeDirectory".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("uiFont") {
if !s.is_empty() {
result_obj.insert("uiFont".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("monoFont") {
if !s.is_empty() {
result_obj.insert("monoFont".to_string(), json!(s));
}
}
if let Some(Value::String(s)) = obj.get("markdownDisplayMode") {
if !s.is_empty() {
result_obj.insert("markdownDisplayMode".to_string(), json!(s));
}
}
// Boolean fields
if let Some(Value::Bool(b)) = obj.get("useSystemTheme") {
result_obj.insert("useSystemTheme".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
result_obj.insert("showReasoningTraces".to_string(), json!(b));
}
// Array fields
if let Some(arr) = obj.get("approvedDirectories") {
result_obj.insert(
"approvedDirectories".to_string(),
normalize_string_array(arr),
);
}
if let Some(arr) = obj.get("securityScopedBookmarks") {
result_obj.insert(
"securityScopedBookmarks".to_string(),
normalize_string_array(arr),
);
}
if let Some(arr) = obj.get("pinnedDirectories") {
result_obj.insert("pinnedDirectories".to_string(), normalize_string_array(arr));
}
// Typography sizes object (partial)
if let Some(typo) = obj.get("typographySizes") {
if let Some(sanitized) = sanitize_typography_sizes_partial(typo) {
result_obj.insert("typographySizes".to_string(), sanitized);
}
}
}
result
}
/// Merge persisted settings (port of Express mergePersistedSettings)
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
let mut result = current.clone();
if let (Some(result_obj), Some(changes_obj)) = (result.as_object_mut(), changes.as_object()) {
// First apply all changes
for (key, value) in changes_obj {
result_obj.insert(key.clone(), value.clone());
}
// Build approvedDirectories from base + additional
let base_approved = if let Some(arr) = changes_obj.get("approvedDirectories") {
extract_string_vec(arr)
} else if let Some(arr) = current.get("approvedDirectories") {
extract_string_vec(arr)
} else {
vec![]
};
let mut additional_approved = vec![];
if let Some(Value::String(s)) = changes_obj.get("lastDirectory") {
if !s.is_empty() {
additional_approved.push(s.clone());
}
}
if let Some(Value::String(s)) = changes_obj.get("homeDirectory") {
if !s.is_empty() {
additional_approved.push(s.clone());
}
}
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
for item in additional_approved {
approved_set.insert(item);
}
let approved_vec: Vec<String> = approved_set.into_iter().collect();
result_obj.insert("approvedDirectories".to_string(), json!(approved_vec));
// Security scoped bookmarks
let base_bookmarks = if let Some(arr) = changes_obj.get("securityScopedBookmarks") {
extract_string_vec(arr)
} else if let Some(arr) = current.get("securityScopedBookmarks") {
extract_string_vec(arr)
} else {
vec![]
};
let bookmarks_set: HashSet<String> = base_bookmarks.into_iter().collect();
let bookmarks_vec: Vec<String> = bookmarks_set.into_iter().collect();
result_obj.insert("securityScopedBookmarks".to_string(), json!(bookmarks_vec));
// Merge typography sizes if present
if changes_obj.contains_key("typographySizes") {
let current_typo = current
.get("typographySizes")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let changes_typo = changes_obj
.get("typographySizes")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let mut merged_typo = current_typo;
for (key, value) in changes_typo {
merged_typo.insert(key, value);
}
result_obj.insert("typographySizes".to_string(), json!(merged_typo));
}
}
result
}
/// Format settings response (port of Express formatSettingsResponse)
fn format_settings_response(settings: &Value) -> Value {
let mut result = sanitize_settings_update(settings);
if let Some(obj) = result.as_object_mut() {
// Ensure array fields are normalized
obj.insert(
"approvedDirectories".to_string(),
normalize_string_array(settings.get("approvedDirectories").unwrap_or(&json!([]))),
);
obj.insert(
"securityScopedBookmarks".to_string(),
normalize_string_array(
settings
.get("securityScopedBookmarks")
.unwrap_or(&json!([])),
),
);
obj.insert(
"pinnedDirectories".to_string(),
normalize_string_array(settings.get("pinnedDirectories").unwrap_or(&json!([]))),
);
// Typography sizes
if let Some(sanitized_typo) = sanitize_typography_sizes_partial(
settings.get("typographySizes").unwrap_or(&json!(null)),
) {
obj.insert("typographySizes".to_string(), sanitized_typo);
}
// showReasoningTraces with fallback
let show_reasoning = settings
.get("showReasoningTraces")
.and_then(|v| v.as_bool())
.or_else(|| {
// Get showReasoningTraces from sanitized result instead of the current mutable borrow
if let Some(Value::Bool(b)) = obj.get("showReasoningTraces") {
Some(*b)
} else {
None
}
})
.unwrap_or(false);
obj.insert("showReasoningTraces".to_string(), json!(show_reasoning));
}
result
}
/// Normalize string array helper
fn normalize_string_array(input: &Value) -> Value {
if let Some(arr) = input.as_array() {
let strings: Vec<String> = arr
.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let unique: HashSet<String> = strings.into_iter().collect();
json!(unique.into_iter().collect::<Vec<_>>())
} else {
json!([])
}
}
/// Sanitize typography sizes partial helper
fn sanitize_typography_sizes_partial(input: &Value) -> Option<Value> {
if let Some(obj) = input.as_object() {
let mut result = serde_json::Map::new();
let mut populated = false;
for key in &["markdown", "code", "uiHeader", "uiLabel", "meta", "micro"] {
if let Some(Value::String(s)) = obj.get(*key) {
if !s.is_empty() {
result.insert(key.to_string(), json!(s));
populated = true;
}
}
}
if populated {
Some(json!(result))
} else {
None
}
} else {
None
}
}
/// Extract string vector from JSON value
fn extract_string_vec(value: &Value) -> Vec<String> {
if let Some(arr) = value.as_array() {
arr.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
} else {
vec![]
}
}
@@ -0,0 +1,308 @@
use log::error;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
env,
io::{Read, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
};
use tauri::{Emitter, State, Window};
const DEFAULT_SHELL: &str = "/bin/zsh";
const DEFAULT_TERM: &str = "xterm-256color";
const DEFAULT_COLORTERM: &str = "truecolor";
const DEFAULT_LOCALE: &str = "en_US.UTF-8";
const TERM_PROGRAM_NAME: &str = "OpenChamber";
const TERM_PROGRAM_VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct TerminalSession {
pub master: Box<dyn MasterPty + Send>,
pub writer: Arc<Mutex<Box<dyn Write + Send>>>,
pub child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
}
pub struct TerminalState {
pub sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
}
impl TerminalState {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
}
#[derive(Deserialize)]
pub struct CreateTerminalPayload {
pub cols: u16,
pub rows: u16,
pub cwd: Option<String>,
}
#[derive(Serialize)]
pub struct CreateTerminalResponse {
pub session_id: String,
}
#[tauri::command]
pub async fn create_terminal_session(
payload: CreateTerminalPayload,
state: State<'_, TerminalState>,
window: Window,
) -> Result<CreateTerminalResponse, String> {
let pty_system = NativePtySystem::default();
let size = PtySize {
rows: payload.rows,
cols: payload.cols,
pixel_width: 0,
pixel_height: 0,
};
let working_dir = resolve_working_directory(payload.cwd.as_deref())?;
let shell_path = resolve_shell();
let mut cmd = CommandBuilder::new(&shell_path);
if shell_accepts_login_flag(&shell_path) {
cmd.arg("-l");
}
if let Some(cwd) = working_dir.to_str() {
cmd.cwd(cwd);
}
apply_terminal_environment(&mut cmd, &shell_path);
let pair = pty_system.openpty(size).map_err(|e| e.to_string())?;
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell: {e}"))?;
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
let writer = Arc::new(Mutex::new(
pair.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {e}"))?,
));
let master = pair.master;
let child = Arc::new(Mutex::new(child));
let session_id = uuid::Uuid::new_v4().to_string();
state.sessions.lock().unwrap().insert(
session_id.clone(),
TerminalSession {
master,
writer: writer.clone(),
child: child.clone(),
},
);
spawn_reader_thread(reader, window.clone(), session_id.clone());
spawn_exit_watcher(child, window, state.sessions.clone(), session_id.clone());
Ok(CreateTerminalResponse { session_id })
}
#[tauri::command]
pub async fn send_terminal_input(
session_id: String,
data: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let sessions = state.sessions.lock().unwrap();
let Some(session) = sessions.get(&session_id) else {
return Err("Terminal session not found".to_string());
};
let mut writer = session
.writer
.lock()
.map_err(|_| "Terminal busy".to_string())?;
writer
.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write to terminal: {e}"))?;
writer
.flush()
.map_err(|e| format!("Failed to flush terminal input: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn resize_terminal(
session_id: String,
cols: u16,
rows: u16,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let mut sessions = state.sessions.lock().unwrap();
let Some(session) = sessions.get_mut(&session_id) else {
return Err("Terminal session not found".to_string());
};
session
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to resize terminal: {e}"))?;
Ok(())
}
#[tauri::command]
pub async fn close_terminal(
session_id: String,
state: State<'_, TerminalState>,
) -> Result<(), String> {
let session = {
let mut sessions = state.sessions.lock().unwrap();
sessions.remove(&session_id)
};
if let Some(session) = session {
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
}
Ok(())
}
fn spawn_reader_thread(mut reader: Box<dyn Read + Send>, window: Window, session_id: String) {
thread::spawn(move || {
let mut buffer = [0u8; 4096];
let event_name = format!("terminal://{}", session_id);
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
let data = String::from_utf8_lossy(&buffer[..n]).to_string();
if data.is_empty() {
continue;
}
if let Err(error) =
window.emit(&event_name, serde_json::json!({ "type": "data", "data": data }))
{
error!("Failed to emit terminal data: {error}");
break;
}
}
Err(error) => {
error!("Terminal read error: {error}");
break;
}
}
}
});
}
fn spawn_exit_watcher(
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
window: Window,
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
session_id: String,
) {
thread::spawn(move || {
let status = {
let mut guard = child.lock().expect("terminal child poisoned");
guard.wait()
};
let (exit_code, signal) = match status {
Ok(status) => (
status.exit_code() as i32,
status.signal().map(|sig| sig.to_string()),
),
Err(err) => {
error!("Failed to wait for terminal exit: {err}");
(1, Some("Terminal crashed".to_string()))
}
};
let event_name = format!("terminal://{}", session_id);
let payload = serde_json::json!({
"type": "exit",
"exitCode": exit_code,
"signal": signal
});
let _ = window.emit(&event_name, payload);
let mut sessions = sessions.lock().unwrap();
sessions.remove(&session_id);
});
}
fn resolve_shell() -> String {
env::var("SHELL")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_SHELL.to_string())
}
fn shell_accepts_login_flag(shell_path: &str) -> bool {
let shell_name = Path::new(shell_path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(shell_path)
.to_lowercase();
matches!(
shell_name.as_str(),
name if name.contains("zsh")
|| name.contains("bash")
|| name.contains("sh")
|| name.contains("fish")
|| name.contains("ksh")
)
}
fn resolve_working_directory(input: Option<&str>) -> Result<PathBuf, String> {
let maybe_path = input
.map(|value| PathBuf::from(value))
.or_else(|| dirs::home_dir());
let Some(path) = maybe_path else {
return Err("Unable to determine working directory".to_string());
};
if !path.exists() || !path.is_dir() {
return Err(format!(
"Working directory is not accessible: {}",
path.display()
));
}
Ok(path)
}
fn apply_terminal_environment(cmd: &mut CommandBuilder, shell_path: &str) {
cmd.env(
"TERM",
env::var("TERM").unwrap_or_else(|_| DEFAULT_TERM.to_string()),
);
cmd.env(
"COLORTERM",
env::var("COLORTERM").unwrap_or_else(|_| DEFAULT_COLORTERM.to_string()),
);
cmd.env(
"LC_ALL",
env::var("LC_ALL").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
);
cmd.env(
"LANG",
env::var("LANG").unwrap_or_else(|_| DEFAULT_LOCALE.to_string()),
);
cmd.env("TERM_PROGRAM", TERM_PROGRAM_NAME);
cmd.env("TERM_PROGRAM_VERSION", TERM_PROGRAM_VERSION);
cmd.env("OPENCHAMBER_DESKTOP", "1");
cmd.env("SHELL", shell_path);
}
+20
View File
@@ -0,0 +1,20 @@
use std::path::PathBuf;
#[cfg(target_os = "macos")]
const PLATFORM_LOG_SEGMENTS: &[&str] = &["Library", "Logs", "OpenChamber"];
#[cfg(not(target_os = "macos"))]
const PLATFORM_LOG_SEGMENTS: &[&str] = &[".config", "openchamber", "logs"];
pub fn log_directory() -> Option<PathBuf> {
let mut path = dirs::home_dir()?;
for segment in PLATFORM_LOG_SEGMENTS {
path.push(segment);
}
Some(path)
}
pub fn log_file_path() -> Option<PathBuf> {
let mut dir = log_directory()?;
dir.push("desktop.log");
Some(dir)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,794 @@
use anyhow::{anyhow, Result};
use log::info;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
static PROMPT_FILE_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^\{file:(.+)\}$").expect("valid regex"));
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceInfo {
pub exists: bool,
pub path: Option<String>,
pub fields: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigSources {
pub md: SourceInfo,
pub json: SourceInfo,
}
/// Get OpenCode config directory path
fn get_config_dir() -> PathBuf {
dirs::home_dir()
.expect("Cannot determine home directory")
.join(".config")
.join("opencode")
}
/// Get agent directory path
fn get_agent_dir() -> PathBuf {
get_config_dir().join("agent")
}
/// Get command directory path
fn get_command_dir() -> PathBuf {
get_config_dir().join("command")
}
/// Get config file path
fn get_config_file() -> PathBuf {
get_config_dir().join("opencode.json")
}
/// Ensure required directories exist
async fn ensure_dirs() -> Result<()> {
let config_dir = get_config_dir();
let agent_dir = get_agent_dir();
let command_dir = get_command_dir();
fs::create_dir_all(&config_dir).await?;
fs::create_dir_all(&agent_dir).await?;
fs::create_dir_all(&command_dir).await?;
Ok(())
}
/// Check if a value is a prompt file reference like {file:./prompts/agent.txt}
fn is_prompt_file_reference(value: &str) -> bool {
PROMPT_FILE_PATTERN.is_match(value.trim())
}
/// Resolve a prompt file reference to an absolute path
fn resolve_prompt_file_path(reference: &str) -> Option<PathBuf> {
let trimmed = reference.trim();
let captures = PROMPT_FILE_PATTERN.captures(trimmed)?;
let target = captures.get(1)?.as_str().trim();
if target.is_empty() {
return None;
}
let path = if target.starts_with("./") {
get_config_dir().join(&target[2..])
} else if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
get_config_dir().join(target)
};
Some(path)
}
/// Write content to a prompt file
async fn write_prompt_file(file_path: &Path, content: &str) -> Result<()> {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(file_path, content).await?;
info!("Updated prompt file: {}", file_path.display());
Ok(())
}
/// Strip JSON comments from content
fn strip_json_comments(content: &str) -> String {
let mut result = String::new();
let mut in_string = false;
let mut escape_next = false;
let mut chars = content.chars().peekable();
while let Some(ch) = chars.next() {
if escape_next {
result.push(ch);
escape_next = false;
continue;
}
if ch == '\\' && in_string {
result.push(ch);
escape_next = true;
continue;
}
if ch == '"' {
in_string = !in_string;
result.push(ch);
continue;
}
if !in_string {
if ch == '/' {
if let Some(&next_ch) = chars.peek() {
if next_ch == '/' {
// Line comment - skip until end of line
chars.next(); // consume the second '/'
while let Some(c) = chars.next() {
if c == '\n' {
result.push('\n');
break;
}
}
continue;
} else if next_ch == '*' {
// Block comment - skip until */
chars.next(); // consume the '*'
let mut prev = ' ';
while let Some(c) = chars.next() {
if prev == '*' && c == '/' {
break;
}
prev = c;
}
continue;
}
}
}
}
result.push(ch);
}
result
}
/// Read opencode.json configuration file
pub async fn read_config() -> Result<Value> {
let config_file = get_config_file();
if !config_file.exists() {
return Ok(Value::Object(serde_json::Map::new()));
}
let content = fs::read_to_string(&config_file).await?;
let normalized = strip_json_comments(&content).trim().to_string();
if normalized.is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e))
}
/// Write opencode.json configuration file with backup
pub async fn write_config(config: &Value) -> Result<()> {
let config_file = get_config_file();
// Create/overwrite single backup before writing
if config_file.exists() {
let file_name = config_file
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("Invalid config file name"))?;
let backup_path = config_file.with_file_name(format!("{file_name}.openchamber.backup"));
fs::copy(&config_file, &backup_path).await?;
info!("Created config backup: {}", backup_path.display());
}
let json_string = serde_json::to_string_pretty(config)?;
fs::write(&config_file, json_string).await?;
info!("Successfully wrote config file");
Ok(())
}
/// Markdown file data
#[derive(Debug)]
struct MdData {
frontmatter: HashMap<String, Value>,
body: String,
}
/// Parse markdown file with YAML frontmatter
async fn parse_md_file(file_path: &Path) -> Result<MdData> {
let content = fs::read_to_string(file_path).await?;
// Match YAML frontmatter: ---\n...\n---\n
let re = Regex::new(r"(?s)^---\r?\n(.*?)\r?\n---\r?\n(.*)$").expect("valid regex");
if let Some(captures) = re.captures(&content) {
let yaml_str = captures.get(1).map(|m| m.as_str()).unwrap_or("");
let body = captures.get(2).map(|m| m.as_str()).unwrap_or("").trim();
let frontmatter: HashMap<String, Value> =
serde_yaml::from_str(yaml_str).unwrap_or_default();
Ok(MdData {
frontmatter,
body: body.to_string(),
})
} else {
// No frontmatter, treat entire content as body
Ok(MdData {
frontmatter: HashMap::new(),
body: content.trim().to_string(),
})
}
}
/// Write markdown file with YAML frontmatter
async fn write_md_file(
file_path: &Path,
frontmatter: &HashMap<String, Value>,
body: &str,
) -> Result<()> {
let yaml_str = serde_yaml::to_string(frontmatter)?;
let content = format!("---\n{}---\n\n{}", yaml_str, body);
fs::write(file_path, content).await?;
info!("Successfully wrote markdown file: {}", file_path.display());
Ok(())
}
/// Get information about where agent configuration is stored
pub async fn get_agent_sources(agent_name: &str) -> Result<ConfigSources> {
ensure_dirs().await?;
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
let md_exists = md_path.exists();
let mut md_fields = Vec::new();
if md_exists {
let md_data = parse_md_file(&md_path).await?;
md_fields.extend(md_data.frontmatter.keys().cloned());
if !md_data.body.trim().is_empty() {
md_fields.push("prompt".to_string());
}
}
let config = read_config().await?;
let json_section = config
.get("agent")
.and_then(|v| v.as_object())
.and_then(|obj| obj.get(agent_name));
let json_fields = json_section
.and_then(|value| value.as_object())
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let sources = ConfigSources {
md: SourceInfo {
exists: md_exists,
path: md_exists.then(|| md_path.display().to_string()),
fields: md_fields,
},
json: SourceInfo {
exists: json_section.is_some(),
path: Some(get_config_file().display().to_string()),
fields: json_fields,
},
};
Ok(sources)
}
/// Create new agent as .md file
pub async fn create_agent(agent_name: &str, config: &HashMap<String, Value>) -> Result<()> {
ensure_dirs().await?;
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
// Check if agent already exists
if md_path.exists() {
return Err(anyhow!("Agent {} already exists as .md file", agent_name));
}
let existing_config = read_config().await?;
if let Some(agents) = existing_config.get("agent").and_then(|v| v.as_object()) {
if agents.contains_key(agent_name) {
return Err(anyhow!(
"Agent {} already exists in opencode.json",
agent_name
));
}
}
// Extract prompt from config
let mut frontmatter = config.clone();
let prompt = frontmatter
.remove("prompt")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
// Write .md file
write_md_file(&md_path, &frontmatter, &prompt).await?;
info!("Created new agent: {}", agent_name);
Ok(())
}
/// Update existing agent using field-level logic
pub async fn update_agent(agent_name: &str, updates: &HashMap<String, Value>) -> Result<()> {
ensure_dirs().await?;
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
let md_exists = md_path.exists();
let mut md_data = if md_exists {
Some(parse_md_file(&md_path).await?)
} else {
None
};
let mut config = read_config().await?;
let mut existing_agent = config
.get("agent")
.and_then(|v| v.as_object())
.and_then(|obj| obj.get(agent_name))
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_else(Map::new);
let had_json_fields = !existing_agent.is_empty();
let mut md_modified = false;
let mut json_modified = false;
for (field, value) in updates.iter() {
// Handle explicit removals (null payload) for scalar/frontmatter/JSON fields
if value.is_null() {
if md_exists {
if let Some(ref mut data) = md_data {
if data.frontmatter.remove(field).is_some() {
md_modified = true;
}
}
}
if existing_agent.remove(field).is_some() {
json_modified = true;
}
continue;
}
// Special handling for prompt field
if field == "prompt" {
let normalized_value = value.as_str().unwrap_or("").to_string();
if md_exists {
if let Some(ref mut data) = md_data {
data.body = normalized_value.clone();
md_modified = true;
}
} else if let Some(prompt_ref) = existing_agent.get("prompt").and_then(|v| v.as_str())
{
if is_prompt_file_reference(prompt_ref) {
if let Some(prompt_file_path) = resolve_prompt_file_path(prompt_ref) {
write_prompt_file(&prompt_file_path, &normalized_value).await?;
} else {
return Err(anyhow!(
"Invalid prompt file reference for agent {}",
agent_name
));
}
continue;
}
}
// Write prompt directly to JSON entry (file ref or inline string)
existing_agent.insert("prompt".to_string(), Value::String(normalized_value));
json_modified = true;
continue;
}
// Check where field is currently defined
let in_md = md_data
.as_ref()
.map(|data| data.frontmatter.contains_key(field))
.unwrap_or(false);
let in_json = existing_agent.contains_key(field);
if in_md {
// Update in .md frontmatter
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else if in_json {
// Update in opencode.json while preserving existing fields
existing_agent.insert(field.clone(), value.clone());
json_modified = true;
} else {
// Field not defined - apply priority rules
if md_exists && !existing_agent.is_empty() {
// Both exist → add to opencode.json (higher priority) without dropping other keys
existing_agent.insert(field.clone(), value.clone());
json_modified = true;
} else if md_exists {
// Only .md exists → add to frontmatter
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else {
// Only JSON or built-in → add/create section in opencode.json
existing_agent.insert(field.clone(), value.clone());
json_modified = true;
}
}
}
// Write changes
if md_modified {
if let Some(data) = md_data {
write_md_file(&md_path, &data.frontmatter, &data.body).await?;
}
}
if json_modified {
// Avoid creating a new JSON section for agents that already live exclusively in .md
if md_exists && !had_json_fields {
json_modified = false;
}
}
if json_modified {
if !config.is_object() {
config = Value::Object(Map::new());
}
let config_obj = config.as_object_mut().unwrap();
let agents_entry = config_obj
.entry("agent".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !agents_entry.is_object() {
*agents_entry = Value::Object(Map::new());
}
let agents_obj = agents_entry.as_object_mut().unwrap();
agents_obj.insert(agent_name.to_string(), Value::Object(existing_agent));
write_config(&config).await?;
}
info!(
"Updated agent: {} (md: {}, json: {})",
agent_name, md_modified, json_modified
);
Ok(())
}
/// Delete agent configuration
pub async fn delete_agent(agent_name: &str) -> Result<()> {
let md_path = get_agent_dir().join(format!("{}.md", agent_name));
let mut deleted = false;
// 1. Delete .md file if exists
if md_path.exists() {
fs::remove_file(&md_path).await?;
info!("Deleted agent .md file: {}", md_path.display());
deleted = true;
}
// 2. Remove section from opencode.json if exists
let mut config = read_config().await?;
if let Some(agents) = config.get_mut("agent").and_then(|v| v.as_object_mut()) {
if agents.remove(agent_name).is_some() {
write_config(&config).await?;
info!("Removed agent from opencode.json: {}", agent_name);
deleted = true;
}
}
// 3. If nothing was deleted (built-in agent), disable it
if !deleted {
if !config.is_object() {
config = Value::Object(serde_json::Map::new());
}
let config_obj = config.as_object_mut().unwrap();
if !config_obj.contains_key("agent") {
config_obj.insert("agent".to_string(), Value::Object(serde_json::Map::new()));
}
let agents = config_obj.get_mut("agent").unwrap();
if !agents.is_object() {
*agents = Value::Object(serde_json::Map::new());
}
let mut disable_obj = serde_json::Map::new();
disable_obj.insert("disable".to_string(), Value::Bool(true));
agents
.as_object_mut()
.unwrap()
.insert(agent_name.to_string(), Value::Object(disable_obj));
write_config(&config).await?;
info!("Disabled built-in agent: {}", agent_name);
}
Ok(())
}
/// Get information about where command configuration is stored
pub async fn get_command_sources(command_name: &str) -> Result<ConfigSources> {
ensure_dirs().await?;
let md_path = get_command_dir().join(format!("{}.md", command_name));
let md_exists = md_path.exists();
let mut md_fields = Vec::new();
if md_exists {
let md_data = parse_md_file(&md_path).await?;
md_fields.extend(md_data.frontmatter.keys().cloned());
if !md_data.body.trim().is_empty() {
md_fields.push("template".to_string());
}
}
let config = read_config().await?;
let json_section = config
.get("command")
.and_then(|v| v.as_object())
.and_then(|obj| obj.get(command_name));
let json_fields = json_section
.and_then(|value| value.as_object())
.map(|obj| obj.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let sources = ConfigSources {
md: SourceInfo {
exists: md_exists,
path: md_exists.then(|| md_path.display().to_string()),
fields: md_fields,
},
json: SourceInfo {
exists: json_section.is_some(),
path: Some(get_config_file().display().to_string()),
fields: json_fields,
},
};
Ok(sources)
}
/// Create new command as .md file
pub async fn create_command(command_name: &str, config: &HashMap<String, Value>) -> Result<()> {
ensure_dirs().await?;
let md_path = get_command_dir().join(format!("{}.md", command_name));
// Check if command already exists
if md_path.exists() {
return Err(anyhow!(
"Command {} already exists as .md file",
command_name
));
}
let existing_config = read_config().await?;
if let Some(commands) = existing_config.get("command").and_then(|v| v.as_object()) {
if commands.contains_key(command_name) {
return Err(anyhow!(
"Command {} already exists in opencode.json",
command_name
));
}
}
// Extract template from config
let mut frontmatter = config.clone();
let template = frontmatter
.remove("template")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_default();
// Write .md file
write_md_file(&md_path, &frontmatter, &template).await?;
info!("Created new command: {}", command_name);
Ok(())
}
/// Update existing command using field-level logic
pub async fn update_command(
command_name: &str,
updates: &HashMap<String, Value>,
) -> Result<()> {
ensure_dirs().await?;
let md_path = get_command_dir().join(format!("{}.md", command_name));
let md_exists = md_path.exists();
let mut md_data = if md_exists {
Some(parse_md_file(&md_path).await?)
} else {
None
};
let mut config = read_config().await?;
let mut existing_command = config
.get("command")
.and_then(|v| v.as_object())
.and_then(|obj| obj.get(command_name))
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_else(Map::new);
let had_json_fields = !existing_command.is_empty();
let mut md_modified = false;
let mut json_modified = false;
for (field, value) in updates.iter() {
// Handle explicit removals (null payload) for scalar/frontmatter/JSON fields
if value.is_null() {
if md_exists {
if let Some(ref mut data) = md_data {
if data.frontmatter.remove(field).is_some() {
md_modified = true;
}
}
}
if existing_command.remove(field).is_some() {
json_modified = true;
}
continue;
}
// Special handling for template field
if field == "template" {
let normalized_value = value.as_str().unwrap_or("").to_string();
if md_exists {
if let Some(ref mut data) = md_data {
data.body = normalized_value.clone();
md_modified = true;
}
continue;
} else if let Some(template_ref) = existing_command.get("template").and_then(|v| v.as_str()) {
if is_prompt_file_reference(template_ref) {
if let Some(template_file_path) = resolve_prompt_file_path(template_ref) {
write_prompt_file(&template_file_path, &normalized_value).await?;
} else {
return Err(anyhow!(
"Invalid template file reference for command {}",
command_name
));
}
continue;
}
}
// Write template directly to JSON entry (file ref or inline string)
existing_command.insert("template".to_string(), Value::String(normalized_value));
json_modified = true;
continue;
}
// Check where field is currently defined
let in_md = md_data
.as_ref()
.map(|data| data.frontmatter.contains_key(field))
.unwrap_or(false);
let in_json = existing_command.contains_key(field);
if in_md {
// Update in .md frontmatter
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else if in_json {
// Update in opencode.json while preserving existing fields
existing_command.insert(field.clone(), value.clone());
json_modified = true;
} else {
// Field not defined - apply priority rules
if md_exists && !existing_command.is_empty() {
// Both exist → add to opencode.json (higher priority)
existing_command.insert(field.clone(), value.clone());
json_modified = true;
} else if md_exists {
// Only .md exists → add to frontmatter
if let Some(ref mut data) = md_data {
data.frontmatter.insert(field.clone(), value.clone());
md_modified = true;
}
} else {
// Only JSON or built-in → add/create section in opencode.json
existing_command.insert(field.clone(), value.clone());
json_modified = true;
}
}
}
// Write changes
if md_modified {
if let Some(data) = md_data {
write_md_file(&md_path, &data.frontmatter, &data.body).await?;
}
}
if json_modified {
// Avoid creating a new JSON section for commands that already live exclusively in .md
if md_exists && !had_json_fields {
json_modified = false;
}
}
if json_modified {
if !config.is_object() {
config = Value::Object(Map::new());
}
let config_obj = config.as_object_mut().unwrap();
let commands_entry = config_obj
.entry("command".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !commands_entry.is_object() {
*commands_entry = Value::Object(Map::new());
}
let commands_obj = commands_entry.as_object_mut().unwrap();
commands_obj.insert(command_name.to_string(), Value::Object(existing_command));
write_config(&config).await?;
}
info!(
"Updated command: {} (md: {}, json: {})",
command_name, md_modified, json_modified
);
Ok(())
}
/// Delete command configuration
pub async fn delete_command(command_name: &str) -> Result<()> {
let md_path = get_command_dir().join(format!("{}.md", command_name));
let mut deleted = false;
// 1. Delete .md file if exists
if md_path.exists() {
fs::remove_file(&md_path).await?;
info!("Deleted command .md file: {}", md_path.display());
deleted = true;
}
// 2. Remove section from opencode.json if exists
let mut config = read_config().await?;
if let Some(commands) = config.get_mut("command").and_then(|v| v.as_object_mut()) {
if commands.remove(command_name).is_some() {
write_config(&config).await?;
info!("Removed command from opencode.json: {}", command_name);
deleted = true;
}
}
// 3. If nothing was deleted, throw error
if !deleted {
return Err(anyhow!("Command \"{}\" not found", command_name));
}
Ok(())
}
@@ -0,0 +1,577 @@
use anyhow::{anyhow, Result};
use log::{debug, info, warn};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
use regex::Regex;
use reqwest::Client;
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::{Child, Command},
sync::Mutex,
time::timeout,
};
static URL_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"https?://[^:\s]+:(?P<port>\d+)(?P<path>/[^\s"']*)?"#).expect("valid regex")
});
const FIRST_SIGNAL_TIMEOUT_MS: u64 = 750;
const READY_CHECK_TIMEOUT_MS: u64 = 20000;
const READY_CHECK_INTERVAL_MS: u64 = 400;
#[derive(Clone)]
pub struct OpenCodeManager {
binary: Option<String>,
args: Vec<String>,
env: HashMap<String, String>,
working_dir: Arc<RwLock<PathBuf>>,
desired_port: u16,
child: Arc<Mutex<Option<Child>>>,
port: Arc<RwLock<Option<u16>>>,
api_prefix: Arc<RwLock<String>>,
is_ready: Arc<AtomicBool>,
shutting_down: Arc<AtomicBool>,
http_client: Client,
}
fn normalize_api_prefix(prefix: &str) -> String {
let trimmed = prefix.trim();
if trimmed.is_empty() || trimmed == "/" {
return String::new();
}
let mut normalized = trimmed.trim_end_matches('/').to_string();
if !normalized.starts_with('/') {
normalized.insert(0, '/');
}
normalized
}
impl OpenCodeManager {
pub fn new_with_directory(initial_dir: Option<PathBuf>) -> Self {
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
.ok()
.and_then(|raw| raw.parse::<u16>().ok())
.unwrap_or(0);
let binary = resolve_opencode_binary();
if let Some(ref bin) = binary {
if !Path::new(bin).is_absolute() {
info!("[desktop:opencode] using PATH-resolved binary: {}", bin);
} else {
info!("[desktop:opencode] using binary: {}", bin);
}
} else {
warn!("[desktop:opencode] OpenCode CLI not found - app will run in limited mode");
}
let mut args = vec![
"serve".to_string(),
"--port".to_string(),
desired_port.to_string(),
];
if let Ok(config) = std::env::var("OPENCHAMBER_OPENCODE_CONFIG") {
if !config.is_empty() {
args.push("--config".to_string());
args.push(config);
}
}
let env = build_augmented_env();
let working_dir = initial_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
info!(
"[desktop:opencode] Initial working directory: {:?}",
working_dir
);
Self {
binary,
args,
env,
working_dir: Arc::new(RwLock::new(working_dir)),
desired_port,
child: Arc::new(Mutex::new(None)),
port: Arc::new(RwLock::new(None)),
api_prefix: Arc::new(RwLock::new(String::new())),
is_ready: Arc::new(AtomicBool::new(false)),
shutting_down: Arc::new(AtomicBool::new(false)),
http_client: Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
}
}
pub fn is_cli_available(&self) -> bool {
self.binary.is_some()
}
pub async fn ensure_running(&self) -> Result<()> {
if self.binary.is_none() {
return Err(anyhow!("OpenCode CLI is not available"));
}
let mut guard = self.child.lock().await;
if let Some(child) = guard.as_mut() {
if child.try_wait()?.is_none() && self.is_ready.load(Ordering::SeqCst) {
return Ok(());
}
}
self.is_ready.store(false, Ordering::SeqCst);
let child = self.spawn_process().await?;
*guard = Some(child);
drop(guard);
// Wait for port detection from logs
if self.desired_port == 0 {
self.wait_for_port_detection().await?;
}
// Detect API prefix early so proxy can forward correctly
let _ = self.detect_api_prefix().await;
// Wait for OpenCode to become ready by polling endpoints
self.wait_for_ready().await?;
self.is_ready.store(true, Ordering::SeqCst);
if let Some(port) = self.current_port() {
info!("[desktop:opencode] ready on port {port}");
}
Ok(())
}
pub async fn restart(&self) -> Result<()> {
info!("[desktop:opencode] restarting...");
self.is_ready.store(false, Ordering::SeqCst);
self.graceful_stop().await?;
// Brief delay to let OS release resources
tokio::time::sleep(Duration::from_millis(250)).await;
// Reset state
if self.desired_port == 0 {
*self.port.write() = None;
}
*self.api_prefix.write() = String::new();
self.ensure_running().await
}
pub async fn shutdown(&self) -> Result<()> {
self.shutting_down.store(true, Ordering::SeqCst);
self.is_ready.store(false, Ordering::SeqCst);
self.graceful_stop().await
}
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
*self.working_dir.write() = new_dir;
Ok(())
}
pub fn get_working_directory(&self) -> PathBuf {
self.working_dir.read().clone()
}
async fn detect_api_prefix(&self) -> Result<()> {
let Some(port) = self.current_port() else {
return Err(anyhow!("Cannot detect API prefix without port"));
};
// Try empty prefix first (OpenCode default), then /api (some installations)
let candidates = ["", "/api"];
for candidate in candidates {
let base = if candidate.is_empty() {
format!("http://127.0.0.1:{port}")
} else {
format!("http://127.0.0.1:{port}{candidate}")
};
let url = format!("{base}/config");
match self.http_client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
// Validate it's actually JSON config, not HTML
if let Ok(text) = resp.text().await {
if text.trim().starts_with('{') || text.trim().starts_with('[') {
info!("[desktop:opencode] Detected API prefix: {:?}", candidate);
*self.api_prefix.write() = normalize_api_prefix(candidate);
return Ok(());
}
}
}
_ => continue,
}
}
info!("[desktop:opencode] No API prefix detected, using empty prefix");
*self.api_prefix.write() = String::new();
Ok(())
}
pub fn current_port(&self) -> Option<u16> {
*self.port.read()
}
pub fn api_prefix(&self) -> String {
self.api_prefix.read().clone()
}
pub fn is_ready(&self) -> bool {
self.is_ready.load(Ordering::SeqCst)
}
pub fn rewrite_path(&self, incoming_path: &str) -> String {
// Strip /api prefix to get OpenCode path
let result = incoming_path
.strip_prefix("/api")
.map(|rest| if rest.is_empty() { "/" } else { rest })
.unwrap_or(incoming_path)
.to_string();
debug!(
"[opencode_manager] rewrite_path: '{}' -> '{}'",
incoming_path, result
);
result
}
async fn spawn_process(&self) -> Result<Child> {
let binary = self.binary.as_ref().ok_or_else(|| {
anyhow!("Cannot spawn process: OpenCode CLI is not available")
})?;
info!(
"[desktop:opencode] launching {} {:?}",
binary, self.args
);
let working_dir = self.working_dir.read().clone();
let mut cmd = Command::new(binary);
cmd.args(&self.args)
.current_dir(&working_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(false);
for (key, value) in &self.env {
cmd.env(key, value);
}
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow!(
"OpenCode binary '{}' not found. Set OPENCODE_BINARY or ensure it's in PATH.",
binary
)
} else {
anyhow!("Failed to spawn OpenCode: {}", e)
}
})?;
// Set port immediately if pre-configured
if self.desired_port > 0 {
*self.port.write() = Some(self.desired_port);
}
// Wait for first signal (stdout/stderr) within 750ms to confirm startup
let first_signal_received = Arc::new(AtomicBool::new(false));
if let Some(stdout) = child.stdout.take() {
let signal_flag = first_signal_received.clone();
self.spawn_output_reader(stdout, "stdout", move || {
signal_flag.store(true, Ordering::SeqCst);
});
}
if let Some(stderr) = child.stderr.take() {
let signal_flag = first_signal_received.clone();
self.spawn_output_reader(stderr, "stderr", move || {
signal_flag.store(true, Ordering::SeqCst);
});
}
// Wait for first signal or timeout
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_millis(FIRST_SIGNAL_TIMEOUT_MS) {
if first_signal_received.load(Ordering::SeqCst) {
break;
}
if let Ok(Some(_)) = child.try_wait() {
return Err(anyhow!("OpenCode process exited immediately after spawn"));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
Ok(child)
}
fn spawn_output_reader<F>(
&self,
stream: impl tokio::io::AsyncRead + Unpin + Send + 'static,
label: &'static str,
on_first_line: F,
) where
F: FnOnce() + Send + 'static,
{
let manager = self.clone();
let first_line_flag = Arc::new(Mutex::new(Some(on_first_line)));
tauri::async_runtime::spawn(async move {
let reader = BufReader::new(stream);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
// Trigger first signal callback
if let Some(callback) = first_line_flag.lock().await.take() {
callback();
}
debug!("[opencode:{label}] {line}");
manager.ingest_output_line(&line);
}
});
}
fn ingest_output_line(&self, line: &str) {
if let Some(captures) = URL_REGEX.captures(line) {
if let Some(port_match) = captures
.name("port")
.and_then(|m| m.as_str().parse::<u16>().ok())
{
*self.port.write() = Some(port_match);
}
if let Some(path_match) = captures.name("path") {
let value = path_match.as_str();
if !value.is_empty() && value != "/" {
*self.api_prefix.write() = value.to_string();
}
}
}
}
async fn wait_for_port_detection(&self) -> Result<()> {
let start = std::time::Instant::now();
let timeout_duration = Duration::from_secs(15);
while start.elapsed() < timeout_duration {
if self.current_port().is_some() {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(anyhow!("OpenCode did not report port within 15 seconds"))
}
async fn wait_for_ready(&self) -> Result<()> {
let Some(port) = self.current_port() else {
return Err(anyhow!("Cannot check readiness without port"));
};
let deadline = tokio::time::Instant::now() + Duration::from_millis(READY_CHECK_TIMEOUT_MS);
let mut last_error: Option<String> = None;
while tokio::time::Instant::now() < deadline {
let api_prefix = self.api_prefix();
// Try /health, /config, /agent endpoints
match self.check_endpoints(port, &api_prefix).await {
Ok(()) => {
// Once ready, attempt to detect and persist the API prefix for proxying
let _ = self.detect_api_prefix().await;
return Ok(());
}
Err(e) => {
last_error = Some(e.to_string());
}
}
tokio::time::sleep(Duration::from_millis(READY_CHECK_INTERVAL_MS)).await;
}
Err(anyhow!(
"OpenCode not ready after {}ms: {}",
READY_CHECK_TIMEOUT_MS,
last_error.unwrap_or_else(|| "no error details".to_string())
))
}
async fn check_endpoints(&self, port: u16, prefix: &str) -> Result<()> {
let base_url = format!("http://127.0.0.1:{port}{prefix}");
// Check /health
let health_url = format!("{base_url}/health");
let health_resp = self.http_client.get(&health_url).send().await?;
if !health_resp.status().is_success() {
return Err(anyhow!("/health returned {}", health_resp.status()));
}
// Check /config
let config_url = format!("{base_url}/config");
let config_resp = self.http_client.get(&config_url).send().await?;
if !config_resp.status().is_success() {
return Err(anyhow!("/config returned {}", config_resp.status()));
}
// Check /agent
let agent_url = format!("{base_url}/agent");
let agent_resp = self.http_client.get(&agent_url).send().await?;
if !agent_resp.status().is_success() {
return Err(anyhow!("/agent returned {}", agent_resp.status()));
}
Ok(())
}
async fn graceful_stop(&self) -> Result<()> {
let mut guard = self.child.lock().await;
let Some(mut child) = guard.take() else {
return Ok(());
};
if child.try_wait()?.is_some() {
// Already exited
return Ok(());
}
// SIGTERM
#[cfg(unix)]
{
use nix::{
sys::signal::{kill, Signal},
unistd::Pid,
};
if let Some(id) = child.id() {
let _ = kill(Pid::from_raw(id as i32), Signal::SIGTERM);
info!("[desktop:opencode] sent SIGTERM");
}
}
#[cfg(windows)]
{
let _ = child.kill().await;
}
// Wait 3 seconds for graceful exit
match timeout(Duration::from_secs(3), child.wait()).await {
Ok(_) => {
info!("[desktop:opencode] exited gracefully");
return Ok(());
}
Err(_) => {
warn!("[desktop:opencode] did not exit after SIGTERM, sending SIGKILL");
}
}
// SIGKILL
let _ = child.kill().await;
// Wait up to 5 seconds for hard kill
match timeout(Duration::from_secs(5), child.wait()).await {
Ok(_) => {
info!("[desktop:opencode] exited after SIGKILL");
}
Err(_) => {
warn!("[desktop:opencode] unresponsive after SIGKILL, continuing anyway");
}
}
Ok(())
}
}
/// Check if CLI binary exists (can be called dynamically for polling)
pub fn check_cli_exists() -> bool {
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
return false;
}
resolve_opencode_binary().is_some()
}
fn resolve_opencode_binary() -> Option<String> {
if std::env::var("OPENCHAMBER_DISABLE_CLI").is_ok() {
return None;
}
// Check explicit override
if let Ok(value) = std::env::var("OPENCODE_BINARY") {
if !value.is_empty() && Path::new(&value).exists() {
info!("[desktop:opencode] using binary from OPENCODE_BINARY: {}", value);
return Some(value);
}
}
// Find in PATH
if let Ok(output) = std::process::Command::new("which")
.arg("opencode")
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
info!("[desktop:opencode] found binary in PATH: {}", path);
return Some(path);
}
}
}
warn!("[desktop:opencode] opencode binary not found in PATH");
None
}
fn build_augmented_env() -> HashMap<String, String> {
let mut env: HashMap<String, String> = std::env::vars().collect();
if let Ok(login_path) = detect_login_shell_path() {
let current = env.get("PATH").cloned().unwrap_or_default();
env.insert("PATH".to_string(), merge_paths(&login_path, &current));
}
env
}
fn merge_paths(login_path: &str, current: &str) -> String {
let mut segments = Vec::new();
let mut seen = std::collections::HashSet::new();
for part in login_path.split(':').chain(current.split(':')) {
if part.is_empty() || seen.contains(part) {
continue;
}
seen.insert(part.to_string());
segments.push(part);
}
segments.join(":")
}
fn detect_login_shell_path() -> Result<String> {
#[cfg(not(unix))]
{
Err(anyhow!("login shell path unsupported"))
}
#[cfg(unix)]
{
use std::process::Command;
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into());
let output = Command::new(&shell)
.arg("-lic")
.arg("echo -n $PATH")
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(anyhow!("shell PATH detection failed"))
}
}
}
@@ -0,0 +1,326 @@
use std::{
collections::HashMap,
sync::Arc,
time::Duration,
};
use anyhow::Result;
use futures_util::TryStreamExt;
use log::{debug, info, warn};
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use tokio_util::io::StreamReader;
use crate::DesktopRuntime;
#[derive(Deserialize)]
struct EventEnvelope {
#[serde(rename = "type")]
event_type: String,
#[serde(default)]
properties: Value,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ActivityPhase {
Idle,
Busy,
Cooldown,
}
pub fn spawn_session_activity_tracker(
app: AppHandle,
runtime: DesktopRuntime,
) -> tauri::async_runtime::JoinHandle<()> {
tauri::async_runtime::spawn(async move {
let client = Client::builder()
.timeout(Duration::from_secs(24 * 60 * 60))
.tcp_keepalive(Some(Duration::from_secs(30)))
.build()
.expect("failed to build reqwest client");
let mut shutdown_rx = runtime.subscribe_shutdown();
let phases = Arc::new(Mutex::new(HashMap::<String, ActivityPhase>::new()));
let cooldowns = Arc::new(Mutex::new(HashMap::<String, tauri::async_runtime::JoinHandle<()>>::new()));
loop {
tokio::select! {
_ = shutdown_rx.recv() => {
info!("[desktop:activity] Shutdown received, stopping SSE listener");
break;
}
_ = async {
// Reset stale phases to idle before connecting so UI doesn't stay stuck on "working" after wake.
reset_and_emit_all_phases(&app, phases.clone(), cooldowns.clone()).await;
if let Err(err) = run_once(&app, &runtime, &client, phases.clone(), cooldowns.clone()).await {
warn!("[desktop:activity] SSE loop error: {err:?}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
} => {}
}
}
})
}
async fn run_once(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) -> Result<()> {
let opencode = runtime.opencode_manager();
let port = match opencode.current_port() {
Some(port) => port,
None => {
warn!("[desktop:activity] OpenCode port unavailable; will retry");
tokio::time::sleep(Duration::from_secs(2)).await;
return Ok(());
}
};
let prefix = opencode.api_prefix();
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
if let Some(dir) = opencode.get_working_directory().to_str().map(|s| s.to_string()) {
let mut parsed = reqwest::Url::parse(&url)?;
parsed
.query_pairs_mut()
.append_pair("directory", &dir);
url = parsed.to_string();
}
debug!("[desktop:activity] Connecting SSE for activity phases: {url}");
let response = client
.get(&url)
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
.send()
.await?;
debug!(
"[desktop:activity] SSE response status={} headers={:?}",
response.status(),
response.headers()
);
if !response.status().is_success() {
warn!(
"[desktop:activity] SSE connect failed with status {}",
response.status()
);
tokio::time::sleep(Duration::from_secs(2)).await;
return Ok(());
}
use tokio::io::AsyncBufReadExt;
let stream = response
.bytes_stream()
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err));
let mut reader = StreamReader::new(stream);
let mut buf = Vec::new();
let mut data_lines: Vec<String> = Vec::new();
loop {
buf.clear();
let bytes_read = match reader.read_until(b'\n', &mut buf).await {
Ok(n) => n,
Err(err) => {
warn!("[desktop:activity] Read error in SSE stream: {err:?}");
return Err(err.into());
}
};
if bytes_read == 0 {
break;
}
let line = match std::str::from_utf8(&buf) {
Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
Err(err) => {
warn!("[desktop:activity] Non-UTF8 SSE chunk: {err}");
continue;
}
};
if line.is_empty() {
if data_lines.is_empty() {
continue;
}
let raw = data_lines.join("\n");
data_lines.clear();
match serde_json::from_str::<EventEnvelope>(&raw) {
Ok(event) => handle_event(app, event, phases.clone(), cooldowns.clone()).await,
Err(err) => {
warn!("[desktop:activity] Failed to parse SSE data: {err}; raw={raw}");
}
}
continue;
}
if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start().to_string());
}
}
Ok(())
}
async fn handle_event(
app: &AppHandle,
event: EventEnvelope,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) {
match event.event_type.as_str() {
"session.status" => {
let session_id = event
.properties
.get("sessionID")
.and_then(Value::as_str)
.map(|s| s.to_string());
let status = event
.properties
.get("status")
.and_then(|s| s.get("type"))
.and_then(Value::as_str);
if let (Some(id), Some(status_type)) = (session_id, status) {
let phase = if status_type == "busy" || status_type == "retry" {
ActivityPhase::Busy
} else {
ActivityPhase::Idle
};
set_phase(app, &id, phase, phases.clone(), cooldowns.clone()).await;
}
}
"message.updated" => {
if let Some(info) = event.properties.get("info") {
let role = info.get("role").and_then(Value::as_str).unwrap_or_default();
if role != "assistant" {
return;
}
let finish = info.get("finish").and_then(Value::as_str);
if finish != Some("stop") {
return;
}
let session_id = info
.get("sessionID")
.and_then(Value::as_str)
.map(|s| s.to_string());
if let Some(id) = session_id {
// If current phase is busy, move to cooldown for 2s then idle
let current = { phases.lock().await.get(&id).cloned() };
if matches!(current, Some(ActivityPhase::Busy)) {
set_phase(app, &id, ActivityPhase::Cooldown, phases.clone(), cooldowns.clone()).await;
let app_clone = app.clone();
let phases_clone = phases.clone();
let cooldowns_clone = cooldowns.clone();
let id_clone = id.clone();
let handle = tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_secs(2)).await;
let current = { phases_clone.lock().await.get(&id_clone).cloned() };
if matches!(current, Some(ActivityPhase::Cooldown)) {
set_phase(&app_clone, &id_clone, ActivityPhase::Idle, phases_clone, cooldowns_clone).await;
}
});
// Store cooldown handle to cancel if phase changes earlier
let mut cd = cooldowns.lock().await;
if let Some(prev) = cd.remove(&id) {
prev.abort();
}
cd.insert(id, handle);
}
}
}
}
_ => {}
}
}
async fn set_phase(
app: &AppHandle,
session_id: &str,
phase: ActivityPhase,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) {
{
let mut map = phases.lock().await;
let current = map.get(session_id);
if current == Some(&phase) {
return;
}
map.insert(session_id.to_string(), phase.clone());
// Cancel cooldown timer when leaving cooldown
if !matches!(phase, ActivityPhase::Cooldown) {
if let Some(handle) = cooldowns.lock().await.remove(session_id) {
handle.abort();
}
}
}
// Emit to webview so UI stays in sync
let payload = serde_json::json!({
"sessionId": session_id,
"phase": match phase {
ActivityPhase::Idle => "idle",
ActivityPhase::Busy => "busy",
ActivityPhase::Cooldown => "cooldown",
}
});
let _ = app.emit("openchamber:session-activity", payload);
}
async fn reset_and_emit_all_phases(
app: &AppHandle,
phases: Arc<Mutex<HashMap<String, ActivityPhase>>>,
cooldowns: Arc<Mutex<HashMap<String, tauri::async_runtime::JoinHandle<()>>>>,
) {
// Cancel any cooldown timers and set all phases to idle to avoid stale "busy" after wake.
{
let mut cd = cooldowns.lock().await;
for handle in cd.values() {
handle.abort();
}
cd.clear();
}
let snapshot = {
let mut guard = phases.lock().await;
for value in guard.values_mut() {
*value = ActivityPhase::Idle;
}
guard.clone()
};
if snapshot.is_empty() {
return;
}
for (session_id, phase) in snapshot {
let payload = serde_json::json!({
"sessionId": session_id,
"phase": match phase {
ActivityPhase::Idle => "idle",
ActivityPhase::Busy => "busy",
ActivityPhase::Cooldown => "cooldown",
}
});
let _ = app.emit("openchamber:session-activity", payload);
}
}
@@ -0,0 +1,167 @@
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use tauri::{LogicalPosition, LogicalSize, WebviewWindow, Window};
use tokio::fs as async_fs;
const WINDOW_STATE_FILE: &str = "window-state.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowState {
pub width: f64,
pub height: f64,
pub x: f64,
pub y: f64,
pub is_maximized: bool,
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: 1280.0,
height: 800.0,
x: 0.0,
y: 0.0,
is_maximized: false,
}
}
}
#[derive(Serialize, Deserialize)]
struct WindowStateFile {
#[serde(rename = "windowState")]
pub window_state: WindowState,
}
#[derive(Clone)]
pub struct WindowStateManager {
inner: Arc<Mutex<WindowState>>,
}
impl WindowStateManager {
pub fn new(initial: WindowState) -> Self {
Self {
inner: Arc::new(Mutex::new(initial)),
}
}
pub fn snapshot(&self) -> WindowState {
self.inner.lock().expect("window state poisoned").clone()
}
pub fn update_position(&self, x: f64, y: f64, is_maximized: bool) {
if is_maximized {
return;
}
if let Ok(mut state) = self.inner.lock() {
if !state.is_maximized {
state.x = x;
state.y = y;
}
}
}
pub fn update_size(&self, width: f64, height: f64, is_maximized: bool) {
if let Ok(mut state) = self.inner.lock() {
if !is_maximized {
state.width = width;
state.height = height;
}
state.is_maximized = is_maximized;
}
}
}
fn state_file_path() -> Result<PathBuf> {
let mut path = dirs::home_dir().ok_or_else(|| anyhow!("No home directory"))?;
path.push(".config");
path.push("openchamber");
path.push(WINDOW_STATE_FILE);
Ok(path)
}
pub async fn load_window_state() -> Result<Option<WindowState>> {
let path = state_file_path()?;
match async_fs::read(&path).await {
Ok(bytes) => {
let file: WindowStateFile = serde_json::from_slice(&bytes)?;
Ok(Some(file.window_state))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
pub async fn save_window_state(state: &WindowState) -> Result<()> {
let path = state_file_path()?;
if let Some(parent) = path.parent() {
async_fs::create_dir_all(parent).await?;
}
let payload = WindowStateFile {
window_state: state.clone(),
};
let data = serde_json::to_vec_pretty(&payload)?;
async_fs::write(&path, data).await?;
Ok(())
}
pub fn apply_window_state(window: &WebviewWindow, state: &WindowState) -> Result<()> {
let mut normalized = state.clone();
clamp_to_visible_region(window, &mut normalized);
if normalized.width > 0.0 && normalized.height > 0.0 {
let _ = window.set_size(LogicalSize::new(normalized.width, normalized.height));
}
let _ = window.set_position(LogicalPosition::new(normalized.x, normalized.y));
if state.is_maximized {
let _ = window.maximize();
} else {
let _ = window.unmaximize();
}
Ok(())
}
pub async fn persist_window_state(window: &Window, manager: &WindowStateManager) -> Result<()> {
let mut snapshot = manager.snapshot();
let is_maximized = window.is_maximized().unwrap_or(snapshot.is_maximized);
snapshot.is_maximized = is_maximized;
if !is_maximized {
let scale_factor = window.scale_factor().unwrap_or(1.0);
if let Ok(size) = window.outer_size() {
let logical: LogicalSize<f64> = size.to_logical(scale_factor);
snapshot.width = logical.width.max(200.0);
snapshot.height = logical.height.max(200.0);
}
if let Ok(position) = window.outer_position() {
let logical: LogicalPosition<f64> = position.to_logical(scale_factor);
snapshot.x = logical.x;
snapshot.y = logical.y;
}
}
save_window_state(&snapshot).await
}
fn clamp_to_visible_region(window: &WebviewWindow, state: &mut WindowState) {
let monitor = match window.current_monitor() {
Ok(Some(monitor)) => monitor,
_ => return,
};
let scale_factor = monitor.scale_factor();
let monitor_size: LogicalSize<f64> = monitor.size().to_logical(scale_factor);
let monitor_position: LogicalPosition<f64> = monitor.position().to_logical(scale_factor);
state.width = state.width.clamp(400.0, monitor_size.width);
state.height = state.height.clamp(300.0, monitor_size.height);
let max_x = monitor_position.x + (monitor_size.width - state.width).max(0.0);
let max_y = monitor_position.y + (monitor_size.height - state.height).max(0.0);
state.x = state.x.clamp(monitor_position.x, max_x);
state.y = state.y.clamp(monitor_position.y, max_y);
}