lib.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  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 os;
  10. mod server;
  11. mod window_customizer;
  12. mod windows;
  13. use crate::cli::CommandChild;
  14. use futures::{FutureExt, TryFutureExt};
  15. use std::{
  16. env,
  17. future::Future,
  18. net::TcpListener,
  19. path::PathBuf,
  20. process::Command,
  21. sync::{Arc, Mutex},
  22. time::Duration,
  23. };
  24. use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel};
  25. #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
  26. use tauri_plugin_deep_link::DeepLinkExt;
  27. use tauri_specta::Event;
  28. use tokio::{
  29. sync::{oneshot, watch},
  30. time::{sleep, timeout},
  31. };
  32. use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
  33. use crate::constants::*;
  34. use crate::windows::{LoadingWindow, MainWindow};
  35. #[derive(Clone, serde::Serialize, specta::Type, Debug)]
  36. struct ServerReadyData {
  37. url: String,
  38. username: Option<String>,
  39. password: Option<String>,
  40. }
  41. #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
  42. #[serde(tag = "phase", rename_all = "snake_case")]
  43. enum InitStep {
  44. ServerWaiting,
  45. SqliteWaiting,
  46. Done,
  47. }
  48. #[derive(serde::Deserialize, specta::Type)]
  49. #[serde(rename_all = "snake_case")]
  50. enum WslPathMode {
  51. Windows,
  52. Linux,
  53. }
  54. struct InitState {
  55. current: watch::Receiver<InitStep>,
  56. }
  57. struct ServerState {
  58. child: Arc<Mutex<Option<CommandChild>>>,
  59. }
  60. /// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
  61. struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
  62. #[tauri::command]
  63. #[specta::specta]
  64. fn kill_sidecar(app: AppHandle) {
  65. let Some(server_state) = app.try_state::<ServerState>() else {
  66. tracing::info!("Server not running");
  67. return;
  68. };
  69. let Some(server_state) = server_state
  70. .child
  71. .lock()
  72. .expect("Failed to acquire mutex lock")
  73. .take()
  74. else {
  75. tracing::info!("Server state missing");
  76. return;
  77. };
  78. let _ = server_state.kill();
  79. tracing::info!("Killed server");
  80. }
  81. #[tauri::command]
  82. #[specta::specta]
  83. async fn await_initialization(
  84. state: State<'_, SidecarReady>,
  85. init_state: State<'_, InitState>,
  86. events: Channel<InitStep>,
  87. ) -> Result<ServerReadyData, String> {
  88. let mut rx = init_state.current.clone();
  89. let stream = async {
  90. let e = *rx.borrow();
  91. let _ = events.send(e);
  92. while rx.changed().await.is_ok() {
  93. let step = *rx.borrow_and_update();
  94. let _ = events.send(step);
  95. if matches!(step, InitStep::Done) {
  96. break;
  97. }
  98. }
  99. };
  100. // Wait for sidecar credentials (available immediately after spawn, before health check)
  101. let data = async {
  102. state
  103. .inner()
  104. .0
  105. .clone()
  106. .await
  107. .map_err(|_| "Failed to get sidecar data".to_string())
  108. };
  109. let (result, _) = futures::future::join(data, stream).await;
  110. result
  111. }
  112. #[tauri::command]
  113. #[specta::specta]
  114. fn check_app_exists(app_name: &str) -> bool {
  115. #[cfg(target_os = "windows")]
  116. {
  117. os::windows::check_windows_app(app_name)
  118. }
  119. #[cfg(target_os = "macos")]
  120. {
  121. check_macos_app(app_name)
  122. }
  123. #[cfg(target_os = "linux")]
  124. {
  125. check_linux_app(app_name)
  126. }
  127. }
  128. #[tauri::command]
  129. #[specta::specta]
  130. fn resolve_app_path(app_name: &str) -> Option<String> {
  131. #[cfg(target_os = "windows")]
  132. {
  133. os::windows::resolve_windows_app_path(app_name)
  134. }
  135. #[cfg(not(target_os = "windows"))]
  136. {
  137. // On macOS/Linux, just return the app_name as-is since
  138. // the opener plugin handles them correctly
  139. Some(app_name.to_string())
  140. }
  141. }
  142. #[tauri::command]
  143. #[specta::specta]
  144. fn open_path(_app: AppHandle, path: String, app_name: Option<String>) -> Result<(), String> {
  145. #[cfg(target_os = "windows")]
  146. {
  147. let app_name = app_name.map(|v| os::windows::resolve_windows_app_path(&v).unwrap_or(v));
  148. let is_powershell = app_name.as_ref().is_some_and(|v| {
  149. std::path::Path::new(v)
  150. .file_name()
  151. .and_then(|name| name.to_str())
  152. .is_some_and(|name| {
  153. name.eq_ignore_ascii_case("powershell")
  154. || name.eq_ignore_ascii_case("powershell.exe")
  155. })
  156. });
  157. if is_powershell {
  158. return os::windows::open_in_powershell(path);
  159. }
  160. return tauri_plugin_opener::open_path(path, app_name.as_deref())
  161. .map_err(|e| format!("Failed to open path: {e}"));
  162. }
  163. #[cfg(not(target_os = "windows"))]
  164. tauri_plugin_opener::open_path(path, app_name.as_deref())
  165. .map_err(|e| format!("Failed to open path: {e}"))
  166. }
  167. #[cfg(target_os = "macos")]
  168. fn check_macos_app(app_name: &str) -> bool {
  169. // Check common installation locations
  170. let mut app_locations = vec![
  171. format!("/Applications/{}.app", app_name),
  172. format!("/System/Applications/{}.app", app_name),
  173. ];
  174. if let Ok(home) = std::env::var("HOME") {
  175. app_locations.push(format!("{}/Applications/{}.app", home, app_name));
  176. }
  177. for location in app_locations {
  178. if std::path::Path::new(&location).exists() {
  179. return true;
  180. }
  181. }
  182. // Also check if command exists in PATH
  183. Command::new("which")
  184. .arg(app_name)
  185. .output()
  186. .map(|output| output.status.success())
  187. .unwrap_or(false)
  188. }
  189. #[derive(serde::Serialize, serde::Deserialize, specta::Type)]
  190. #[serde(rename_all = "camelCase")]
  191. pub enum LinuxDisplayBackend {
  192. Wayland,
  193. Auto,
  194. }
  195. #[tauri::command]
  196. #[specta::specta]
  197. fn get_display_backend() -> Option<LinuxDisplayBackend> {
  198. #[cfg(target_os = "linux")]
  199. {
  200. let prefer = linux_display::read_wayland().unwrap_or(false);
  201. return Some(if prefer {
  202. LinuxDisplayBackend::Wayland
  203. } else {
  204. LinuxDisplayBackend::Auto
  205. });
  206. }
  207. #[cfg(not(target_os = "linux"))]
  208. None
  209. }
  210. #[tauri::command]
  211. #[specta::specta]
  212. fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> {
  213. #[cfg(target_os = "linux")]
  214. {
  215. let prefer = matches!(_backend, LinuxDisplayBackend::Wayland);
  216. return linux_display::write_wayland(&_app, prefer);
  217. }
  218. #[cfg(not(target_os = "linux"))]
  219. Ok(())
  220. }
  221. #[cfg(target_os = "linux")]
  222. fn check_linux_app(app_name: &str) -> bool {
  223. return true;
  224. }
  225. #[tauri::command]
  226. #[specta::specta]
  227. fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> {
  228. if !cfg!(windows) {
  229. return Ok(path);
  230. }
  231. let flag = match mode.unwrap_or(WslPathMode::Linux) {
  232. WslPathMode::Windows => "-w",
  233. WslPathMode::Linux => "-u",
  234. };
  235. let output = if path.starts_with('~') {
  236. let suffix = path.strip_prefix('~').unwrap_or("");
  237. let escaped = suffix.replace('"', "\\\"");
  238. let cmd = format!("wslpath {flag} \"$HOME{escaped}\"");
  239. Command::new("wsl")
  240. .args(["-e", "sh", "-lc", &cmd])
  241. .output()
  242. .map_err(|e| format!("Failed to run wslpath: {e}"))?
  243. } else {
  244. Command::new("wsl")
  245. .args(["-e", "wslpath", flag, &path])
  246. .output()
  247. .map_err(|e| format!("Failed to run wslpath: {e}"))?
  248. };
  249. if !output.status.success() {
  250. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  251. if stderr.is_empty() {
  252. return Err("wslpath failed".to_string());
  253. }
  254. return Err(stderr);
  255. }
  256. Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
  257. }
  258. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  259. pub fn run() {
  260. let builder = make_specta_builder();
  261. #[cfg(debug_assertions)] // <- Only export on non-release builds
  262. export_types(&builder);
  263. #[cfg(all(target_os = "macos", not(debug_assertions)))]
  264. let _ = std::process::Command::new("killall")
  265. .arg("opencode-cli")
  266. .output();
  267. let mut builder = tauri::Builder::default()
  268. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
  269. // Focus existing window when another instance is launched
  270. if let Some(window) = app.get_webview_window(MainWindow::LABEL) {
  271. let _ = window.set_focus();
  272. let _ = window.unminimize();
  273. }
  274. }))
  275. .plugin(tauri_plugin_deep_link::init())
  276. .plugin(tauri_plugin_os::init())
  277. .plugin(
  278. tauri_plugin_window_state::Builder::new()
  279. .with_state_flags(window_state_flags())
  280. .with_denylist(&[LoadingWindow::LABEL])
  281. .build(),
  282. )
  283. .plugin(tauri_plugin_store::Builder::new().build())
  284. .plugin(tauri_plugin_dialog::init())
  285. .plugin(tauri_plugin_shell::init())
  286. .plugin(tauri_plugin_process::init())
  287. .plugin(tauri_plugin_opener::init())
  288. .plugin(tauri_plugin_clipboard_manager::init())
  289. .plugin(tauri_plugin_http::init())
  290. .plugin(tauri_plugin_notification::init())
  291. .plugin(crate::window_customizer::PinchZoomDisablePlugin)
  292. .plugin(tauri_plugin_decorum::init())
  293. .invoke_handler(builder.invoke_handler())
  294. .setup(move |app| {
  295. let handle = app.handle().clone();
  296. let log_dir = app
  297. .path()
  298. .app_log_dir()
  299. .expect("failed to resolve app log dir");
  300. // Hold the guard in managed state so it lives for the app's lifetime,
  301. // ensuring all buffered logs are flushed on shutdown.
  302. handle.manage(logging::init(&log_dir));
  303. builder.mount_events(&handle);
  304. tauri::async_runtime::spawn(initialize(handle));
  305. Ok(())
  306. });
  307. if UPDATER_ENABLED {
  308. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  309. }
  310. builder
  311. .build(tauri::generate_context!())
  312. .expect("error while running tauri application")
  313. .run(|app, event| {
  314. if let RunEvent::Exit = event {
  315. tracing::info!("Received Exit");
  316. kill_sidecar(app.clone());
  317. }
  318. });
  319. }
  320. fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
  321. tauri_specta::Builder::<tauri::Wry>::new()
  322. // Then register them (separated by a comma)
  323. .commands(tauri_specta::collect_commands![
  324. kill_sidecar,
  325. cli::install_cli,
  326. await_initialization,
  327. server::get_default_server_url,
  328. server::set_default_server_url,
  329. server::get_wsl_config,
  330. server::set_wsl_config,
  331. get_display_backend,
  332. set_display_backend,
  333. markdown::parse_markdown_command,
  334. check_app_exists,
  335. wsl_path,
  336. resolve_app_path,
  337. open_path
  338. ])
  339. .events(tauri_specta::collect_events![
  340. LoadingWindowComplete,
  341. SqliteMigrationProgress
  342. ])
  343. .error_handling(tauri_specta::ErrorHandlingMode::Throw)
  344. }
  345. fn export_types(builder: &tauri_specta::Builder<tauri::Wry>) {
  346. builder
  347. .export(
  348. specta_typescript::Typescript::default(),
  349. "../src/bindings.ts",
  350. )
  351. .expect("Failed to export typescript bindings");
  352. }
  353. #[cfg(test)]
  354. #[test]
  355. fn test_export_types() {
  356. let builder = make_specta_builder();
  357. export_types(&builder);
  358. }
  359. #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
  360. struct LoadingWindowComplete;
  361. async fn initialize(app: AppHandle) {
  362. tracing::info!("Initializing app");
  363. let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting);
  364. setup_app(&app, init_rx);
  365. spawn_cli_sync_task(app.clone());
  366. // Spawn sidecar immediately - credentials are known before health check
  367. let port = get_sidecar_port();
  368. let hostname = "127.0.0.1";
  369. let url = format!("http://{hostname}:{port}");
  370. let password = uuid::Uuid::new_v4().to_string();
  371. tracing::info!("Spawning sidecar on {url}");
  372. let (child, health_check) =
  373. server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone());
  374. // Make sidecar credentials available immediately (before health check completes)
  375. let (ready_tx, ready_rx) = oneshot::channel();
  376. let _ = ready_tx.send(ServerReadyData {
  377. url: url.clone(),
  378. username: Some("opencode".to_string()),
  379. password: Some(password),
  380. });
  381. app.manage(SidecarReady(ready_rx.shared()));
  382. app.manage(ServerState {
  383. child: Arc::new(Mutex::new(Some(child))),
  384. });
  385. let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
  386. // SQLite migration handling:
  387. // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it.
  388. // A separate loading window is shown for long migrations.
  389. let needs_migration = !sqlite_file_exists();
  390. let sqlite_done = needs_migration.then(|| {
  391. tracing::info!(
  392. path = %opencode_db_path().expect("failed to get db path").display(),
  393. "Sqlite file not found, waiting for it to be generated"
  394. );
  395. let (done_tx, done_rx) = oneshot::channel::<()>();
  396. let done_tx = Arc::new(Mutex::new(Some(done_tx)));
  397. let init_tx = init_tx.clone();
  398. let id = SqliteMigrationProgress::listen(&app, move |e| {
  399. let _ = init_tx.send(InitStep::SqliteWaiting);
  400. if matches!(e.payload, SqliteMigrationProgress::Done)
  401. && let Some(done_tx) = done_tx.lock().unwrap().take()
  402. {
  403. let _ = done_tx.send(());
  404. }
  405. });
  406. let app = app.clone();
  407. tokio::spawn(done_rx.map(async move |_| {
  408. app.unlisten(id);
  409. }))
  410. });
  411. // The loading task waits for SQLite migration (if needed) then for the sidecar health check.
  412. // This is only used to drive the loading window progress - the main window is shown immediately.
  413. let loading_task = tokio::spawn({
  414. async move {
  415. if let Some(sqlite_done_rx) = sqlite_done {
  416. let _ = sqlite_done_rx.await;
  417. }
  418. // Wait for sidecar to become healthy (for loading window progress)
  419. let res = timeout(Duration::from_secs(30), health_check.0).await;
  420. match res {
  421. Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"),
  422. Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"),
  423. Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"),
  424. Err(_) => tracing::error!("Sidecar health check timed out"),
  425. }
  426. tracing::info!("Loading task finished");
  427. }
  428. })
  429. .map_err(|_| ())
  430. .shared();
  431. // Show loading window for SQLite migrations if they take >1s
  432. let loading_window = if needs_migration
  433. && timeout(Duration::from_secs(1), loading_task.clone())
  434. .await
  435. .is_err()
  436. {
  437. tracing::debug!("Loading task timed out, showing loading window");
  438. let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
  439. sleep(Duration::from_secs(1)).await;
  440. Some(loading_window)
  441. } else {
  442. None
  443. };
  444. // Create main window immediately - the web app handles its own loading/health gate
  445. MainWindow::create(&app).expect("Failed to create main window");
  446. let _ = loading_task.await;
  447. tracing::info!("Loading done, completing initialisation");
  448. let _ = init_tx.send(InitStep::Done);
  449. if loading_window.is_some() {
  450. loading_window_complete.await;
  451. tracing::info!("Loading window completed");
  452. }
  453. if let Some(loading_window) = loading_window {
  454. let _ = loading_window.close();
  455. }
  456. }
  457. fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
  458. #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
  459. app.deep_link().register_all().ok();
  460. app.manage(InitState { current: init_rx });
  461. }
  462. fn spawn_cli_sync_task(app: AppHandle) {
  463. tokio::spawn(async move {
  464. if let Err(e) = sync_cli(app) {
  465. tracing::error!("Failed to sync CLI: {e}");
  466. }
  467. });
  468. }
  469. fn get_sidecar_port() -> u32 {
  470. option_env!("OPENCODE_PORT")
  471. .map(|s| s.to_string())
  472. .or_else(|| std::env::var("OPENCODE_PORT").ok())
  473. .and_then(|port_str| port_str.parse().ok())
  474. .unwrap_or_else(|| {
  475. TcpListener::bind("127.0.0.1:0")
  476. .expect("Failed to bind to find free port")
  477. .local_addr()
  478. .expect("Failed to get local address")
  479. .port()
  480. }) as u32
  481. }
  482. fn sqlite_file_exists() -> bool {
  483. let Ok(path) = opencode_db_path() else {
  484. return true;
  485. };
  486. path.exists()
  487. }
  488. fn opencode_db_path() -> Result<PathBuf, &'static str> {
  489. let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
  490. let data_home = match xdg_data_home {
  491. Some(v) => PathBuf::from(v),
  492. None => {
  493. let home = dirs::home_dir().ok_or("cannot determine home directory")?;
  494. home.join(".local").join("share")
  495. }
  496. };
  497. Ok(data_home.join("opencode").join("opencode.db"))
  498. }
  499. // Creates a `once` listener for the specified event and returns a future that resolves
  500. // when the listener is fired.
  501. // Since the future creation and awaiting can be done separately, it's possible to create the listener
  502. // synchronously before doing something, then awaiting afterwards.
  503. fn event_once_fut<T: tauri_specta::Event + serde::de::DeserializeOwned>(
  504. app: &AppHandle,
  505. ) -> impl Future<Output = ()> {
  506. let (tx, rx) = oneshot::channel();
  507. T::once(app, |_| {
  508. let _ = tx.send(());
  509. });
  510. async {
  511. let _ = rx.await;
  512. }
  513. }