lib.rs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. mod cli;
  2. mod constants;
  3. #[cfg(target_os = "linux")]
  4. pub mod linux_display;
  5. #[cfg(target_os = "linux")]
  6. pub mod linux_windowing;
  7. mod logging;
  8. mod markdown;
  9. mod server;
  10. mod window_customizer;
  11. mod windows;
  12. use crate::cli::CommandChild;
  13. use futures::{
  14. FutureExt, TryFutureExt,
  15. future::{self, Shared},
  16. };
  17. use std::{
  18. env,
  19. net::TcpListener,
  20. path::PathBuf,
  21. process::Command,
  22. sync::{Arc, Mutex},
  23. time::Duration,
  24. };
  25. use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel};
  26. #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
  27. use tauri_plugin_deep_link::DeepLinkExt;
  28. use tauri_specta::Event;
  29. use tokio::{
  30. sync::{oneshot, watch},
  31. time::{sleep, timeout},
  32. };
  33. use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
  34. use crate::constants::*;
  35. use crate::server::get_saved_server_url;
  36. use crate::windows::{LoadingWindow, MainWindow};
  37. #[derive(Clone, serde::Serialize, specta::Type, Debug)]
  38. struct ServerReadyData {
  39. url: String,
  40. password: Option<String>,
  41. }
  42. #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
  43. #[serde(tag = "phase", rename_all = "snake_case")]
  44. enum InitStep {
  45. ServerWaiting,
  46. SqliteWaiting,
  47. Done,
  48. }
  49. #[derive(serde::Deserialize, specta::Type)]
  50. #[serde(rename_all = "snake_case")]
  51. enum WslPathMode {
  52. Windows,
  53. Linux,
  54. }
  55. struct InitState {
  56. current: watch::Receiver<InitStep>,
  57. }
  58. #[derive(Clone)]
  59. struct ServerState {
  60. child: Arc<Mutex<Option<CommandChild>>>,
  61. status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
  62. }
  63. impl ServerState {
  64. pub fn new(
  65. child: Option<CommandChild>,
  66. status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
  67. ) -> Self {
  68. Self {
  69. child: Arc::new(Mutex::new(child)),
  70. status,
  71. }
  72. }
  73. pub fn set_child(&self, child: Option<CommandChild>) {
  74. *self.child.lock().unwrap() = child;
  75. }
  76. }
  77. #[tauri::command]
  78. #[specta::specta]
  79. fn kill_sidecar(app: AppHandle) {
  80. let Some(server_state) = app.try_state::<ServerState>() else {
  81. tracing::info!("Server not running");
  82. return;
  83. };
  84. let Some(server_state) = server_state
  85. .child
  86. .lock()
  87. .expect("Failed to acquire mutex lock")
  88. .take()
  89. else {
  90. tracing::info!("Server state missing");
  91. return;
  92. };
  93. let _ = server_state.kill();
  94. tracing::info!("Killed server");
  95. }
  96. fn get_logs() -> String {
  97. logging::tail()
  98. }
  99. #[tauri::command]
  100. #[specta::specta]
  101. async fn await_initialization(
  102. state: State<'_, ServerState>,
  103. init_state: State<'_, InitState>,
  104. events: Channel<InitStep>,
  105. ) -> Result<ServerReadyData, String> {
  106. let mut rx = init_state.current.clone();
  107. let events = async {
  108. let e = *rx.borrow();
  109. let _ = events.send(e);
  110. while rx.changed().await.is_ok() {
  111. let step = *rx.borrow_and_update();
  112. let _ = events.send(step);
  113. if matches!(step, InitStep::Done) {
  114. break;
  115. }
  116. }
  117. };
  118. future::join(state.status.clone(), events)
  119. .await
  120. .0
  121. .map_err(|_| "Failed to get server status".to_string())?
  122. }
  123. #[tauri::command]
  124. #[specta::specta]
  125. fn check_app_exists(app_name: &str) -> bool {
  126. #[cfg(target_os = "windows")]
  127. {
  128. check_windows_app(app_name)
  129. }
  130. #[cfg(target_os = "macos")]
  131. {
  132. check_macos_app(app_name)
  133. }
  134. #[cfg(target_os = "linux")]
  135. {
  136. check_linux_app(app_name)
  137. }
  138. }
  139. #[cfg(target_os = "windows")]
  140. fn check_windows_app(_app_name: &str) -> bool {
  141. // Check if command exists in PATH, including .exe
  142. return true;
  143. }
  144. #[cfg(target_os = "windows")]
  145. fn resolve_windows_app_path(app_name: &str) -> Option<String> {
  146. use std::path::{Path, PathBuf};
  147. // Try to find the command using 'where'
  148. let output = Command::new("where").arg(app_name).output().ok()?;
  149. if !output.status.success() {
  150. return None;
  151. }
  152. let paths = String::from_utf8_lossy(&output.stdout)
  153. .lines()
  154. .map(str::trim)
  155. .filter(|line| !line.is_empty())
  156. .map(PathBuf::from)
  157. .collect::<Vec<_>>();
  158. let has_ext = |path: &Path, ext: &str| {
  159. path.extension()
  160. .and_then(|v| v.to_str())
  161. .map(|v| v.eq_ignore_ascii_case(ext))
  162. .unwrap_or(false)
  163. };
  164. if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
  165. return Some(path.to_string_lossy().to_string());
  166. }
  167. let resolve_cmd = |path: &Path| -> Option<String> {
  168. let content = std::fs::read_to_string(path).ok()?;
  169. for token in content.split('"') {
  170. let lower = token.to_ascii_lowercase();
  171. if !lower.contains(".exe") {
  172. continue;
  173. }
  174. if let Some(index) = lower.find("%~dp0") {
  175. let base = path.parent()?;
  176. let suffix = &token[index + 5..];
  177. let mut resolved = PathBuf::from(base);
  178. for part in suffix.replace('/', "\\").split('\\') {
  179. if part.is_empty() || part == "." {
  180. continue;
  181. }
  182. if part == ".." {
  183. let _ = resolved.pop();
  184. continue;
  185. }
  186. resolved.push(part);
  187. }
  188. if resolved.exists() {
  189. return Some(resolved.to_string_lossy().to_string());
  190. }
  191. }
  192. let resolved = PathBuf::from(token);
  193. if resolved.exists() {
  194. return Some(resolved.to_string_lossy().to_string());
  195. }
  196. }
  197. None
  198. };
  199. for path in &paths {
  200. if has_ext(path, "cmd") || has_ext(path, "bat") {
  201. if let Some(resolved) = resolve_cmd(path) {
  202. return Some(resolved);
  203. }
  204. }
  205. if path.extension().is_none() {
  206. let cmd = path.with_extension("cmd");
  207. if cmd.exists() {
  208. if let Some(resolved) = resolve_cmd(&cmd) {
  209. return Some(resolved);
  210. }
  211. }
  212. let bat = path.with_extension("bat");
  213. if bat.exists() {
  214. if let Some(resolved) = resolve_cmd(&bat) {
  215. return Some(resolved);
  216. }
  217. }
  218. }
  219. }
  220. let key = app_name
  221. .chars()
  222. .filter(|v| v.is_ascii_alphanumeric())
  223. .flat_map(|v| v.to_lowercase())
  224. .collect::<String>();
  225. if !key.is_empty() {
  226. for path in &paths {
  227. let dirs = [
  228. path.parent(),
  229. path.parent().and_then(|dir| dir.parent()),
  230. path.parent()
  231. .and_then(|dir| dir.parent())
  232. .and_then(|dir| dir.parent()),
  233. ];
  234. for dir in dirs.into_iter().flatten() {
  235. if let Ok(entries) = std::fs::read_dir(dir) {
  236. for entry in entries.flatten() {
  237. let candidate = entry.path();
  238. if !has_ext(&candidate, "exe") {
  239. continue;
  240. }
  241. let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
  242. continue;
  243. };
  244. let name = stem
  245. .chars()
  246. .filter(|v| v.is_ascii_alphanumeric())
  247. .flat_map(|v| v.to_lowercase())
  248. .collect::<String>();
  249. if name.contains(&key) || key.contains(&name) {
  250. return Some(candidate.to_string_lossy().to_string());
  251. }
  252. }
  253. }
  254. }
  255. }
  256. }
  257. paths.first().map(|path| path.to_string_lossy().to_string())
  258. }
  259. #[tauri::command]
  260. #[specta::specta]
  261. fn resolve_app_path(app_name: &str) -> Option<String> {
  262. #[cfg(target_os = "windows")]
  263. {
  264. resolve_windows_app_path(app_name)
  265. }
  266. #[cfg(not(target_os = "windows"))]
  267. {
  268. // On macOS/Linux, just return the app_name as-is since
  269. // the opener plugin handles them correctly
  270. Some(app_name.to_string())
  271. }
  272. }
  273. #[cfg(target_os = "macos")]
  274. fn check_macos_app(app_name: &str) -> bool {
  275. // Check common installation locations
  276. let mut app_locations = vec![
  277. format!("/Applications/{}.app", app_name),
  278. format!("/System/Applications/{}.app", app_name),
  279. ];
  280. if let Ok(home) = std::env::var("HOME") {
  281. app_locations.push(format!("{}/Applications/{}.app", home, app_name));
  282. }
  283. for location in app_locations {
  284. if std::path::Path::new(&location).exists() {
  285. return true;
  286. }
  287. }
  288. // Also check if command exists in PATH
  289. Command::new("which")
  290. .arg(app_name)
  291. .output()
  292. .map(|output| output.status.success())
  293. .unwrap_or(false)
  294. }
  295. #[derive(serde::Serialize, serde::Deserialize, specta::Type)]
  296. #[serde(rename_all = "camelCase")]
  297. pub enum LinuxDisplayBackend {
  298. Wayland,
  299. Auto,
  300. }
  301. #[tauri::command]
  302. #[specta::specta]
  303. fn get_display_backend() -> Option<LinuxDisplayBackend> {
  304. #[cfg(target_os = "linux")]
  305. {
  306. let prefer = linux_display::read_wayland().unwrap_or(false);
  307. return Some(if prefer {
  308. LinuxDisplayBackend::Wayland
  309. } else {
  310. LinuxDisplayBackend::Auto
  311. });
  312. }
  313. #[cfg(not(target_os = "linux"))]
  314. None
  315. }
  316. #[tauri::command]
  317. #[specta::specta]
  318. fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> {
  319. #[cfg(target_os = "linux")]
  320. {
  321. let prefer = matches!(_backend, LinuxDisplayBackend::Wayland);
  322. return linux_display::write_wayland(&_app, prefer);
  323. }
  324. #[cfg(not(target_os = "linux"))]
  325. Ok(())
  326. }
  327. #[cfg(target_os = "linux")]
  328. fn check_linux_app(app_name: &str) -> bool {
  329. return true;
  330. }
  331. #[tauri::command]
  332. #[specta::specta]
  333. fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> {
  334. if !cfg!(windows) {
  335. return Ok(path);
  336. }
  337. let flag = match mode.unwrap_or(WslPathMode::Linux) {
  338. WslPathMode::Windows => "-w",
  339. WslPathMode::Linux => "-u",
  340. };
  341. let output = if path.starts_with('~') {
  342. let suffix = path.strip_prefix('~').unwrap_or("");
  343. let escaped = suffix.replace('"', "\\\"");
  344. let cmd = format!("wslpath {flag} \"$HOME{escaped}\"");
  345. Command::new("wsl")
  346. .args(["-e", "sh", "-lc", &cmd])
  347. .output()
  348. .map_err(|e| format!("Failed to run wslpath: {e}"))?
  349. } else {
  350. Command::new("wsl")
  351. .args(["-e", "wslpath", flag, &path])
  352. .output()
  353. .map_err(|e| format!("Failed to run wslpath: {e}"))?
  354. };
  355. if !output.status.success() {
  356. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  357. if stderr.is_empty() {
  358. return Err("wslpath failed".to_string());
  359. }
  360. return Err(stderr);
  361. }
  362. Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
  363. }
  364. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  365. pub fn run() {
  366. let builder = make_specta_builder();
  367. #[cfg(debug_assertions)] // <- Only export on non-release builds
  368. export_types(&builder);
  369. #[cfg(all(target_os = "macos", not(debug_assertions)))]
  370. let _ = std::process::Command::new("killall")
  371. .arg("opencode-cli")
  372. .output();
  373. let mut builder = tauri::Builder::default()
  374. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
  375. // Focus existing window when another instance is launched
  376. if let Some(window) = app.get_webview_window(MainWindow::LABEL) {
  377. let _ = window.set_focus();
  378. let _ = window.unminimize();
  379. }
  380. }))
  381. .plugin(tauri_plugin_deep_link::init())
  382. .plugin(tauri_plugin_os::init())
  383. .plugin(
  384. tauri_plugin_window_state::Builder::new()
  385. .with_state_flags(window_state_flags())
  386. .with_denylist(&[LoadingWindow::LABEL])
  387. .build(),
  388. )
  389. .plugin(tauri_plugin_store::Builder::new().build())
  390. .plugin(tauri_plugin_dialog::init())
  391. .plugin(tauri_plugin_shell::init())
  392. .plugin(tauri_plugin_process::init())
  393. .plugin(tauri_plugin_opener::init())
  394. .plugin(tauri_plugin_clipboard_manager::init())
  395. .plugin(tauri_plugin_http::init())
  396. .plugin(tauri_plugin_notification::init())
  397. .plugin(crate::window_customizer::PinchZoomDisablePlugin)
  398. .plugin(tauri_plugin_decorum::init())
  399. .invoke_handler(builder.invoke_handler())
  400. .setup(move |app| {
  401. let handle = app.handle().clone();
  402. let log_dir = app
  403. .path()
  404. .app_log_dir()
  405. .expect("failed to resolve app log dir");
  406. // Hold the guard in managed state so it lives for the app's lifetime,
  407. // ensuring all buffered logs are flushed on shutdown.
  408. handle.manage(logging::init(&log_dir));
  409. builder.mount_events(&handle);
  410. tauri::async_runtime::spawn(initialize(handle));
  411. Ok(())
  412. });
  413. if UPDATER_ENABLED {
  414. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  415. }
  416. builder
  417. .build(tauri::generate_context!())
  418. .expect("error while running tauri application")
  419. .run(|app, event| {
  420. if let RunEvent::Exit = event {
  421. tracing::info!("Received Exit");
  422. kill_sidecar(app.clone());
  423. }
  424. });
  425. }
  426. fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
  427. tauri_specta::Builder::<tauri::Wry>::new()
  428. // Then register them (separated by a comma)
  429. .commands(tauri_specta::collect_commands![
  430. kill_sidecar,
  431. cli::install_cli,
  432. await_initialization,
  433. server::get_default_server_url,
  434. server::set_default_server_url,
  435. server::get_wsl_config,
  436. server::set_wsl_config,
  437. get_display_backend,
  438. set_display_backend,
  439. markdown::parse_markdown_command,
  440. check_app_exists,
  441. wsl_path,
  442. resolve_app_path
  443. ])
  444. .events(tauri_specta::collect_events![
  445. LoadingWindowComplete,
  446. SqliteMigrationProgress
  447. ])
  448. .error_handling(tauri_specta::ErrorHandlingMode::Throw)
  449. }
  450. fn export_types(builder: &tauri_specta::Builder<tauri::Wry>) {
  451. builder
  452. .export(
  453. specta_typescript::Typescript::default(),
  454. "../src/bindings.ts",
  455. )
  456. .expect("Failed to export typescript bindings");
  457. }
  458. #[cfg(test)]
  459. #[test]
  460. fn test_export_types() {
  461. let builder = make_specta_builder();
  462. export_types(&builder);
  463. }
  464. #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
  465. struct LoadingWindowComplete;
  466. async fn initialize(app: AppHandle) {
  467. tracing::info!("Initializing app");
  468. let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting);
  469. setup_app(&app, init_rx);
  470. spawn_cli_sync_task(app.clone());
  471. let (server_ready_tx, server_ready_rx) = oneshot::channel();
  472. let server_ready_rx = server_ready_rx.shared();
  473. app.manage(ServerState::new(None, server_ready_rx.clone()));
  474. let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
  475. tracing::info!("Main and loading windows created");
  476. // SQLite migration handling:
  477. // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
  478. // First, we spawn a task that listens for SqliteMigrationProgress events that can
  479. // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
  480. // Then in the loading task, we wait for sqlite migration to complete before
  481. // starting our health check against the server, otherwise long migrations could result in a timeout.
  482. let needs_sqlite_migration = !sqlite_file_exists();
  483. let sqlite_done = needs_sqlite_migration.then(|| {
  484. tracing::info!(
  485. path = %opencode_db_path().expect("failed to get db path").display(),
  486. "Sqlite file not found, waiting for it to be generated"
  487. );
  488. let (done_tx, done_rx) = oneshot::channel::<()>();
  489. let done_tx = Arc::new(Mutex::new(Some(done_tx)));
  490. let init_tx = init_tx.clone();
  491. let id = SqliteMigrationProgress::listen(&app, move |e| {
  492. let _ = init_tx.send(InitStep::SqliteWaiting);
  493. if matches!(e.payload, SqliteMigrationProgress::Done)
  494. && let Some(done_tx) = done_tx.lock().unwrap().take()
  495. {
  496. let _ = done_tx.send(());
  497. }
  498. });
  499. let app = app.clone();
  500. tokio::spawn(done_rx.map(async move |_| {
  501. app.unlisten(id);
  502. }))
  503. });
  504. let loading_task = tokio::spawn({
  505. let app = app.clone();
  506. async move {
  507. tracing::info!("Setting up server connection");
  508. let server_connection = setup_server_connection(app.clone()).await;
  509. tracing::info!("Server connection setup");
  510. // we delay spawning this future so that the timeout is created lazily
  511. let cli_health_check = match server_connection {
  512. ServerConnection::CLI {
  513. child,
  514. health_check,
  515. url,
  516. password,
  517. } => {
  518. let app = app.clone();
  519. Some(
  520. async move {
  521. let res = timeout(Duration::from_secs(30), health_check.0).await;
  522. let err = match res {
  523. Ok(Ok(Ok(()))) => None,
  524. Ok(Ok(Err(e))) => Some(e),
  525. Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
  526. Err(_) => Some("Health check timed out".to_string()),
  527. };
  528. if let Some(err) = err {
  529. let _ = child.kill();
  530. return Err(format!(
  531. "Failed to spawn OpenCode Server ({err}). Logs:\n{}",
  532. get_logs()
  533. ));
  534. }
  535. tracing::info!("CLI health check OK");
  536. app.state::<ServerState>().set_child(Some(child));
  537. Ok(ServerReadyData { url, password })
  538. }
  539. .map(move |res| {
  540. let _ = server_ready_tx.send(res);
  541. }),
  542. )
  543. }
  544. ServerConnection::Existing { url } => {
  545. let _ = server_ready_tx.send(Ok(ServerReadyData {
  546. url: url.to_string(),
  547. password: None,
  548. }));
  549. None
  550. }
  551. };
  552. tracing::info!("server connection started");
  553. if let Some(cli_health_check) = cli_health_check {
  554. if let Some(sqlite_done_rx) = sqlite_done {
  555. let _ = sqlite_done_rx.await;
  556. }
  557. tokio::spawn(cli_health_check);
  558. }
  559. let _ = server_ready_rx.await;
  560. tracing::info!("Loading task finished");
  561. }
  562. })
  563. .map_err(|_| ())
  564. .shared();
  565. let loading_window = if needs_sqlite_migration
  566. && timeout(Duration::from_secs(1), loading_task.clone())
  567. .await
  568. .is_err()
  569. {
  570. tracing::debug!("Loading task timed out, showing loading window");
  571. let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
  572. sleep(Duration::from_secs(1)).await;
  573. Some(loading_window)
  574. } else {
  575. tracing::debug!("Showing main window without loading window");
  576. MainWindow::create(&app).expect("Failed to create main window");
  577. None
  578. };
  579. let _ = loading_task.await;
  580. tracing::info!("Loading done, completing initialisation");
  581. let _ = init_tx.send(InitStep::Done);
  582. if loading_window.is_some() {
  583. loading_window_complete.await;
  584. tracing::info!("Loading window completed");
  585. }
  586. MainWindow::create(&app).expect("Failed to create main window");
  587. if let Some(loading_window) = loading_window {
  588. let _ = loading_window.close();
  589. }
  590. }
  591. fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
  592. #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
  593. app.deep_link().register_all().ok();
  594. app.manage(InitState { current: init_rx });
  595. }
  596. fn spawn_cli_sync_task(app: AppHandle) {
  597. tokio::spawn(async move {
  598. if let Err(e) = sync_cli(app) {
  599. tracing::error!("Failed to sync CLI: {e}");
  600. }
  601. });
  602. }
  603. enum ServerConnection {
  604. Existing {
  605. url: String,
  606. },
  607. CLI {
  608. url: String,
  609. password: Option<String>,
  610. child: CommandChild,
  611. health_check: server::HealthCheck,
  612. },
  613. }
  614. async fn setup_server_connection(app: AppHandle) -> ServerConnection {
  615. let custom_url = get_saved_server_url(&app).await;
  616. tracing::info!(?custom_url, "Attempting server connection");
  617. if let Some(url) = custom_url
  618. && server::check_health_or_ask_retry(&app, &url).await
  619. {
  620. tracing::info!(%url, "Connected to custom server");
  621. return ServerConnection::Existing { url: url.clone() };
  622. }
  623. let local_port = get_sidecar_port();
  624. let hostname = "127.0.0.1";
  625. let local_url = format!("http://{hostname}:{local_port}");
  626. tracing::debug!(url = %local_url, "Checking health of local server");
  627. if server::check_health(&local_url, None).await {
  628. tracing::info!(url = %local_url, "Health check OK, using existing server");
  629. return ServerConnection::Existing { url: local_url };
  630. }
  631. let password = uuid::Uuid::new_v4().to_string();
  632. tracing::info!("Spawning new local server");
  633. let (child, health_check) =
  634. server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
  635. ServerConnection::CLI {
  636. url: local_url,
  637. password: Some(password),
  638. child,
  639. health_check,
  640. }
  641. }
  642. fn get_sidecar_port() -> u32 {
  643. option_env!("OPENCODE_PORT")
  644. .map(|s| s.to_string())
  645. .or_else(|| std::env::var("OPENCODE_PORT").ok())
  646. .and_then(|port_str| port_str.parse().ok())
  647. .unwrap_or_else(|| {
  648. TcpListener::bind("127.0.0.1:0")
  649. .expect("Failed to bind to find free port")
  650. .local_addr()
  651. .expect("Failed to get local address")
  652. .port()
  653. }) as u32
  654. }
  655. fn sqlite_file_exists() -> bool {
  656. let Ok(path) = opencode_db_path() else {
  657. return true;
  658. };
  659. path.exists()
  660. }
  661. fn opencode_db_path() -> Result<PathBuf, &'static str> {
  662. let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
  663. let data_home = match xdg_data_home {
  664. Some(v) => PathBuf::from(v),
  665. None => {
  666. let home = dirs::home_dir().ok_or("cannot determine home directory")?;
  667. home.join(".local").join("share")
  668. }
  669. };
  670. Ok(data_home.join("opencode").join("opencode.db"))
  671. }
  672. // Creates a `once` listener for the specified event and returns a future that resolves
  673. // when the listener is fired.
  674. // Since the future creation and awaiting can be done separately, it's possible to create the listener
  675. // synchronously before doing something, then awaiting afterwards.
  676. fn event_once_fut<T: tauri_specta::Event + serde::de::DeserializeOwned>(
  677. app: &AppHandle,
  678. ) -> impl Future<Output = ()> {
  679. let (tx, rx) = oneshot::channel();
  680. T::once(app, |_| {
  681. let _ = tx.send(());
  682. });
  683. async {
  684. let _ = rx.await;
  685. }
  686. }