lib.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. mod cli;
  2. #[cfg(windows)]
  3. mod job_object;
  4. mod window_customizer;
  5. use cli::{install_cli, sync_cli};
  6. use futures::FutureExt;
  7. use futures::future;
  8. #[cfg(windows)]
  9. use job_object::*;
  10. use std::{
  11. collections::VecDeque,
  12. net::TcpListener,
  13. sync::{Arc, Mutex},
  14. time::{Duration, Instant},
  15. };
  16. use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
  17. use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
  18. use tauri_plugin_shell::process::{CommandChild, CommandEvent};
  19. use tauri_plugin_store::StoreExt;
  20. use tokio::sync::oneshot;
  21. use crate::window_customizer::PinchZoomDisablePlugin;
  22. const SETTINGS_STORE: &str = "opencode.settings.dat";
  23. const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
  24. #[derive(Clone, serde::Serialize)]
  25. struct ServerReadyData {
  26. url: String,
  27. password: Option<String>,
  28. }
  29. #[derive(Clone)]
  30. struct ServerState {
  31. child: Arc<Mutex<Option<CommandChild>>>,
  32. status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
  33. }
  34. impl ServerState {
  35. pub fn new(
  36. child: Option<CommandChild>,
  37. status: oneshot::Receiver<Result<ServerReadyData, String>>,
  38. ) -> Self {
  39. Self {
  40. child: Arc::new(Mutex::new(child)),
  41. status: status.shared(),
  42. }
  43. }
  44. pub fn set_child(&self, child: Option<CommandChild>) {
  45. *self.child.lock().unwrap() = child;
  46. }
  47. }
  48. #[derive(Clone)]
  49. struct LogState(Arc<Mutex<VecDeque<String>>>);
  50. const MAX_LOG_ENTRIES: usize = 200;
  51. #[tauri::command]
  52. fn kill_sidecar(app: AppHandle) {
  53. let Some(server_state) = app.try_state::<ServerState>() else {
  54. println!("Server not running");
  55. return;
  56. };
  57. let Some(server_state) = server_state
  58. .child
  59. .lock()
  60. .expect("Failed to acquire mutex lock")
  61. .take()
  62. else {
  63. println!("Server state missing");
  64. return;
  65. };
  66. let _ = server_state.kill();
  67. println!("Killed server");
  68. }
  69. async fn get_logs(app: AppHandle) -> Result<String, String> {
  70. let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
  71. let logs = log_state
  72. .0
  73. .lock()
  74. .map_err(|_| "Failed to acquire log lock")?;
  75. Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
  76. }
  77. #[tauri::command]
  78. async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
  79. state
  80. .status
  81. .clone()
  82. .await
  83. .map_err(|_| "Failed to get server status".to_string())?
  84. }
  85. #[tauri::command]
  86. fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
  87. let store = app
  88. .store(SETTINGS_STORE)
  89. .map_err(|e| format!("Failed to open settings store: {}", e))?;
  90. let value = store.get(DEFAULT_SERVER_URL_KEY);
  91. match value {
  92. Some(v) => Ok(v.as_str().map(String::from)),
  93. None => Ok(None),
  94. }
  95. }
  96. #[tauri::command]
  97. async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
  98. let store = app
  99. .store(SETTINGS_STORE)
  100. .map_err(|e| format!("Failed to open settings store: {}", e))?;
  101. match url {
  102. Some(u) => {
  103. store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
  104. }
  105. None => {
  106. store.delete(DEFAULT_SERVER_URL_KEY);
  107. }
  108. }
  109. store
  110. .save()
  111. .map_err(|e| format!("Failed to save settings: {}", e))?;
  112. Ok(())
  113. }
  114. fn get_sidecar_port() -> u32 {
  115. option_env!("OPENCODE_PORT")
  116. .map(|s| s.to_string())
  117. .or_else(|| std::env::var("OPENCODE_PORT").ok())
  118. .and_then(|port_str| port_str.parse().ok())
  119. .unwrap_or_else(|| {
  120. TcpListener::bind("127.0.0.1:0")
  121. .expect("Failed to bind to find free port")
  122. .local_addr()
  123. .expect("Failed to get local address")
  124. .port()
  125. }) as u32
  126. }
  127. fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
  128. let log_state = app.state::<LogState>();
  129. let log_state_clone = log_state.inner().clone();
  130. println!("spawning sidecar on port {port}");
  131. let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
  132. .env("OPENCODE_SERVER_PASSWORD", password)
  133. .spawn()
  134. .expect("Failed to spawn opencode");
  135. tauri::async_runtime::spawn(async move {
  136. while let Some(event) = rx.recv().await {
  137. match event {
  138. CommandEvent::Stdout(line_bytes) => {
  139. let line = String::from_utf8_lossy(&line_bytes);
  140. print!("{line}");
  141. // Store log in shared state
  142. if let Ok(mut logs) = log_state_clone.0.lock() {
  143. logs.push_back(format!("[STDOUT] {}", line));
  144. // Keep only the last MAX_LOG_ENTRIES
  145. while logs.len() > MAX_LOG_ENTRIES {
  146. logs.pop_front();
  147. }
  148. }
  149. }
  150. CommandEvent::Stderr(line_bytes) => {
  151. let line = String::from_utf8_lossy(&line_bytes);
  152. eprint!("{line}");
  153. // Store log in shared state
  154. if let Ok(mut logs) = log_state_clone.0.lock() {
  155. logs.push_back(format!("[STDERR] {}", line));
  156. // Keep only the last MAX_LOG_ENTRIES
  157. while logs.len() > MAX_LOG_ENTRIES {
  158. logs.pop_front();
  159. }
  160. }
  161. }
  162. _ => {}
  163. }
  164. }
  165. });
  166. child
  167. }
  168. async fn check_server_health(url: &str, password: Option<&str>) -> bool {
  169. let health_url = format!("{}/global/health", url.trim_end_matches('/'));
  170. let client = reqwest::Client::builder()
  171. .timeout(Duration::from_secs(3))
  172. .build();
  173. let Ok(client) = client else {
  174. return false;
  175. };
  176. let mut req = client.get(&health_url);
  177. if let Some(password) = password {
  178. req = req.basic_auth("opencode", Some(password));
  179. }
  180. req.send()
  181. .await
  182. .map(|r| r.status().is_success())
  183. .unwrap_or(false)
  184. }
  185. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  186. pub fn run() {
  187. let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
  188. #[cfg(all(target_os = "macos", not(debug_assertions)))]
  189. let _ = std::process::Command::new("killall")
  190. .arg("opencode-cli")
  191. .output();
  192. let mut builder = tauri::Builder::default()
  193. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
  194. // Focus existing window when another instance is launched
  195. if let Some(window) = app.get_webview_window("main") {
  196. let _ = window.set_focus();
  197. let _ = window.unminimize();
  198. }
  199. }))
  200. .plugin(tauri_plugin_os::init())
  201. .plugin(
  202. tauri_plugin_window_state::Builder::new()
  203. .with_state_flags(
  204. tauri_plugin_window_state::StateFlags::all()
  205. - tauri_plugin_window_state::StateFlags::DECORATIONS,
  206. )
  207. .build(),
  208. )
  209. .plugin(tauri_plugin_store::Builder::new().build())
  210. .plugin(tauri_plugin_dialog::init())
  211. .plugin(tauri_plugin_shell::init())
  212. .plugin(tauri_plugin_process::init())
  213. .plugin(tauri_plugin_opener::init())
  214. .plugin(tauri_plugin_clipboard_manager::init())
  215. .plugin(tauri_plugin_http::init())
  216. .plugin(tauri_plugin_notification::init())
  217. .plugin(PinchZoomDisablePlugin)
  218. .invoke_handler(tauri::generate_handler![
  219. kill_sidecar,
  220. install_cli,
  221. ensure_server_ready,
  222. get_default_server_url,
  223. set_default_server_url
  224. ])
  225. .setup(move |app| {
  226. let app = app.handle().clone();
  227. // Initialize log state
  228. app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
  229. #[cfg(windows)]
  230. app.manage(JobObjectState::new());
  231. let primary_monitor = app.primary_monitor().ok().flatten();
  232. let size = primary_monitor
  233. .map(|m| m.size().to_logical(m.scale_factor()))
  234. .unwrap_or(LogicalSize::new(1920, 1080));
  235. let config = app
  236. .config()
  237. .app
  238. .windows
  239. .iter()
  240. .find(|w| w.label == "main")
  241. .expect("main window config missing");
  242. let window_builder = WebviewWindowBuilder::from_config(&app, config)
  243. .expect("Failed to create window builder from config")
  244. .inner_size(size.width as f64, size.height as f64)
  245. .initialization_script(format!(
  246. r#"
  247. window.__OPENCODE__ ??= {{}};
  248. window.__OPENCODE__.updaterEnabled = {updater_enabled};
  249. "#
  250. ));
  251. #[cfg(target_os = "macos")]
  252. let window_builder = window_builder
  253. .title_bar_style(tauri::TitleBarStyle::Overlay)
  254. .hidden_title(true);
  255. let _window = window_builder.build().expect("Failed to create window");
  256. let (tx, rx) = oneshot::channel();
  257. app.manage(ServerState::new(None, rx));
  258. {
  259. let app = app.clone();
  260. tauri::async_runtime::spawn(async move {
  261. let mut custom_url = None;
  262. if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
  263. println!("Using desktop-specific custom URL: {url}");
  264. custom_url = Some(url);
  265. }
  266. if custom_url.is_none()
  267. && let Some(cli_config) = cli::get_config(&app).await
  268. && let Some(url) = get_server_url_from_config(&cli_config)
  269. {
  270. println!("Using custom server URL from config: {url}");
  271. custom_url = Some(url);
  272. }
  273. let res = match setup_server_connection(&app, custom_url).await {
  274. Ok((child, url)) => {
  275. #[cfg(windows)]
  276. if let Some(child) = &child {
  277. let job_state = app.state::<JobObjectState>();
  278. job_state.assign_pid(child.pid());
  279. }
  280. app.state::<ServerState>().set_child(child);
  281. Ok(url)
  282. }
  283. Err(e) => Err(e),
  284. };
  285. let _ = tx.send(res);
  286. });
  287. }
  288. {
  289. let app = app.clone();
  290. tauri::async_runtime::spawn(async move {
  291. if let Err(e) = sync_cli(app) {
  292. eprintln!("Failed to sync CLI: {e}");
  293. }
  294. });
  295. }
  296. Ok(())
  297. });
  298. if updater_enabled {
  299. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  300. }
  301. builder
  302. .build(tauri::generate_context!())
  303. .expect("error while running tauri application")
  304. .run(|app, event| {
  305. if let RunEvent::Exit = event {
  306. println!("Received Exit");
  307. kill_sidecar(app.clone());
  308. }
  309. });
  310. }
  311. fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
  312. let server = config.server.as_ref()?;
  313. let port = server.port?;
  314. println!("server.port found in OC config: {port}");
  315. let hostname = server.hostname.as_ref();
  316. Some(format!(
  317. "http://{}:{}",
  318. hostname.map(|v| v.as_str()).unwrap_or("127.0.0.1"),
  319. port
  320. ))
  321. }
  322. async fn setup_server_connection(
  323. app: &AppHandle,
  324. custom_url: Option<String>,
  325. ) -> Result<(Option<CommandChild>, ServerReadyData), String> {
  326. if let Some(url) = custom_url {
  327. loop {
  328. if check_server_health(&url, None).await {
  329. println!("Connected to custom server: {}", url);
  330. return Ok((
  331. None,
  332. ServerReadyData {
  333. url: url.clone(),
  334. password: None,
  335. },
  336. ));
  337. }
  338. const RETRY: &str = "Retry";
  339. let res = app.dialog()
  340. .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
  341. .title("Connection Failed")
  342. .buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
  343. .blocking_show_with_result();
  344. match res {
  345. MessageDialogResult::Custom(name) if name == RETRY => {
  346. continue;
  347. }
  348. _ => {
  349. break;
  350. }
  351. }
  352. }
  353. }
  354. let local_port = get_sidecar_port();
  355. let local_url = format!("http://127.0.0.1:{local_port}");
  356. if !check_server_health(&local_url, None).await {
  357. let password = uuid::Uuid::new_v4().to_string();
  358. match spawn_local_server(app, local_port, &password).await {
  359. Ok(child) => Ok((
  360. Some(child),
  361. ServerReadyData {
  362. url: local_url,
  363. password: Some(password),
  364. },
  365. )),
  366. Err(err) => Err(err),
  367. }
  368. } else {
  369. Ok((
  370. None,
  371. ServerReadyData {
  372. url: local_url,
  373. password: None,
  374. },
  375. ))
  376. }
  377. }
  378. async fn spawn_local_server(
  379. app: &AppHandle,
  380. port: u32,
  381. password: &str,
  382. ) -> Result<CommandChild, String> {
  383. let child = spawn_sidecar(app, port, password);
  384. let url = format!("http://127.0.0.1:{port}");
  385. let timestamp = Instant::now();
  386. loop {
  387. if timestamp.elapsed() > Duration::from_secs(30) {
  388. break Err(format!(
  389. "Failed to spawn OpenCode Server. Logs:\n{}",
  390. get_logs(app.clone()).await.unwrap()
  391. ));
  392. }
  393. tokio::time::sleep(Duration::from_millis(10)).await;
  394. if check_server_health(&url, Some(password)).await {
  395. println!("Server ready after {:?}", timestamp.elapsed());
  396. break Ok(child);
  397. }
  398. }
  399. }