use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri_plugin_shell::{ ShellExt, process::{Command, CommandChild, CommandEvent}, }; use crate::{LogState, constants::MAX_LOG_ENTRIES}; const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_BINARY_NAME: &str = "opencode"; #[derive(serde::Deserialize)] pub struct ServerConfig { pub hostname: Option, pub port: Option, } #[derive(serde::Deserialize)] pub struct Config { pub server: Option, } pub async fn get_config(app: &AppHandle) -> Option { create_command(app, "debug config") .output() .await .inspect_err(|e| eprintln!("Failed to read OC config: {e}")) .ok() .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok()) .and_then(|s| serde_json::from_str::(&s).ok()) } fn get_cli_install_path() -> Option { std::env::var("HOME").ok().map(|home| { std::path::PathBuf::from(home) .join(CLI_INSTALL_DIR) .join(CLI_BINARY_NAME) }) } pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf { // Get binary with symlinks support tauri::process::current_binary(&app.env()) .expect("Failed to get current binary") .parent() .expect("Failed to get parent dir") .join("opencode-cli") } fn is_cli_installed() -> bool { get_cli_install_path() .map(|path| path.exists()) .unwrap_or(false) } const INSTALL_SCRIPT: &str = include_str!("../../../../install"); #[tauri::command] #[specta::specta] pub fn install_cli(app: tauri::AppHandle) -> Result { if cfg!(not(unix)) { return Err("CLI installation is only supported on macOS & Linux".to_string()); } let sidecar = get_sidecar_path(&app); if !sidecar.exists() { return Err("Sidecar binary not found".to_string()); } let temp_script = std::env::temp_dir().join("opencode-install.sh"); std::fs::write(&temp_script, INSTALL_SCRIPT) .map_err(|e| format!("Failed to write install script: {}", e))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755)) .map_err(|e| format!("Failed to set script permissions: {}", e))?; } let output = std::process::Command::new(&temp_script) .arg("--binary") .arg(&sidecar) .output() .map_err(|e| format!("Failed to run install script: {}", e))?; let _ = std::fs::remove_file(&temp_script); if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("Install script failed: {}", stderr)); } let install_path = get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?; Ok(install_path.to_string_lossy().to_string()) } pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> { if cfg!(debug_assertions) { println!("Skipping CLI sync for debug build"); return Ok(()); } if !is_cli_installed() { println!("No CLI installation found, skipping sync"); return Ok(()); } let cli_path = get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?; let output = std::process::Command::new(&cli_path) .arg("--version") .output() .map_err(|e| format!("Failed to get CLI version: {}", e))?; if !output.status.success() { return Err("Failed to get CLI version".to_string()); } let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); let cli_version = semver::Version::parse(&cli_version_str) .map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?; let app_version = app.package_info().version.clone(); if cli_version >= app_version { println!( "CLI version {} is up to date (app version: {}), skipping sync", cli_version, app_version ); return Ok(()); } println!( "CLI version {} is older than app version {}, syncing", cli_version, app_version ); install_cli(app)?; println!("Synced installed CLI"); Ok(()) } fn get_user_shell() -> String { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) } pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { let state_dir = app .path() .resolve("", BaseDirectory::AppLocalData) .expect("Failed to resolve app local data dir"); #[cfg(target_os = "windows")] return app .shell() .sidecar("opencode-cli") .unwrap() .args(args.split_whitespace()) .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") .env("OPENCODE_CLIENT", "desktop") .env("XDG_STATE_HOME", &state_dir); #[cfg(not(target_os = "windows"))] return { let sidecar = get_sidecar_path(app); let shell = get_user_shell(); let cmd = if shell.ends_with("/nu") { format!("^\"{}\" {}", sidecar.display(), args) } else { format!("\"{}\" {}", sidecar.display(), args) }; app.shell() .command(&shell) .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") .env("OPENCODE_CLIENT", "desktop") .env("XDG_STATE_HOME", &state_dir) .args(["-il", "-c", &cmd]) }; } pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild { let log_state = app.state::(); let log_state_clone = log_state.inner().clone(); println!("spawning sidecar on port {port}"); let (mut rx, child) = create_command( app, format!("serve --hostname {hostname} --port {port}").as_str(), ) .env("OPENCODE_SERVER_USERNAME", "opencode") .env("OPENCODE_SERVER_PASSWORD", password) .spawn() .expect("Failed to spawn opencode"); tokio::spawn(async move { while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes); print!("{line}"); // Store log in shared state if let Ok(mut logs) = log_state_clone.0.lock() { logs.push_back(format!("[STDOUT] {}", line)); // Keep only the last MAX_LOG_ENTRIES while logs.len() > MAX_LOG_ENTRIES { logs.pop_front(); } } } CommandEvent::Stderr(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes); eprint!("{line}"); // Store log in shared state if let Ok(mut logs) = log_state_clone.0.lock() { logs.push_back(format!("[STDERR] {}", line)); // Keep only the last MAX_LOG_ENTRIES while logs.len() > MAX_LOG_ENTRIES { logs.pop_front(); } } } _ => {} } } }); child }