lib.rs 16 KB

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