lib.rs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. mod window_customizer;
  2. use std::{
  3. collections::VecDeque,
  4. net::{SocketAddr, TcpListener},
  5. sync::{Arc, Mutex},
  6. time::{Duration, Instant},
  7. };
  8. use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
  9. use tauri_plugin_clipboard_manager::ClipboardExt;
  10. use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
  11. use tauri_plugin_shell::process::{CommandChild, CommandEvent};
  12. use tauri_plugin_shell::ShellExt;
  13. use tokio::net::TcpSocket;
  14. use crate::window_customizer::PinchZoomDisablePlugin;
  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. let state_dir = app
  80. .path()
  81. .resolve("", BaseDirectory::AppLocalData)
  82. .expect("Failed to resolve app local data dir");
  83. #[cfg(target_os = "windows")]
  84. let (mut rx, child) = app
  85. .shell()
  86. .sidecar("opencode-cli")
  87. .unwrap()
  88. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  89. .env("OPENCODE_CLIENT", "desktop")
  90. .env("XDG_STATE_HOME", &state_dir)
  91. .args(["serve", &format!("--port={port}")])
  92. .spawn()
  93. .expect("Failed to spawn opencode");
  94. #[cfg(not(target_os = "windows"))]
  95. let (mut rx, child) = {
  96. let sidecar_path = tauri::utils::platform::current_exe()
  97. .expect("Failed to get current exe")
  98. .parent()
  99. .expect("Failed to get parent dir")
  100. .join("opencode-cli");
  101. let shell = get_user_shell();
  102. app.shell()
  103. .command(&shell)
  104. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  105. .env("OPENCODE_CLIENT", "desktop")
  106. .env("XDG_STATE_HOME", &state_dir)
  107. .args([
  108. "-il",
  109. "-c",
  110. &format!("{} serve --port={}", sidecar_path.display(), port),
  111. ])
  112. .spawn()
  113. .expect("Failed to spawn opencode")
  114. };
  115. tauri::async_runtime::spawn(async move {
  116. while let Some(event) = rx.recv().await {
  117. match event {
  118. CommandEvent::Stdout(line_bytes) => {
  119. let line = String::from_utf8_lossy(&line_bytes);
  120. print!("{line}");
  121. // Store log in shared state
  122. if let Ok(mut logs) = log_state_clone.0.lock() {
  123. logs.push_back(format!("[STDOUT] {}", line));
  124. // Keep only the last MAX_LOG_ENTRIES
  125. while logs.len() > MAX_LOG_ENTRIES {
  126. logs.pop_front();
  127. }
  128. }
  129. }
  130. CommandEvent::Stderr(line_bytes) => {
  131. let line = String::from_utf8_lossy(&line_bytes);
  132. eprint!("{line}");
  133. // Store log in shared state
  134. if let Ok(mut logs) = log_state_clone.0.lock() {
  135. logs.push_back(format!("[STDERR] {}", line));
  136. // Keep only the last MAX_LOG_ENTRIES
  137. while logs.len() > MAX_LOG_ENTRIES {
  138. logs.pop_front();
  139. }
  140. }
  141. }
  142. _ => {}
  143. }
  144. }
  145. });
  146. child
  147. }
  148. async fn is_server_running(port: u32) -> bool {
  149. TcpSocket::new_v4()
  150. .unwrap()
  151. .connect(SocketAddr::new(
  152. "127.0.0.1".parse().expect("Failed to parse IP"),
  153. port as u16,
  154. ))
  155. .await
  156. .is_ok()
  157. }
  158. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  159. pub fn run() {
  160. let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
  161. let mut builder = tauri::Builder::default()
  162. .plugin(tauri_plugin_os::init())
  163. .plugin(tauri_plugin_window_state::Builder::new().build())
  164. .plugin(tauri_plugin_store::Builder::new().build())
  165. .plugin(tauri_plugin_dialog::init())
  166. .plugin(tauri_plugin_shell::init())
  167. .plugin(tauri_plugin_process::init())
  168. .plugin(tauri_plugin_opener::init())
  169. .plugin(tauri_plugin_clipboard_manager::init())
  170. .plugin(tauri_plugin_http::init())
  171. .plugin(PinchZoomDisablePlugin)
  172. .invoke_handler(tauri::generate_handler![
  173. kill_sidecar,
  174. copy_logs_to_clipboard,
  175. get_logs
  176. ])
  177. .setup(move |app| {
  178. let app = app.handle().clone();
  179. // Initialize log state
  180. app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
  181. tauri::async_runtime::spawn(async move {
  182. let port = get_sidecar_port();
  183. let should_spawn_sidecar = !is_server_running(port).await;
  184. let child = if should_spawn_sidecar {
  185. let child = spawn_sidecar(&app, port);
  186. let timestamp = Instant::now();
  187. loop {
  188. if timestamp.elapsed() > Duration::from_secs(7) {
  189. let res = app.dialog()
  190. .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
  191. .title("Startup Failed")
  192. .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
  193. .blocking_show_with_result();
  194. if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
  195. match copy_logs_to_clipboard(app.clone()).await {
  196. Ok(()) => println!("Logs copied to clipboard successfully"),
  197. Err(e) => println!("Failed to copy logs to clipboard: {}", e),
  198. }
  199. }
  200. app.exit(1);
  201. return;
  202. }
  203. tokio::time::sleep(Duration::from_millis(10)).await;
  204. if is_server_running(port).await {
  205. // give the server a little bit more time to warm up
  206. tokio::time::sleep(Duration::from_millis(10)).await;
  207. break;
  208. }
  209. }
  210. println!("Server ready after {:?}", timestamp.elapsed());
  211. Some(child)
  212. } else {
  213. None
  214. };
  215. let primary_monitor = app.primary_monitor().ok().flatten();
  216. let size = primary_monitor
  217. .map(|m| m.size().to_logical(m.scale_factor()))
  218. .unwrap_or(LogicalSize::new(1920, 1080));
  219. let mut window_builder =
  220. WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
  221. .title("OpenCode")
  222. .inner_size(size.width as f64, size.height as f64)
  223. .decorations(true)
  224. .zoom_hotkeys_enabled(true)
  225. .disable_drag_drop_handler()
  226. .initialization_script(format!(
  227. r#"
  228. window.__OPENCODE__ ??= {{}};
  229. window.__OPENCODE__.updaterEnabled = {updater_enabled};
  230. window.__OPENCODE__.port = {port};
  231. "#
  232. ));
  233. #[cfg(target_os = "macos")]
  234. {
  235. window_builder = window_builder
  236. .title_bar_style(tauri::TitleBarStyle::Overlay)
  237. .hidden_title(true);
  238. }
  239. window_builder.build().expect("Failed to create window");
  240. app.manage(ServerState(Arc::new(Mutex::new(child))));
  241. });
  242. Ok(())
  243. });
  244. if updater_enabled {
  245. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  246. }
  247. builder
  248. .build(tauri::generate_context!())
  249. .expect("error while running tauri application")
  250. .run(|app, event| {
  251. if let RunEvent::Exit = event {
  252. println!("Received Exit");
  253. kill_sidecar(app.clone());
  254. }
  255. });
  256. }