lib.rs 19 KB

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