lib.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. mod cli;
  2. mod window_customizer;
  3. use cli::{get_sidecar_path, install_cli, sync_cli};
  4. use futures::FutureExt;
  5. use std::{
  6. collections::VecDeque,
  7. net::{SocketAddr, TcpListener},
  8. sync::{Arc, Mutex},
  9. time::{Duration, Instant},
  10. };
  11. use tauri::{
  12. path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
  13. WebviewWindow,
  14. };
  15. use tauri_plugin_shell::process::{CommandChild, CommandEvent};
  16. use tauri_plugin_shell::ShellExt;
  17. use tokio::net::TcpSocket;
  18. use crate::window_customizer::PinchZoomDisablePlugin;
  19. #[derive(Clone)]
  20. struct ServerState {
  21. child: Arc<Mutex<Option<CommandChild>>>,
  22. status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
  23. }
  24. impl ServerState {
  25. pub fn new(
  26. child: Option<CommandChild>,
  27. status: tokio::sync::oneshot::Receiver<Result<(), String>>,
  28. ) -> Self {
  29. Self {
  30. child: Arc::new(Mutex::new(child)),
  31. status: status.shared(),
  32. }
  33. }
  34. pub fn set_child(&self, child: Option<CommandChild>) {
  35. *self.child.lock().unwrap() = child;
  36. }
  37. }
  38. #[derive(Clone)]
  39. struct LogState(Arc<Mutex<VecDeque<String>>>);
  40. const MAX_LOG_ENTRIES: usize = 200;
  41. #[tauri::command]
  42. fn kill_sidecar(app: AppHandle) {
  43. let Some(server_state) = app.try_state::<ServerState>() else {
  44. println!("Server not running");
  45. return;
  46. };
  47. let Some(server_state) = server_state
  48. .child
  49. .lock()
  50. .expect("Failed to acquire mutex lock")
  51. .take()
  52. else {
  53. println!("Server state missing");
  54. return;
  55. };
  56. let _ = server_state.kill();
  57. println!("Killed server");
  58. }
  59. async fn get_logs(app: AppHandle) -> Result<String, String> {
  60. let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
  61. let logs = log_state
  62. .0
  63. .lock()
  64. .map_err(|_| "Failed to acquire log lock")?;
  65. Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
  66. }
  67. #[tauri::command]
  68. async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
  69. state
  70. .status
  71. .clone()
  72. .await
  73. .map_err(|_| "Failed to get server status".to_string())?
  74. }
  75. fn get_sidecar_port() -> u32 {
  76. option_env!("OPENCODE_PORT")
  77. .map(|s| s.to_string())
  78. .or_else(|| std::env::var("OPENCODE_PORT").ok())
  79. .and_then(|port_str| port_str.parse().ok())
  80. .unwrap_or_else(|| {
  81. TcpListener::bind("127.0.0.1:0")
  82. .expect("Failed to bind to find free port")
  83. .local_addr()
  84. .expect("Failed to get local address")
  85. .port()
  86. }) as u32
  87. }
  88. fn get_user_shell() -> String {
  89. std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
  90. }
  91. fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
  92. let log_state = app.state::<LogState>();
  93. let log_state_clone = log_state.inner().clone();
  94. let state_dir = app
  95. .path()
  96. .resolve("", BaseDirectory::AppLocalData)
  97. .expect("Failed to resolve app local data dir");
  98. #[cfg(target_os = "windows")]
  99. let (mut rx, child) = app
  100. .shell()
  101. .sidecar("opencode-cli")
  102. .unwrap()
  103. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  104. .env("OPENCODE_CLIENT", "desktop")
  105. .env("XDG_STATE_HOME", &state_dir)
  106. .args(["serve", &format!("--port={port}")])
  107. .spawn()
  108. .expect("Failed to spawn opencode");
  109. #[cfg(not(target_os = "windows"))]
  110. let (mut rx, child) = {
  111. let sidecar = get_sidecar_path();
  112. let shell = get_user_shell();
  113. app.shell()
  114. .command(&shell)
  115. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  116. .env("OPENCODE_CLIENT", "desktop")
  117. .env("XDG_STATE_HOME", &state_dir)
  118. .args([
  119. "-il",
  120. "-c",
  121. &format!("\"{}\" serve --port={}", sidecar.display(), port),
  122. ])
  123. .spawn()
  124. .expect("Failed to spawn opencode")
  125. };
  126. tauri::async_runtime::spawn(async move {
  127. while let Some(event) = rx.recv().await {
  128. match event {
  129. CommandEvent::Stdout(line_bytes) => {
  130. let line = String::from_utf8_lossy(&line_bytes);
  131. print!("{line}");
  132. // Store log in shared state
  133. if let Ok(mut logs) = log_state_clone.0.lock() {
  134. logs.push_back(format!("[STDOUT] {}", line));
  135. // Keep only the last MAX_LOG_ENTRIES
  136. while logs.len() > MAX_LOG_ENTRIES {
  137. logs.pop_front();
  138. }
  139. }
  140. }
  141. CommandEvent::Stderr(line_bytes) => {
  142. let line = String::from_utf8_lossy(&line_bytes);
  143. eprint!("{line}");
  144. // Store log in shared state
  145. if let Ok(mut logs) = log_state_clone.0.lock() {
  146. logs.push_back(format!("[STDERR] {}", line));
  147. // Keep only the last MAX_LOG_ENTRIES
  148. while logs.len() > MAX_LOG_ENTRIES {
  149. logs.pop_front();
  150. }
  151. }
  152. }
  153. _ => {}
  154. }
  155. }
  156. });
  157. child
  158. }
  159. async fn is_server_running(port: u32) -> bool {
  160. TcpSocket::new_v4()
  161. .unwrap()
  162. .connect(SocketAddr::new(
  163. "127.0.0.1".parse().expect("Failed to parse IP"),
  164. port as u16,
  165. ))
  166. .await
  167. .is_ok()
  168. }
  169. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  170. pub fn run() {
  171. let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
  172. let mut builder = tauri::Builder::default()
  173. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
  174. // Focus existing window when another instance is launched
  175. if let Some(window) = app.get_webview_window("main") {
  176. let _ = window.set_focus();
  177. let _ = window.unminimize();
  178. }
  179. }))
  180. .plugin(tauri_plugin_os::init())
  181. .plugin(tauri_plugin_window_state::Builder::new().build())
  182. .plugin(tauri_plugin_store::Builder::new().build())
  183. .plugin(tauri_plugin_dialog::init())
  184. .plugin(tauri_plugin_shell::init())
  185. .plugin(tauri_plugin_process::init())
  186. .plugin(tauri_plugin_opener::init())
  187. .plugin(tauri_plugin_clipboard_manager::init())
  188. .plugin(tauri_plugin_http::init())
  189. .plugin(tauri_plugin_notification::init())
  190. .plugin(PinchZoomDisablePlugin)
  191. .invoke_handler(tauri::generate_handler![
  192. kill_sidecar,
  193. install_cli,
  194. ensure_server_started
  195. ])
  196. .setup(move |app| {
  197. let app = app.handle().clone();
  198. // Initialize log state
  199. app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
  200. // Get port and create window immediately for faster perceived startup
  201. let port = get_sidecar_port();
  202. let primary_monitor = app.primary_monitor().ok().flatten();
  203. let size = primary_monitor
  204. .map(|m| m.size().to_logical(m.scale_factor()))
  205. .unwrap_or(LogicalSize::new(1920, 1080));
  206. // Create window immediately with serverReady = false
  207. let mut window_builder =
  208. WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
  209. .title("OpenCode")
  210. .inner_size(size.width as f64, size.height as f64)
  211. .decorations(true)
  212. .zoom_hotkeys_enabled(true)
  213. .disable_drag_drop_handler()
  214. .initialization_script(format!(
  215. r#"
  216. window.__OPENCODE__ ??= {{}};
  217. window.__OPENCODE__.updaterEnabled = {updater_enabled};
  218. window.__OPENCODE__.port = {port};
  219. "#
  220. ));
  221. #[cfg(target_os = "macos")]
  222. {
  223. window_builder = window_builder
  224. .title_bar_style(tauri::TitleBarStyle::Overlay)
  225. .hidden_title(true);
  226. }
  227. let window = window_builder.build().expect("Failed to create window");
  228. let (tx, rx) = tokio::sync::oneshot::channel();
  229. app.manage(ServerState::new(None, rx));
  230. {
  231. let app = app.clone();
  232. tauri::async_runtime::spawn(async move {
  233. let should_spawn_sidecar = !is_server_running(port).await;
  234. let (child, res) = if should_spawn_sidecar {
  235. let child = spawn_sidecar(&app, port);
  236. let timestamp = Instant::now();
  237. let res = loop {
  238. if timestamp.elapsed() > Duration::from_secs(7) {
  239. break Err(format!(
  240. "Failed to spawn OpenCode Server. Logs:\n{}",
  241. get_logs(app.clone()).await.unwrap()
  242. ));
  243. }
  244. tokio::time::sleep(Duration::from_millis(10)).await;
  245. if is_server_running(port).await {
  246. // give the server a little bit more time to warm up
  247. tokio::time::sleep(Duration::from_millis(10)).await;
  248. break Ok(());
  249. }
  250. };
  251. println!("Server ready after {:?}", timestamp.elapsed());
  252. (Some(child), res)
  253. } else {
  254. (None, Ok(()))
  255. };
  256. app.state::<ServerState>().set_child(child);
  257. if res.is_ok() {
  258. let _ = window.eval("window.__OPENCODE__.serverReady = true;");
  259. }
  260. let _ = tx.send(res);
  261. });
  262. }
  263. {
  264. let app = app.clone();
  265. tauri::async_runtime::spawn(async move {
  266. if let Err(e) = sync_cli(app) {
  267. eprintln!("Failed to sync CLI: {e}");
  268. }
  269. });
  270. }
  271. Ok(())
  272. });
  273. if updater_enabled {
  274. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  275. }
  276. builder
  277. .build(tauri::generate_context!())
  278. .expect("error while running tauri application")
  279. .run(|app, event| {
  280. if let RunEvent::Exit = event {
  281. println!("Received Exit");
  282. kill_sidecar(app.clone());
  283. }
  284. });
  285. }