lib.rs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. use std::{
  2. collections::VecDeque,
  3. net::{SocketAddr, TcpListener},
  4. sync::{Arc, Mutex},
  5. time::{Duration, Instant},
  6. };
  7. #[cfg(target_os = "macos")]
  8. use tauri::TitleBarStyle;
  9. use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow};
  10. use tauri_plugin_clipboard_manager::ClipboardExt;
  11. use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
  12. use tauri_plugin_shell::process::{CommandChild, CommandEvent};
  13. use tauri_plugin_shell::ShellExt;
  14. use tokio::net::TcpSocket;
  15. #[derive(Clone)]
  16. struct ServerState(Arc<Mutex<Option<CommandChild>>>);
  17. #[derive(Clone)]
  18. struct LogState(Arc<Mutex<VecDeque<String>>>);
  19. const MAX_LOG_ENTRIES: usize = 200;
  20. #[tauri::command]
  21. fn kill_sidecar(app: AppHandle) {
  22. let Some(server_state) = app.try_state::<ServerState>() else {
  23. println!("Server not running");
  24. return;
  25. };
  26. let Some(server_state) = server_state
  27. .0
  28. .lock()
  29. .expect("Failed to acquire mutex lock")
  30. .take()
  31. else {
  32. println!("Server state missing");
  33. return;
  34. };
  35. let _ = server_state.kill();
  36. println!("Killed server");
  37. }
  38. #[tauri::command]
  39. async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> {
  40. let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
  41. let logs = log_state
  42. .0
  43. .lock()
  44. .map_err(|_| "Failed to acquire log lock")?;
  45. let log_text = logs.iter().cloned().collect::<Vec<_>>().join("");
  46. app.clipboard()
  47. .write_text(log_text)
  48. .map_err(|e| format!("Failed to copy to clipboard: {}", e))?;
  49. Ok(())
  50. }
  51. #[tauri::command]
  52. async fn get_logs(app: AppHandle) -> Result<String, String> {
  53. let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
  54. let logs = log_state
  55. .0
  56. .lock()
  57. .map_err(|_| "Failed to acquire log lock")?;
  58. Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
  59. }
  60. fn get_sidecar_port() -> u32 {
  61. option_env!("OPENCODE_PORT")
  62. .map(|s| s.to_string())
  63. .or_else(|| std::env::var("OPENCODE_PORT").ok())
  64. .and_then(|port_str| port_str.parse().ok())
  65. .unwrap_or_else(|| {
  66. TcpListener::bind("127.0.0.1:0")
  67. .expect("Failed to bind to find free port")
  68. .local_addr()
  69. .expect("Failed to get local address")
  70. .port()
  71. }) as u32
  72. }
  73. fn get_user_shell() -> String {
  74. std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
  75. }
  76. fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
  77. let log_state = app.state::<LogState>();
  78. let log_state_clone = log_state.inner().clone();
  79. #[cfg(target_os = "windows")]
  80. let (mut rx, child) = app
  81. .shell()
  82. .sidecar("opencode-cli")
  83. .unwrap()
  84. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  85. .env("OPENCODE_CLIENT", "desktop")
  86. .args(["serve", &format!("--port={port}")])
  87. .spawn()
  88. .expect("Failed to spawn opencode");
  89. #[cfg(not(target_os = "windows"))]
  90. let (mut rx, child) = {
  91. let sidecar_path = tauri::utils::platform::current_exe()
  92. .expect("Failed to get current exe")
  93. .parent()
  94. .expect("Failed to get parent dir")
  95. .join("opencode-cli");
  96. let shell = get_user_shell();
  97. app.shell()
  98. .command(&shell)
  99. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  100. .env("OPENCODE_CLIENT", "desktop")
  101. .args([
  102. "-il",
  103. "-c",
  104. &format!("{} serve --port={}", sidecar_path.display(), port),
  105. ])
  106. .spawn()
  107. .expect("Failed to spawn opencode")
  108. };
  109. tauri::async_runtime::spawn(async move {
  110. while let Some(event) = rx.recv().await {
  111. match event {
  112. CommandEvent::Stdout(line_bytes) => {
  113. let line = String::from_utf8_lossy(&line_bytes);
  114. print!("{line}");
  115. // Store log in shared state
  116. if let Ok(mut logs) = log_state_clone.0.lock() {
  117. logs.push_back(format!("[STDOUT] {}", line));
  118. // Keep only the last MAX_LOG_ENTRIES
  119. while logs.len() > MAX_LOG_ENTRIES {
  120. logs.pop_front();
  121. }
  122. }
  123. }
  124. CommandEvent::Stderr(line_bytes) => {
  125. let line = String::from_utf8_lossy(&line_bytes);
  126. eprint!("{line}");
  127. // Store log in shared state
  128. if let Ok(mut logs) = log_state_clone.0.lock() {
  129. logs.push_back(format!("[STDERR] {}", line));
  130. // Keep only the last MAX_LOG_ENTRIES
  131. while logs.len() > MAX_LOG_ENTRIES {
  132. logs.pop_front();
  133. }
  134. }
  135. }
  136. _ => {}
  137. }
  138. }
  139. });
  140. child
  141. }
  142. async fn is_server_running(port: u32) -> bool {
  143. TcpSocket::new_v4()
  144. .unwrap()
  145. .connect(SocketAddr::new(
  146. "127.0.0.1".parse().expect("Failed to parse IP"),
  147. port as u16,
  148. ))
  149. .await
  150. .is_ok()
  151. }
  152. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  153. pub fn run() {
  154. let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
  155. let mut builder = tauri::Builder::default()
  156. .plugin(tauri_plugin_os::init())
  157. .plugin(tauri_plugin_window_state::Builder::new().build())
  158. .plugin(tauri_plugin_store::Builder::new().build())
  159. .plugin(tauri_plugin_dialog::init())
  160. .plugin(tauri_plugin_shell::init())
  161. .plugin(tauri_plugin_process::init())
  162. .plugin(tauri_plugin_opener::init())
  163. .plugin(tauri_plugin_clipboard_manager::init())
  164. .invoke_handler(tauri::generate_handler![
  165. kill_sidecar,
  166. copy_logs_to_clipboard,
  167. get_logs
  168. ])
  169. .setup(move |app| {
  170. let app = app.handle().clone();
  171. // Initialize log state
  172. app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
  173. tauri::async_runtime::spawn(async move {
  174. let port = get_sidecar_port();
  175. let should_spawn_sidecar = !is_server_running(port).await;
  176. let child = if should_spawn_sidecar {
  177. let child = spawn_sidecar(&app, port);
  178. let timestamp = Instant::now();
  179. loop {
  180. if timestamp.elapsed() > Duration::from_secs(7) {
  181. let res = app.dialog()
  182. .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
  183. .title("Startup Failed")
  184. .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
  185. .blocking_show_with_result();
  186. if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
  187. match copy_logs_to_clipboard(app.clone()).await {
  188. Ok(()) => println!("Logs copied to clipboard successfully"),
  189. Err(e) => println!("Failed to copy logs to clipboard: {}", e),
  190. }
  191. }
  192. app.exit(1);
  193. return;
  194. }
  195. tokio::time::sleep(Duration::from_millis(10)).await;
  196. if is_server_running(port).await {
  197. // give the server a little bit more time to warm up
  198. tokio::time::sleep(Duration::from_millis(10)).await;
  199. break;
  200. }
  201. }
  202. println!("Server ready after {:?}", timestamp.elapsed());
  203. Some(child)
  204. } else {
  205. None
  206. };
  207. let primary_monitor = app.primary_monitor().ok().flatten();
  208. let size = primary_monitor
  209. .map(|m| m.size().to_logical(m.scale_factor()))
  210. .unwrap_or(LogicalSize::new(1920, 1080));
  211. let mut window_builder =
  212. WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
  213. .title("OpenCode")
  214. .inner_size(size.width as f64, size.height as f64)
  215. .decorations(true)
  216. .zoom_hotkeys_enabled(true)
  217. .disable_drag_drop_handler()
  218. .initialization_script(format!(
  219. r#"
  220. window.__OPENCODE__ ??= {{}};
  221. window.__OPENCODE__.updaterEnabled = {updater_enabled};
  222. window.__OPENCODE__.port = {port};
  223. "#
  224. ));
  225. #[cfg(target_os = "macos")]
  226. {
  227. window_builder = window_builder
  228. .title_bar_style(TitleBarStyle::Overlay)
  229. .hidden_title(true);
  230. }
  231. window_builder.build().expect("Failed to create window");
  232. app.manage(ServerState(Arc::new(Mutex::new(child))));
  233. });
  234. Ok(())
  235. });
  236. if updater_enabled {
  237. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  238. }
  239. builder
  240. .build(tauri::generate_context!())
  241. .expect("error while running tauri application")
  242. .run(|app, event| {
  243. if let RunEvent::Exit = event {
  244. println!("Received Exit");
  245. kill_sidecar(app.clone());
  246. }
  247. });
  248. }