| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741 |
- use futures::{FutureExt, Stream, StreamExt, future};
- use process_wrap::tokio::CommandWrap;
- #[cfg(unix)]
- use process_wrap::tokio::ProcessGroup;
- #[cfg(windows)]
- use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
- use std::collections::HashMap;
- #[cfg(unix)]
- use std::os::unix::process::ExitStatusExt;
- use std::path::Path;
- use std::process::Stdio;
- use std::sync::Arc;
- use std::time::{Duration, Instant};
- use tauri::{AppHandle, Manager, path::BaseDirectory};
- use tauri_specta::Event;
- use tokio::{
- io::{AsyncBufRead, AsyncBufReadExt, BufReader},
- process::Command,
- sync::{mpsc, oneshot},
- task::JoinHandle,
- };
- use tokio_stream::wrappers::ReceiverStream;
- use tracing::Instrument;
- #[cfg(windows)]
- use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
- use crate::server::get_wsl_config;
- #[cfg(windows)]
- #[derive(Clone, Copy, Debug)]
- // Keep this as a custom wrapper instead of process_wrap::CreationFlags.
- // JobObject pre_spawn rewrites creation flags, so this must run after it.
- struct WinCreationFlags;
- #[cfg(windows)]
- impl CommandWrapper for WinCreationFlags {
- fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
- command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
- Ok(())
- }
- }
- const CLI_INSTALL_DIR: &str = ".opencode/bin";
- const CLI_BINARY_NAME: &str = "opencode";
- const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
- #[derive(serde::Deserialize, Debug)]
- pub struct ServerConfig {
- pub hostname: Option<String>,
- pub port: Option<u32>,
- }
- #[derive(serde::Deserialize, Debug)]
- pub struct Config {
- pub server: Option<ServerConfig>,
- }
- #[derive(Clone, Debug)]
- pub enum CommandEvent {
- Stdout(String),
- Stderr(String),
- Error(String),
- Terminated(TerminatedPayload),
- }
- #[derive(Clone, Copy, Debug)]
- pub struct TerminatedPayload {
- pub code: Option<i32>,
- pub signal: Option<i32>,
- }
- #[derive(Clone, Debug)]
- pub struct CommandChild {
- kill: mpsc::Sender<()>,
- }
- impl CommandChild {
- pub fn kill(&self) -> std::io::Result<()> {
- self.kill
- .try_send(())
- .map_err(|e| std::io::Error::other(e.to_string()))
- }
- }
- pub async fn get_config(app: &AppHandle) -> Option<Config> {
- let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
- events
- .fold(String::new(), async |mut config_str, event| {
- if let CommandEvent::Stdout(s) = &event {
- config_str += s.as_str()
- }
- if let CommandEvent::Stderr(s) = &event {
- config_str += s.as_str()
- }
- config_str
- })
- .map(|v| serde_json::from_str::<Config>(&v))
- .await
- .ok()
- }
- fn get_cli_install_path() -> Option<std::path::PathBuf> {
- 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<String, String> {
- 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) {
- tracing::debug!("Skipping CLI sync for debug build");
- return Ok(());
- }
- if !is_cli_installed() {
- tracing::info!("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 {
- tracing::info!(
- %cli_version, %app_version,
- "CLI is up to date, skipping sync"
- );
- return Ok(());
- }
- tracing::info!(
- %cli_version, %app_version,
- "CLI is older than app version, syncing"
- );
- install_cli(app)?;
- tracing::info!("Synced installed CLI");
- Ok(())
- }
- fn get_user_shell() -> String {
- std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
- }
- fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
- get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
- }
- fn shell_escape(input: &str) -> String {
- if input.is_empty() {
- return "''".to_string();
- }
- let mut escaped = String::from("'");
- escaped.push_str(&input.replace("'", "'\"'\"'"));
- escaped.push('\'');
- escaped
- }
- fn parse_shell_env(stdout: &[u8]) -> HashMap<String, String> {
- String::from_utf8_lossy(stdout)
- .split('\0')
- .filter_map(|line| {
- if line.is_empty() {
- return None;
- }
- let (key, value) = line.split_once('=')?;
- if key.is_empty() {
- return None;
- }
- Some((key.to_string(), value.to_string()))
- })
- .collect()
- }
- fn command_output_with_timeout(
- mut cmd: std::process::Command,
- timeout: Duration,
- ) -> std::io::Result<Option<std::process::Output>> {
- let mut child = cmd.spawn()?;
- let start = Instant::now();
- loop {
- if child.try_wait()?.is_some() {
- return child.wait_with_output().map(Some);
- }
- if start.elapsed() >= timeout {
- let _ = child.kill();
- let _ = child.wait();
- return Ok(None);
- }
- std::thread::sleep(Duration::from_millis(25));
- }
- }
- enum ShellEnvProbe {
- Loaded(HashMap<String, String>),
- Timeout,
- Unavailable,
- }
- fn probe_shell_env(shell: &str, mode: &str) -> ShellEnvProbe {
- let mut cmd = std::process::Command::new(shell);
- cmd.args([mode, "-c", "env -0"]);
- cmd.stdin(Stdio::null());
- cmd.stdout(Stdio::piped());
- cmd.stderr(Stdio::null());
- let output = match command_output_with_timeout(cmd, SHELL_ENV_TIMEOUT) {
- Ok(Some(output)) => output,
- Ok(None) => return ShellEnvProbe::Timeout,
- Err(error) => {
- tracing::debug!(shell, mode, ?error, "Shell env probe failed");
- return ShellEnvProbe::Unavailable;
- }
- };
- if !output.status.success() {
- tracing::debug!(shell, mode, "Shell env probe exited with non-zero status");
- return ShellEnvProbe::Unavailable;
- }
- let env = parse_shell_env(&output.stdout);
- if env.is_empty() {
- tracing::debug!(shell, mode, "Shell env probe returned empty env");
- return ShellEnvProbe::Unavailable;
- }
- ShellEnvProbe::Loaded(env)
- }
- fn is_nushell(shell: &str) -> bool {
- let shell_name = Path::new(shell)
- .file_name()
- .and_then(|name| name.to_str())
- .unwrap_or(shell)
- .to_ascii_lowercase();
- shell_name == "nu" || shell_name == "nu.exe" || shell.to_ascii_lowercase().ends_with("\\nu.exe")
- }
- fn load_shell_env(shell: &str) -> Option<HashMap<String, String>> {
- if is_nushell(shell) {
- tracing::debug!(shell, "Skipping shell env probe for nushell");
- return None;
- }
- match probe_shell_env(shell, "-il") {
- ShellEnvProbe::Loaded(env) => {
- tracing::info!(
- shell,
- env_count = env.len(),
- "Loaded shell environment with -il"
- );
- return Some(env);
- }
- ShellEnvProbe::Timeout => {
- tracing::warn!(shell, "Interactive shell env probe timed out");
- return None;
- }
- ShellEnvProbe::Unavailable => {}
- }
- if let ShellEnvProbe::Loaded(env) = probe_shell_env(shell, "-l") {
- tracing::info!(
- shell,
- env_count = env.len(),
- "Loaded shell environment with -l"
- );
- return Some(env);
- }
- tracing::warn!(shell, "Falling back to app environment");
- None
- }
- fn merge_shell_env(
- shell_env: Option<HashMap<String, String>>,
- envs: Vec<(String, String)>,
- ) -> Vec<(String, String)> {
- let mut merged = shell_env.unwrap_or_default();
- for (key, value) in envs {
- merged.insert(key, value);
- }
- merged.into_iter().collect()
- }
- pub fn spawn_command(
- app: &tauri::AppHandle,
- args: &str,
- extra_env: &[(&str, String)],
- ) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), std::io::Error> {
- let state_dir = app
- .path()
- .resolve("", BaseDirectory::AppLocalData)
- .expect("Failed to resolve app local data dir");
- let mut envs = vec![
- (
- "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(),
- "true".to_string(),
- ),
- (
- "OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(),
- "true".to_string(),
- ),
- ("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
- (
- "XDG_STATE_HOME".to_string(),
- state_dir.to_string_lossy().to_string(),
- ),
- ];
- envs.extend(
- extra_env
- .iter()
- .map(|(key, value)| (key.to_string(), value.clone())),
- );
- let mut cmd = if cfg!(windows) {
- if is_wsl_enabled(app) {
- tracing::info!("WSL is enabled, spawning CLI server in WSL");
- let version = app.package_info().version.to_string();
- let mut script = vec![
- "set -e".to_string(),
- "BIN=\"$HOME/.opencode/bin/opencode\"".to_string(),
- "if [ ! -x \"$BIN\" ]; then".to_string(),
- format!(
- " curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
- shell_escape(&version)
- ),
- "fi".to_string(),
- ];
- let mut env_prefix = vec![
- "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(),
- "OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(),
- "OPENCODE_CLIENT=desktop".to_string(),
- "XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(),
- ];
- env_prefix.extend(
- envs.iter()
- .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
- .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER")
- .filter(|(key, _)| key != "OPENCODE_CLIENT")
- .filter(|(key, _)| key != "XDG_STATE_HOME")
- .map(|(key, value)| format!("{}={}", key, shell_escape(value))),
- );
- script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
- let mut cmd = Command::new("wsl");
- cmd.args(["-e", "bash", "-lc", &script.join("\n")]);
- cmd
- } else {
- let sidecar = get_sidecar_path(app);
- let mut cmd = Command::new(sidecar);
- cmd.args(args.split_whitespace());
- for (key, value) in envs {
- cmd.env(key, value);
- }
- cmd
- }
- } else {
- let sidecar = get_sidecar_path(app);
- let shell = get_user_shell();
- let envs = merge_shell_env(load_shell_env(&shell), envs);
- let line = if shell.ends_with("/nu") {
- format!("^\"{}\" {}", sidecar.display(), args)
- } else {
- format!("\"{}\" {}", sidecar.display(), args)
- };
- let mut cmd = Command::new(shell);
- cmd.args(["-l", "-c", &line]);
- for (key, value) in envs {
- cmd.env(key, value);
- }
- cmd
- };
- cmd.stdout(Stdio::piped());
- cmd.stderr(Stdio::piped());
- cmd.stdin(Stdio::null());
- let mut wrap = CommandWrap::from(cmd);
- #[cfg(unix)]
- {
- wrap.wrap(ProcessGroup::leader());
- }
- #[cfg(windows)]
- {
- wrap.wrap(JobObject).wrap(WinCreationFlags).wrap(KillOnDrop);
- }
- let mut child = wrap.spawn()?;
- let guard = Arc::new(tokio::sync::RwLock::new(()));
- let (tx, rx) = mpsc::channel(256);
- let (kill_tx, mut kill_rx) = mpsc::channel(1);
- let stdout = spawn_pipe_reader(
- tx.clone(),
- guard.clone(),
- BufReader::new(child.stdout().take().unwrap()),
- CommandEvent::Stdout,
- );
- let stderr = spawn_pipe_reader(
- tx.clone(),
- guard.clone(),
- BufReader::new(child.stderr().take().unwrap()),
- CommandEvent::Stderr,
- );
- tokio::task::spawn(async move {
- let mut kill_open = true;
- let status = loop {
- match child.try_wait() {
- Ok(Some(status)) => break Ok(status),
- Ok(None) => {}
- Err(err) => break Err(err),
- }
- tokio::select! {
- msg = kill_rx.recv(), if kill_open => {
- if msg.is_some() {
- let _ = child.start_kill();
- }
- kill_open = false;
- }
- _ = tokio::time::sleep(Duration::from_millis(100)) => {}
- }
- };
- match status {
- Ok(status) => {
- let payload = TerminatedPayload {
- code: status.code(),
- signal: signal_from_status(status),
- };
- let _ = tx.send(CommandEvent::Terminated(payload)).await;
- }
- Err(err) => {
- let _ = tx.send(CommandEvent::Error(err.to_string())).await;
- }
- }
- stdout.abort();
- stderr.abort();
- });
- let event_stream = ReceiverStream::new(rx);
- let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream);
- Ok((event_stream, CommandChild { kill: kill_tx }))
- }
- fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
- #[cfg(unix)]
- return status.signal();
- #[cfg(not(unix))]
- {
- let _ = status;
- None
- }
- }
- pub fn serve(
- app: &AppHandle,
- hostname: &str,
- port: u32,
- password: &str,
- ) -> (CommandChild, oneshot::Receiver<TerminatedPayload>) {
- let (exit_tx, exit_rx) = oneshot::channel::<TerminatedPayload>();
- tracing::info!(port, "Spawning sidecar");
- let envs = [
- ("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
- ("OPENCODE_SERVER_PASSWORD", password.to_string()),
- ];
- let (events, child) = spawn_command(
- app,
- format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
- &envs,
- )
- .expect("Failed to spawn opencode");
- let mut exit_tx = Some(exit_tx);
- tokio::spawn(
- events
- .for_each(move |event| {
- match event {
- CommandEvent::Stdout(line) => {
- tracing::info!("{line}");
- }
- CommandEvent::Stderr(line) => {
- tracing::info!("{line}");
- }
- CommandEvent::Error(err) => {
- tracing::error!("{err}");
- }
- CommandEvent::Terminated(payload) => {
- tracing::info!(
- code = ?payload.code,
- signal = ?payload.signal,
- "Sidecar terminated"
- );
- if let Some(tx) = exit_tx.take() {
- let _ = tx.send(payload);
- }
- }
- }
- future::ready(())
- })
- .instrument(tracing::info_span!("sidecar")),
- );
- (child, exit_rx)
- }
- pub mod sqlite_migration {
- use super::*;
- #[derive(
- tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type,
- )]
- #[serde(tag = "type", content = "value")]
- pub enum SqliteMigrationProgress {
- InProgress(u8),
- Done,
- }
- pub(super) fn logs_middleware(
- app: AppHandle,
- stream: impl Stream<Item = CommandEvent>,
- ) -> impl Stream<Item = CommandEvent> {
- let app = app.clone();
- let mut done = false;
- stream.filter_map(move |event| {
- if done {
- return future::ready(Some(event));
- }
- future::ready(match &event {
- CommandEvent::Stdout(s) | CommandEvent::Stderr(s) => {
- if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
- if let Ok(progress) = s.parse::<u8>() {
- let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
- } else if s == "done" {
- done = true;
- let _ = SqliteMigrationProgress::Done.emit(&app);
- }
- None
- } else {
- Some(event)
- }
- }
- _ => Some(event),
- })
- })
- }
- }
- fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
- tx: mpsc::Sender<CommandEvent>,
- guard: Arc<tokio::sync::RwLock<()>>,
- pipe_reader: impl AsyncBufRead + Send + Unpin + 'static,
- wrapper: F,
- ) -> JoinHandle<()> {
- tokio::spawn(async move {
- let _lock = guard.read().await;
- let reader = BufReader::new(pipe_reader);
- read_line(reader, tx, wrapper).await;
- })
- }
- async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
- reader: BufReader<impl AsyncBufRead + Unpin>,
- tx: mpsc::Sender<CommandEvent>,
- wrapper: F,
- ) {
- let mut lines = reader.lines();
- loop {
- let line = lines.next_line().await;
- match line {
- Ok(s) => {
- if let Some(s) = s {
- let _ = tx.clone().send(wrapper(s)).await;
- }
- }
- Err(e) => {
- let tx_ = tx.clone();
- let _ = tx_.send(CommandEvent::Error(e.to_string())).await;
- break;
- }
- }
- }
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- use std::collections::HashMap;
- #[test]
- fn parse_shell_env_supports_null_delimited_pairs() {
- let env = parse_shell_env(b"PATH=/usr/bin:/bin\0FOO=bar=baz\0\0");
- assert_eq!(env.get("PATH"), Some(&"/usr/bin:/bin".to_string()));
- assert_eq!(env.get("FOO"), Some(&"bar=baz".to_string()));
- }
- #[test]
- fn parse_shell_env_ignores_invalid_entries() {
- let env = parse_shell_env(b"INVALID\0=empty\0OK=1\0");
- assert_eq!(env.len(), 1);
- assert_eq!(env.get("OK"), Some(&"1".to_string()));
- }
- #[test]
- fn merge_shell_env_keeps_explicit_overrides() {
- let mut shell_env = HashMap::new();
- shell_env.insert("PATH".to_string(), "/shell/path".to_string());
- shell_env.insert("HOME".to_string(), "/tmp/home".to_string());
- let merged = merge_shell_env(
- Some(shell_env),
- vec![
- ("PATH".to_string(), "/desktop/path".to_string()),
- ("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
- ],
- )
- .into_iter()
- .collect::<HashMap<_, _>>();
- assert_eq!(merged.get("PATH"), Some(&"/desktop/path".to_string()));
- assert_eq!(merged.get("HOME"), Some(&"/tmp/home".to_string()));
- assert_eq!(merged.get("OPENCODE_CLIENT"), Some(&"desktop".to_string()));
- }
- #[test]
- fn is_nushell_handles_path_and_binary_name() {
- assert!(is_nushell("nu"));
- assert!(is_nushell("/opt/homebrew/bin/nu"));
- assert!(is_nushell("C:\\Program Files\\nu.exe"));
- assert!(!is_nushell("/bin/zsh"));
- }
- }
|