lib.rs 24 KB

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