cli.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. use futures::{FutureExt, Stream, StreamExt, future};
  2. use process_wrap::tokio::CommandWrap;
  3. #[cfg(unix)]
  4. use process_wrap::tokio::ProcessGroup;
  5. #[cfg(windows)]
  6. use process_wrap::tokio::{JobObject, KillOnDrop};
  7. #[cfg(unix)]
  8. use std::os::unix::process::ExitStatusExt;
  9. use std::sync::Arc;
  10. use std::{process::Stdio, time::Duration};
  11. use tauri::{AppHandle, Manager, path::BaseDirectory};
  12. use tauri_plugin_store::StoreExt;
  13. use tauri_specta::Event;
  14. use tokio::{
  15. io::{AsyncBufRead, AsyncBufReadExt, BufReader},
  16. process::Command,
  17. sync::{mpsc, oneshot},
  18. task::JoinHandle,
  19. };
  20. use tokio_stream::wrappers::ReceiverStream;
  21. use tracing::Instrument;
  22. use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
  23. const CLI_INSTALL_DIR: &str = ".opencode/bin";
  24. const CLI_BINARY_NAME: &str = "opencode";
  25. #[derive(serde::Deserialize, Debug)]
  26. pub struct ServerConfig {
  27. pub hostname: Option<String>,
  28. pub port: Option<u32>,
  29. }
  30. #[derive(serde::Deserialize, Debug)]
  31. pub struct Config {
  32. pub server: Option<ServerConfig>,
  33. }
  34. #[derive(Clone, Debug)]
  35. pub enum CommandEvent {
  36. Stdout(String),
  37. Stderr(String),
  38. Error(String),
  39. Terminated(TerminatedPayload),
  40. }
  41. #[derive(Clone, Copy, Debug)]
  42. pub struct TerminatedPayload {
  43. pub code: Option<i32>,
  44. pub signal: Option<i32>,
  45. }
  46. #[derive(Clone, Debug)]
  47. pub struct CommandChild {
  48. kill: mpsc::Sender<()>,
  49. }
  50. impl CommandChild {
  51. pub fn kill(&self) -> std::io::Result<()> {
  52. self.kill
  53. .try_send(())
  54. .map_err(|e| std::io::Error::other(e.to_string()))
  55. }
  56. }
  57. pub async fn get_config(app: &AppHandle) -> Option<Config> {
  58. let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
  59. events
  60. .fold(String::new(), async |mut config_str, event| {
  61. if let CommandEvent::Stdout(s) = &event {
  62. config_str += s.as_str()
  63. }
  64. if let CommandEvent::Stderr(s) = &event {
  65. config_str += s.as_str()
  66. }
  67. config_str
  68. })
  69. .map(|v| serde_json::from_str::<Config>(&v))
  70. .await
  71. .ok()
  72. }
  73. fn get_cli_install_path() -> Option<std::path::PathBuf> {
  74. std::env::var("HOME").ok().map(|home| {
  75. std::path::PathBuf::from(home)
  76. .join(CLI_INSTALL_DIR)
  77. .join(CLI_BINARY_NAME)
  78. })
  79. }
  80. pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
  81. // Get binary with symlinks support
  82. tauri::process::current_binary(&app.env())
  83. .expect("Failed to get current binary")
  84. .parent()
  85. .expect("Failed to get parent dir")
  86. .join("opencode-cli")
  87. }
  88. fn is_cli_installed() -> bool {
  89. get_cli_install_path()
  90. .map(|path| path.exists())
  91. .unwrap_or(false)
  92. }
  93. const INSTALL_SCRIPT: &str = include_str!("../../../../install");
  94. #[tauri::command]
  95. #[specta::specta]
  96. pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
  97. if cfg!(not(unix)) {
  98. return Err("CLI installation is only supported on macOS & Linux".to_string());
  99. }
  100. let sidecar = get_sidecar_path(&app);
  101. if !sidecar.exists() {
  102. return Err("Sidecar binary not found".to_string());
  103. }
  104. let temp_script = std::env::temp_dir().join("opencode-install.sh");
  105. std::fs::write(&temp_script, INSTALL_SCRIPT)
  106. .map_err(|e| format!("Failed to write install script: {}", e))?;
  107. #[cfg(unix)]
  108. {
  109. use std::os::unix::fs::PermissionsExt;
  110. std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
  111. .map_err(|e| format!("Failed to set script permissions: {}", e))?;
  112. }
  113. let output = std::process::Command::new(&temp_script)
  114. .arg("--binary")
  115. .arg(&sidecar)
  116. .output()
  117. .map_err(|e| format!("Failed to run install script: {}", e))?;
  118. let _ = std::fs::remove_file(&temp_script);
  119. if !output.status.success() {
  120. let stderr = String::from_utf8_lossy(&output.stderr);
  121. return Err(format!("Install script failed: {}", stderr));
  122. }
  123. let install_path =
  124. get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
  125. Ok(install_path.to_string_lossy().to_string())
  126. }
  127. pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
  128. if cfg!(debug_assertions) {
  129. tracing::debug!("Skipping CLI sync for debug build");
  130. return Ok(());
  131. }
  132. if !is_cli_installed() {
  133. tracing::info!("No CLI installation found, skipping sync");
  134. return Ok(());
  135. }
  136. let cli_path =
  137. get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
  138. let output = std::process::Command::new(&cli_path)
  139. .arg("--version")
  140. .output()
  141. .map_err(|e| format!("Failed to get CLI version: {}", e))?;
  142. if !output.status.success() {
  143. return Err("Failed to get CLI version".to_string());
  144. }
  145. let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
  146. let cli_version = semver::Version::parse(&cli_version_str)
  147. .map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
  148. let app_version = app.package_info().version.clone();
  149. if cli_version >= app_version {
  150. tracing::info!(
  151. %cli_version, %app_version,
  152. "CLI is up to date, skipping sync"
  153. );
  154. return Ok(());
  155. }
  156. tracing::info!(
  157. %cli_version, %app_version,
  158. "CLI is older than app version, syncing"
  159. );
  160. install_cli(app)?;
  161. tracing::info!("Synced installed CLI");
  162. Ok(())
  163. }
  164. fn get_user_shell() -> String {
  165. std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
  166. }
  167. fn is_wsl_enabled(app: &tauri::AppHandle) -> bool {
  168. let Ok(store) = app.store(SETTINGS_STORE) else {
  169. return false;
  170. };
  171. store
  172. .get(WSL_ENABLED_KEY)
  173. .as_ref()
  174. .and_then(|value| value.as_bool())
  175. .unwrap_or(false)
  176. }
  177. fn shell_escape(input: &str) -> String {
  178. if input.is_empty() {
  179. return "''".to_string();
  180. }
  181. let mut escaped = String::from("'");
  182. escaped.push_str(&input.replace("'", "'\"'\"'"));
  183. escaped.push('\'');
  184. escaped
  185. }
  186. pub fn spawn_command(
  187. app: &tauri::AppHandle,
  188. args: &str,
  189. extra_env: &[(&str, String)],
  190. ) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), std::io::Error> {
  191. let state_dir = app
  192. .path()
  193. .resolve("", BaseDirectory::AppLocalData)
  194. .expect("Failed to resolve app local data dir");
  195. let mut envs = vec![
  196. (
  197. "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(),
  198. "true".to_string(),
  199. ),
  200. (
  201. "OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(),
  202. "true".to_string(),
  203. ),
  204. ("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
  205. (
  206. "XDG_STATE_HOME".to_string(),
  207. state_dir.to_string_lossy().to_string(),
  208. ),
  209. ];
  210. envs.extend(
  211. extra_env
  212. .iter()
  213. .map(|(key, value)| (key.to_string(), value.clone())),
  214. );
  215. let mut cmd = if cfg!(windows) {
  216. if is_wsl_enabled(app) {
  217. tracing::info!("WSL is enabled, spawning CLI server in WSL");
  218. let version = app.package_info().version.to_string();
  219. let mut script = vec![
  220. "set -e".to_string(),
  221. "BIN=\"$HOME/.opencode/bin/opencode\"".to_string(),
  222. "if [ ! -x \"$BIN\" ]; then".to_string(),
  223. format!(
  224. " curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
  225. shell_escape(&version)
  226. ),
  227. "fi".to_string(),
  228. ];
  229. let mut env_prefix = vec![
  230. "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(),
  231. "OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(),
  232. "OPENCODE_CLIENT=desktop".to_string(),
  233. "XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(),
  234. ];
  235. env_prefix.extend(
  236. envs.iter()
  237. .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
  238. .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER")
  239. .filter(|(key, _)| key != "OPENCODE_CLIENT")
  240. .filter(|(key, _)| key != "XDG_STATE_HOME")
  241. .map(|(key, value)| format!("{}={}", key, shell_escape(value))),
  242. );
  243. script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
  244. let mut cmd = Command::new("wsl");
  245. cmd.args(["-e", "bash", "-lc", &script.join("\n")]);
  246. cmd
  247. } else {
  248. let sidecar = get_sidecar_path(app);
  249. let mut cmd = Command::new(sidecar);
  250. cmd.args(args.split_whitespace());
  251. for (key, value) in envs {
  252. cmd.env(key, value);
  253. }
  254. cmd
  255. }
  256. } else {
  257. let sidecar = get_sidecar_path(app);
  258. let shell = get_user_shell();
  259. let line = if shell.ends_with("/nu") {
  260. format!("^\"{}\" {}", sidecar.display(), args)
  261. } else {
  262. format!("\"{}\" {}", sidecar.display(), args)
  263. };
  264. let mut cmd = Command::new(shell);
  265. cmd.args(["-l", "-c", &line]);
  266. for (key, value) in envs {
  267. cmd.env(key, value);
  268. }
  269. cmd
  270. };
  271. cmd.stdout(Stdio::piped());
  272. cmd.stderr(Stdio::piped());
  273. cmd.stdin(Stdio::null());
  274. #[cfg(windows)]
  275. cmd.creation_flags(0x0800_0000);
  276. let mut wrap = CommandWrap::from(cmd);
  277. #[cfg(unix)]
  278. {
  279. wrap.wrap(ProcessGroup::leader());
  280. }
  281. #[cfg(windows)]
  282. {
  283. wrap.wrap(JobObject).wrap(KillOnDrop);
  284. }
  285. let mut child = wrap.spawn()?;
  286. let guard = Arc::new(tokio::sync::RwLock::new(()));
  287. let (tx, rx) = mpsc::channel(256);
  288. let (kill_tx, mut kill_rx) = mpsc::channel(1);
  289. let stdout = spawn_pipe_reader(
  290. tx.clone(),
  291. guard.clone(),
  292. BufReader::new(child.stdout().take().unwrap()),
  293. CommandEvent::Stdout,
  294. );
  295. let stderr = spawn_pipe_reader(
  296. tx.clone(),
  297. guard.clone(),
  298. BufReader::new(child.stderr().take().unwrap()),
  299. CommandEvent::Stderr,
  300. );
  301. tokio::task::spawn(async move {
  302. let mut kill_open = true;
  303. let status = loop {
  304. match child.try_wait() {
  305. Ok(Some(status)) => break Ok(status),
  306. Ok(None) => {}
  307. Err(err) => break Err(err),
  308. }
  309. tokio::select! {
  310. msg = kill_rx.recv(), if kill_open => {
  311. if msg.is_some() {
  312. let _ = child.start_kill();
  313. }
  314. kill_open = false;
  315. }
  316. _ = tokio::time::sleep(Duration::from_millis(100)) => {}
  317. }
  318. };
  319. match status {
  320. Ok(status) => {
  321. let payload = TerminatedPayload {
  322. code: status.code(),
  323. signal: signal_from_status(status),
  324. };
  325. let _ = tx.send(CommandEvent::Terminated(payload)).await;
  326. }
  327. Err(err) => {
  328. let _ = tx.send(CommandEvent::Error(err.to_string())).await;
  329. }
  330. }
  331. stdout.abort();
  332. stderr.abort();
  333. });
  334. let event_stream = ReceiverStream::new(rx);
  335. let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream);
  336. Ok((event_stream, CommandChild { kill: kill_tx }))
  337. }
  338. fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
  339. #[cfg(unix)]
  340. return status.signal();
  341. #[cfg(not(unix))]
  342. {
  343. let _ = status;
  344. None
  345. }
  346. }
  347. pub fn serve(
  348. app: &AppHandle,
  349. hostname: &str,
  350. port: u32,
  351. password: &str,
  352. ) -> (CommandChild, oneshot::Receiver<TerminatedPayload>) {
  353. let (exit_tx, exit_rx) = oneshot::channel::<TerminatedPayload>();
  354. tracing::info!(port, "Spawning sidecar");
  355. let envs = [
  356. ("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
  357. ("OPENCODE_SERVER_PASSWORD", password.to_string()),
  358. ];
  359. let (events, child) = spawn_command(
  360. app,
  361. format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
  362. &envs,
  363. )
  364. .expect("Failed to spawn opencode");
  365. let mut exit_tx = Some(exit_tx);
  366. tokio::spawn(
  367. events
  368. .for_each(move |event| {
  369. match event {
  370. CommandEvent::Stdout(line) => {
  371. tracing::info!("{line}");
  372. }
  373. CommandEvent::Stderr(line) => {
  374. tracing::info!("{line}");
  375. }
  376. CommandEvent::Error(err) => {
  377. tracing::error!("{err}");
  378. }
  379. CommandEvent::Terminated(payload) => {
  380. tracing::info!(
  381. code = ?payload.code,
  382. signal = ?payload.signal,
  383. "Sidecar terminated"
  384. );
  385. if let Some(tx) = exit_tx.take() {
  386. let _ = tx.send(payload);
  387. }
  388. }
  389. }
  390. future::ready(())
  391. })
  392. .instrument(tracing::info_span!("sidecar")),
  393. );
  394. (child, exit_rx)
  395. }
  396. pub mod sqlite_migration {
  397. use super::*;
  398. #[derive(
  399. tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type,
  400. )]
  401. #[serde(tag = "type", content = "value")]
  402. pub enum SqliteMigrationProgress {
  403. InProgress(u8),
  404. Done,
  405. }
  406. pub(super) fn logs_middleware(
  407. app: AppHandle,
  408. stream: impl Stream<Item = CommandEvent>,
  409. ) -> impl Stream<Item = CommandEvent> {
  410. let app = app.clone();
  411. let mut done = false;
  412. stream.filter_map(move |event| {
  413. if done {
  414. return future::ready(Some(event));
  415. }
  416. future::ready(match &event {
  417. CommandEvent::Stdout(s) | CommandEvent::Stderr(s) => {
  418. if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
  419. if let Ok(progress) = s.parse::<u8>() {
  420. let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
  421. } else if s == "done" {
  422. done = true;
  423. let _ = SqliteMigrationProgress::Done.emit(&app);
  424. }
  425. None
  426. } else {
  427. Some(event)
  428. }
  429. }
  430. _ => Some(event),
  431. })
  432. })
  433. }
  434. }
  435. fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
  436. tx: mpsc::Sender<CommandEvent>,
  437. guard: Arc<tokio::sync::RwLock<()>>,
  438. pipe_reader: impl AsyncBufRead + Send + Unpin + 'static,
  439. wrapper: F,
  440. ) -> JoinHandle<()> {
  441. tokio::spawn(async move {
  442. let _lock = guard.read().await;
  443. let reader = BufReader::new(pipe_reader);
  444. read_line(reader, tx, wrapper).await;
  445. })
  446. }
  447. async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
  448. reader: BufReader<impl AsyncBufRead + Unpin>,
  449. tx: mpsc::Sender<CommandEvent>,
  450. wrapper: F,
  451. ) {
  452. let mut lines = reader.lines();
  453. loop {
  454. let line = lines.next_line().await;
  455. match line {
  456. Ok(s) => {
  457. if let Some(s) = s {
  458. let _ = tx.clone().send(wrapper(s)).await;
  459. }
  460. }
  461. Err(e) => {
  462. let tx_ = tx.clone();
  463. let _ = tx_.send(CommandEvent::Error(e.to_string())).await;
  464. break;
  465. }
  466. }
  467. }
  468. }