cli.rs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. use tauri::{AppHandle, Manager, path::BaseDirectory};
  2. use tauri_plugin_shell::{
  3. ShellExt,
  4. process::{Command, CommandChild, CommandEvent},
  5. };
  6. use crate::{LogState, constants::MAX_LOG_ENTRIES};
  7. const CLI_INSTALL_DIR: &str = ".opencode/bin";
  8. const CLI_BINARY_NAME: &str = "opencode";
  9. #[derive(serde::Deserialize)]
  10. pub struct ServerConfig {
  11. pub hostname: Option<String>,
  12. pub port: Option<u32>,
  13. }
  14. #[derive(serde::Deserialize)]
  15. pub struct Config {
  16. pub server: Option<ServerConfig>,
  17. }
  18. pub async fn get_config(app: &AppHandle) -> Option<Config> {
  19. create_command(app, "debug config")
  20. .output()
  21. .await
  22. .inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
  23. .ok()
  24. .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
  25. .and_then(|s| serde_json::from_str::<Config>(&s).ok())
  26. }
  27. fn get_cli_install_path() -> Option<std::path::PathBuf> {
  28. std::env::var("HOME").ok().map(|home| {
  29. std::path::PathBuf::from(home)
  30. .join(CLI_INSTALL_DIR)
  31. .join(CLI_BINARY_NAME)
  32. })
  33. }
  34. pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
  35. // Get binary with symlinks support
  36. tauri::process::current_binary(&app.env())
  37. .expect("Failed to get current binary")
  38. .parent()
  39. .expect("Failed to get parent dir")
  40. .join("opencode-cli")
  41. }
  42. fn is_cli_installed() -> bool {
  43. get_cli_install_path()
  44. .map(|path| path.exists())
  45. .unwrap_or(false)
  46. }
  47. const INSTALL_SCRIPT: &str = include_str!("../../../../install");
  48. #[tauri::command]
  49. #[specta::specta]
  50. pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
  51. if cfg!(not(unix)) {
  52. return Err("CLI installation is only supported on macOS & Linux".to_string());
  53. }
  54. let sidecar = get_sidecar_path(&app);
  55. if !sidecar.exists() {
  56. return Err("Sidecar binary not found".to_string());
  57. }
  58. let temp_script = std::env::temp_dir().join("opencode-install.sh");
  59. std::fs::write(&temp_script, INSTALL_SCRIPT)
  60. .map_err(|e| format!("Failed to write install script: {}", e))?;
  61. #[cfg(unix)]
  62. {
  63. use std::os::unix::fs::PermissionsExt;
  64. std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
  65. .map_err(|e| format!("Failed to set script permissions: {}", e))?;
  66. }
  67. let output = std::process::Command::new(&temp_script)
  68. .arg("--binary")
  69. .arg(&sidecar)
  70. .output()
  71. .map_err(|e| format!("Failed to run install script: {}", e))?;
  72. let _ = std::fs::remove_file(&temp_script);
  73. if !output.status.success() {
  74. let stderr = String::from_utf8_lossy(&output.stderr);
  75. return Err(format!("Install script failed: {}", stderr));
  76. }
  77. let install_path =
  78. get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
  79. Ok(install_path.to_string_lossy().to_string())
  80. }
  81. pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
  82. if cfg!(debug_assertions) {
  83. println!("Skipping CLI sync for debug build");
  84. return Ok(());
  85. }
  86. if !is_cli_installed() {
  87. println!("No CLI installation found, skipping sync");
  88. return Ok(());
  89. }
  90. let cli_path =
  91. get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
  92. let output = std::process::Command::new(&cli_path)
  93. .arg("--version")
  94. .output()
  95. .map_err(|e| format!("Failed to get CLI version: {}", e))?;
  96. if !output.status.success() {
  97. return Err("Failed to get CLI version".to_string());
  98. }
  99. let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
  100. let cli_version = semver::Version::parse(&cli_version_str)
  101. .map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
  102. let app_version = app.package_info().version.clone();
  103. if cli_version >= app_version {
  104. println!(
  105. "CLI version {} is up to date (app version: {}), skipping sync",
  106. cli_version, app_version
  107. );
  108. return Ok(());
  109. }
  110. println!(
  111. "CLI version {} is older than app version {}, syncing",
  112. cli_version, app_version
  113. );
  114. install_cli(app)?;
  115. println!("Synced installed CLI");
  116. Ok(())
  117. }
  118. fn get_user_shell() -> String {
  119. std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
  120. }
  121. pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
  122. let state_dir = app
  123. .path()
  124. .resolve("", BaseDirectory::AppLocalData)
  125. .expect("Failed to resolve app local data dir");
  126. #[cfg(target_os = "windows")]
  127. return app
  128. .shell()
  129. .sidecar("opencode-cli")
  130. .unwrap()
  131. .args(args.split_whitespace())
  132. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  133. .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
  134. .env("OPENCODE_CLIENT", "desktop")
  135. .env("XDG_STATE_HOME", &state_dir);
  136. #[cfg(not(target_os = "windows"))]
  137. return {
  138. let sidecar = get_sidecar_path(app);
  139. let shell = get_user_shell();
  140. let cmd = if shell.ends_with("/nu") {
  141. format!("^\"{}\" {}", sidecar.display(), args)
  142. } else {
  143. format!("\"{}\" {}", sidecar.display(), args)
  144. };
  145. app.shell()
  146. .command(&shell)
  147. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  148. .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
  149. .env("OPENCODE_CLIENT", "desktop")
  150. .env("XDG_STATE_HOME", &state_dir)
  151. .args(["-il", "-c", &cmd])
  152. };
  153. }
  154. pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild {
  155. let log_state = app.state::<LogState>();
  156. let log_state_clone = log_state.inner().clone();
  157. println!("spawning sidecar on port {port}");
  158. let (mut rx, child) = create_command(
  159. app,
  160. format!("serve --hostname {hostname} --port {port}").as_str(),
  161. )
  162. .env("OPENCODE_SERVER_USERNAME", "opencode")
  163. .env("OPENCODE_SERVER_PASSWORD", password)
  164. .spawn()
  165. .expect("Failed to spawn opencode");
  166. tokio::spawn(async move {
  167. while let Some(event) = rx.recv().await {
  168. match event {
  169. CommandEvent::Stdout(line_bytes) => {
  170. let line = String::from_utf8_lossy(&line_bytes);
  171. print!("{line}");
  172. // Store log in shared state
  173. if let Ok(mut logs) = log_state_clone.0.lock() {
  174. logs.push_back(format!("[STDOUT] {}", line));
  175. // Keep only the last MAX_LOG_ENTRIES
  176. while logs.len() > MAX_LOG_ENTRIES {
  177. logs.pop_front();
  178. }
  179. }
  180. }
  181. CommandEvent::Stderr(line_bytes) => {
  182. let line = String::from_utf8_lossy(&line_bytes);
  183. eprint!("{line}");
  184. // Store log in shared state
  185. if let Ok(mut logs) = log_state_clone.0.lock() {
  186. logs.push_back(format!("[STDERR] {}", line));
  187. // Keep only the last MAX_LOG_ENTRIES
  188. while logs.len() > MAX_LOG_ENTRIES {
  189. logs.pop_front();
  190. }
  191. }
  192. }
  193. _ => {}
  194. }
  195. }
  196. });
  197. child
  198. }