lib.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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
  283. // Some VPNs set a global/system proxy that WebView2 applies even for loopback
  284. // connections, which breaks the app's localhost sidecar server.
  285. // Note: when setting additional args, we must re-apply wry's default
  286. // `--disable-features=...` flags.
  287. .additional_browser_args(
  288. "--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection",
  289. )
  290. .decorations(false);
  291. let window = window_builder.build().expect("Failed to create window");
  292. #[cfg(windows)]
  293. let _ = window.create_overlay_titlebar();
  294. let (tx, rx) = oneshot::channel();
  295. app.manage(ServerState::new(None, rx));
  296. {
  297. let app = app.clone();
  298. tauri::async_runtime::spawn(async move {
  299. let mut custom_url = None;
  300. if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
  301. println!("Using desktop-specific custom URL: {url}");
  302. custom_url = Some(url);
  303. }
  304. if custom_url.is_none()
  305. && let Some(cli_config) = cli::get_config(&app).await
  306. && let Some(url) = get_server_url_from_config(&cli_config)
  307. {
  308. println!("Using custom server URL from config: {url}");
  309. custom_url = Some(url);
  310. }
  311. let res = match setup_server_connection(&app, custom_url).await {
  312. Ok((child, url)) => {
  313. #[cfg(windows)]
  314. if let Some(child) = &child {
  315. let job_state = app.state::<JobObjectState>();
  316. job_state.assign_pid(child.pid());
  317. }
  318. app.state::<ServerState>().set_child(child);
  319. Ok(url)
  320. }
  321. Err(e) => Err(e),
  322. };
  323. let _ = tx.send(res);
  324. });
  325. }
  326. {
  327. let app = app.clone();
  328. tauri::async_runtime::spawn(async move {
  329. if let Err(e) = sync_cli(app) {
  330. eprintln!("Failed to sync CLI: {e}");
  331. }
  332. });
  333. }
  334. Ok(())
  335. });
  336. if updater_enabled {
  337. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  338. }
  339. builder
  340. .build(tauri::generate_context!())
  341. .expect("error while running tauri application")
  342. .run(|app, event| {
  343. if let RunEvent::Exit = event {
  344. println!("Received Exit");
  345. kill_sidecar(app.clone());
  346. }
  347. });
  348. }
  349. /// Converts a bind address hostname to a valid URL hostname for connection.
  350. /// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
  351. /// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
  352. fn normalize_hostname_for_url(hostname: &str) -> String {
  353. // Wildcard bind addresses -> localhost equivalents
  354. if hostname == "0.0.0.0" {
  355. return "127.0.0.1".to_string();
  356. }
  357. if hostname == "::" {
  358. return "[::1]".to_string();
  359. }
  360. // IPv6 addresses need brackets in URLs
  361. if hostname.contains(':') && !hostname.starts_with('[') {
  362. return format!("[{}]", hostname);
  363. }
  364. hostname.to_string()
  365. }
  366. fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
  367. let server = config.server.as_ref()?;
  368. let port = server.port?;
  369. println!("server.port found in OC config: {port}");
  370. let hostname = server
  371. .hostname
  372. .as_ref()
  373. .map(|v| normalize_hostname_for_url(v))
  374. .unwrap_or_else(|| "127.0.0.1".to_string());
  375. Some(format!("http://{}:{}", hostname, port))
  376. }
  377. async fn setup_server_connection(
  378. app: &AppHandle,
  379. custom_url: Option<String>,
  380. ) -> Result<(Option<CommandChild>, ServerReadyData), String> {
  381. if let Some(url) = custom_url {
  382. loop {
  383. if check_server_health(&url, None).await {
  384. println!("Connected to custom server: {}", url);
  385. return Ok((
  386. None,
  387. ServerReadyData {
  388. url: url.clone(),
  389. password: None,
  390. },
  391. ));
  392. }
  393. const RETRY: &str = "Retry";
  394. let res = app.dialog()
  395. .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
  396. .title("Connection Failed")
  397. .buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
  398. .blocking_show_with_result();
  399. match res {
  400. MessageDialogResult::Custom(name) if name == RETRY => {
  401. continue;
  402. }
  403. _ => {
  404. break;
  405. }
  406. }
  407. }
  408. }
  409. let local_port = get_sidecar_port();
  410. let hostname = "127.0.0.1";
  411. let local_url = format!("http://{hostname}:{local_port}");
  412. if !check_server_health(&local_url, None).await {
  413. let password = uuid::Uuid::new_v4().to_string();
  414. match spawn_local_server(app, hostname, local_port, &password).await {
  415. Ok(child) => Ok((
  416. Some(child),
  417. ServerReadyData {
  418. url: local_url,
  419. password: Some(password),
  420. },
  421. )),
  422. Err(err) => Err(err),
  423. }
  424. } else {
  425. Ok((
  426. None,
  427. ServerReadyData {
  428. url: local_url,
  429. password: None,
  430. },
  431. ))
  432. }
  433. }
  434. async fn spawn_local_server(
  435. app: &AppHandle,
  436. hostname: &str,
  437. port: u32,
  438. password: &str,
  439. ) -> Result<CommandChild, String> {
  440. let child = spawn_sidecar(app, hostname, port, password);
  441. let url = format!("http://{hostname}:{port}");
  442. let timestamp = Instant::now();
  443. loop {
  444. if timestamp.elapsed() > Duration::from_secs(30) {
  445. break Err(format!(
  446. "Failed to spawn OpenCode Server. Logs:\n{}",
  447. get_logs(app.clone()).await.unwrap()
  448. ));
  449. }
  450. tokio::time::sleep(Duration::from_millis(10)).await;
  451. if check_server_health(&url, Some(password)).await {
  452. println!("Server ready after {:?}", timestamp.elapsed());
  453. break Ok(child);
  454. }
  455. }
  456. }