lib.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. mod cli;
  2. mod constants;
  3. #[cfg(windows)]
  4. mod job_object;
  5. #[cfg(target_os = "linux")]
  6. mod linux_display;
  7. mod markdown;
  8. mod server;
  9. mod window_customizer;
  10. mod windows;
  11. use futures::{
  12. FutureExt, TryFutureExt,
  13. future::{self, Shared},
  14. };
  15. #[cfg(windows)]
  16. use job_object::*;
  17. use std::{
  18. collections::VecDeque,
  19. env,
  20. net::TcpListener,
  21. path::PathBuf,
  22. sync::{Arc, Mutex},
  23. time::Duration,
  24. process::Command,
  25. };
  26. use tauri::{AppHandle, 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 tokio::{
  31. sync::{oneshot, watch},
  32. time::{sleep, timeout},
  33. };
  34. use crate::cli::sync_cli;
  35. use crate::constants::*;
  36. use crate::server::get_saved_server_url;
  37. use crate::windows::{LoadingWindow, MainWindow};
  38. #[derive(Clone, serde::Serialize, specta::Type, Debug)]
  39. struct ServerReadyData {
  40. url: String,
  41. password: Option<String>,
  42. }
  43. #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
  44. #[serde(tag = "phase", rename_all = "snake_case")]
  45. enum InitStep {
  46. ServerWaiting,
  47. SqliteWaiting,
  48. Done,
  49. }
  50. struct InitState {
  51. current: watch::Receiver<InitStep>,
  52. }
  53. #[derive(Clone)]
  54. struct ServerState {
  55. child: Arc<Mutex<Option<CommandChild>>>,
  56. status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
  57. }
  58. impl ServerState {
  59. pub fn new(
  60. child: Option<CommandChild>,
  61. status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
  62. ) -> Self {
  63. Self {
  64. child: Arc::new(Mutex::new(child)),
  65. status,
  66. }
  67. }
  68. pub fn set_child(&self, child: Option<CommandChild>) {
  69. *self.child.lock().unwrap() = child;
  70. }
  71. }
  72. #[derive(Clone)]
  73. struct LogState(Arc<Mutex<VecDeque<String>>>);
  74. #[tauri::command]
  75. #[specta::specta]
  76. fn kill_sidecar(app: AppHandle) {
  77. let Some(server_state) = app.try_state::<ServerState>() else {
  78. println!("Server not running");
  79. return;
  80. };
  81. let Some(server_state) = server_state
  82. .child
  83. .lock()
  84. .expect("Failed to acquire mutex lock")
  85. .take()
  86. else {
  87. println!("Server state missing");
  88. return;
  89. };
  90. let _ = server_state.kill();
  91. println!("Killed server");
  92. }
  93. async fn get_logs(app: AppHandle) -> Result<String, String> {
  94. let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
  95. let logs = log_state
  96. .0
  97. .lock()
  98. .map_err(|_| "Failed to acquire log lock")?;
  99. Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
  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()).clone();
  111. let _ = events.send(e).unwrap();
  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 = "macos")]
  147. fn check_macos_app(app_name: &str) -> bool {
  148. // Check common installation locations
  149. let mut app_locations = vec![
  150. format!("/Applications/{}.app", app_name),
  151. format!("/System/Applications/{}.app", app_name),
  152. ];
  153. if let Ok(home) = std::env::var("HOME") {
  154. app_locations.push(format!("{}/Applications/{}.app", home, app_name));
  155. }
  156. for location in app_locations {
  157. if std::path::Path::new(&location).exists() {
  158. return true;
  159. }
  160. }
  161. // Also check if command exists in PATH
  162. Command::new("which")
  163. .arg(app_name)
  164. .output()
  165. .map(|output| output.status.success())
  166. .unwrap_or(false)
  167. }
  168. #[derive(serde::Serialize, serde::Deserialize, specta::Type)]
  169. #[serde(rename_all = "camelCase")]
  170. pub enum LinuxDisplayBackend {
  171. Wayland,
  172. Auto,
  173. }
  174. #[tauri::command]
  175. #[specta::specta]
  176. fn get_display_backend() -> Option<LinuxDisplayBackend> {
  177. #[cfg(target_os = "linux")]
  178. {
  179. let prefer = linux_display::read_wayland().unwrap_or(false);
  180. return Some(if prefer {
  181. LinuxDisplayBackend::Wayland
  182. } else {
  183. LinuxDisplayBackend::Auto
  184. });
  185. }
  186. #[cfg(not(target_os = "linux"))]
  187. None
  188. }
  189. #[tauri::command]
  190. #[specta::specta]
  191. fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> {
  192. #[cfg(target_os = "linux")]
  193. {
  194. let prefer = matches!(_backend, LinuxDisplayBackend::Wayland);
  195. return linux_display::write_wayland(&_app, prefer);
  196. }
  197. #[cfg(not(target_os = "linux"))]
  198. Ok(())
  199. }
  200. #[cfg(target_os = "linux")]
  201. fn check_linux_app(app_name: &str) -> bool {
  202. return true;
  203. }
  204. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  205. pub fn run() {
  206. let builder = tauri_specta::Builder::<tauri::Wry>::new()
  207. // Then register them (separated by a comma)
  208. .commands(tauri_specta::collect_commands![
  209. kill_sidecar,
  210. cli::install_cli,
  211. await_initialization,
  212. server::get_default_server_url,
  213. server::set_default_server_url,
  214. get_display_backend,
  215. set_display_backend,
  216. markdown::parse_markdown_command,
  217. check_app_exists
  218. ])
  219. .events(tauri_specta::collect_events![LoadingWindowComplete])
  220. .error_handling(tauri_specta::ErrorHandlingMode::Throw);
  221. #[cfg(debug_assertions)] // <- Only export on non-release builds
  222. builder
  223. .export(
  224. specta_typescript::Typescript::default(),
  225. "../src/bindings.ts",
  226. )
  227. .expect("Failed to export typescript bindings");
  228. #[cfg(all(target_os = "macos", not(debug_assertions)))]
  229. let _ = std::process::Command::new("killall")
  230. .arg("opencode-cli")
  231. .output();
  232. let mut builder = tauri::Builder::default()
  233. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
  234. // Focus existing window when another instance is launched
  235. if let Some(window) = app.get_webview_window(MainWindow::LABEL) {
  236. let _ = window.set_focus();
  237. let _ = window.unminimize();
  238. }
  239. }))
  240. .plugin(tauri_plugin_deep_link::init())
  241. .plugin(tauri_plugin_os::init())
  242. .plugin(
  243. tauri_plugin_window_state::Builder::new()
  244. .with_state_flags(window_state_flags())
  245. .with_denylist(&[LoadingWindow::LABEL])
  246. .build(),
  247. )
  248. .plugin(tauri_plugin_store::Builder::new().build())
  249. .plugin(tauri_plugin_dialog::init())
  250. .plugin(tauri_plugin_shell::init())
  251. .plugin(tauri_plugin_process::init())
  252. .plugin(tauri_plugin_opener::init())
  253. .plugin(tauri_plugin_clipboard_manager::init())
  254. .plugin(tauri_plugin_http::init())
  255. .plugin(tauri_plugin_notification::init())
  256. .plugin(crate::window_customizer::PinchZoomDisablePlugin)
  257. .plugin(tauri_plugin_decorum::init())
  258. .invoke_handler(builder.invoke_handler())
  259. .setup(move |app| {
  260. let app = app.handle().clone();
  261. builder.mount_events(&app);
  262. tauri::async_runtime::spawn(initialize(app));
  263. Ok(())
  264. });
  265. if UPDATER_ENABLED {
  266. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  267. }
  268. builder
  269. .build(tauri::generate_context!())
  270. .expect("error while running tauri application")
  271. .run(|app, event| {
  272. if let RunEvent::Exit = event {
  273. println!("Received Exit");
  274. kill_sidecar(app.clone());
  275. }
  276. });
  277. }
  278. #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
  279. struct LoadingWindowComplete;
  280. // #[tracing::instrument(skip_all)]
  281. async fn initialize(app: AppHandle) {
  282. println!("Initializing app");
  283. let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting);
  284. setup_app(&app, init_rx);
  285. spawn_cli_sync_task(app.clone());
  286. let (server_ready_tx, server_ready_rx) = oneshot::channel();
  287. let server_ready_rx = server_ready_rx.shared();
  288. app.manage(ServerState::new(None, server_ready_rx.clone()));
  289. let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
  290. println!("Main and loading windows created");
  291. let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
  292. let loading_task = tokio::spawn({
  293. let init_tx = init_tx.clone();
  294. let app = app.clone();
  295. async move {
  296. let mut sqlite_exists = sqlite_file_exists();
  297. println!("Setting up server connection");
  298. let server_connection = setup_server_connection(app.clone()).await;
  299. // we delay spawning this future so that the timeout is created lazily
  300. let cli_health_check = match server_connection {
  301. ServerConnection::CLI {
  302. child,
  303. health_check,
  304. url,
  305. password,
  306. } => {
  307. let app = app.clone();
  308. Some(
  309. async move {
  310. let Ok(Ok(_)) = timeout(Duration::from_secs(30), health_check.0).await
  311. else {
  312. let _ = child.kill();
  313. return Err(format!(
  314. "Failed to spawn OpenCode Server. Logs:\n{}",
  315. get_logs(app.clone()).await.unwrap()
  316. ));
  317. };
  318. println!("CLI health check OK");
  319. #[cfg(windows)]
  320. {
  321. let job_state = app.state::<JobObjectState>();
  322. job_state.assign_pid(child.pid());
  323. }
  324. app.state::<ServerState>().set_child(Some(child));
  325. Ok(ServerReadyData { url, password })
  326. }
  327. .map(move |res| {
  328. let _ = server_ready_tx.send(res);
  329. }),
  330. )
  331. }
  332. ServerConnection::Existing { url } => {
  333. let _ = server_ready_tx.send(Ok(ServerReadyData {
  334. url: url.to_string(),
  335. password: None,
  336. }));
  337. None
  338. }
  339. };
  340. if let Some(cli_health_check) = cli_health_check {
  341. if sqlite_enabled {
  342. println!("Does sqlite file exist: {sqlite_exists}");
  343. if !sqlite_exists {
  344. println!(
  345. "Sqlite file not found at {}, waiting for it to be generated",
  346. opencode_db_path().expect("failed to get db path").display()
  347. );
  348. let _ = init_tx.send(InitStep::SqliteWaiting);
  349. while !sqlite_exists {
  350. sleep(Duration::from_secs(1)).await;
  351. sqlite_exists = sqlite_file_exists();
  352. }
  353. }
  354. }
  355. tokio::spawn(cli_health_check);
  356. }
  357. let _ = server_ready_rx.await;
  358. }
  359. })
  360. .map_err(|_| ())
  361. .shared();
  362. let loading_window = if sqlite_enabled
  363. && timeout(Duration::from_secs(1), loading_task.clone())
  364. .await
  365. .is_err()
  366. {
  367. println!("Loading task timed out, showing loading window");
  368. let app = app.clone();
  369. let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
  370. sleep(Duration::from_secs(1)).await;
  371. Some(loading_window)
  372. } else {
  373. MainWindow::create(&app).expect("Failed to create main window");
  374. None
  375. };
  376. let _ = loading_task.await;
  377. println!("Loading done, completing initialisation");
  378. let _ = init_tx.send(InitStep::Done);
  379. if loading_window.is_some() {
  380. loading_window_complete.await;
  381. println!("Loading window completed");
  382. }
  383. MainWindow::create(&app).expect("Failed to create main window");
  384. if let Some(loading_window) = loading_window {
  385. let _ = loading_window.close();
  386. }
  387. }
  388. fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
  389. #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
  390. app.deep_link().register_all().ok();
  391. // Initialize log state
  392. app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
  393. #[cfg(windows)]
  394. app.manage(JobObjectState::new());
  395. app.manage(InitState { current: init_rx });
  396. }
  397. fn spawn_cli_sync_task(app: AppHandle) {
  398. tokio::spawn(async move {
  399. if let Err(e) = sync_cli(app) {
  400. eprintln!("Failed to sync CLI: {e}");
  401. }
  402. });
  403. }
  404. enum ServerConnection {
  405. Existing {
  406. url: String,
  407. },
  408. CLI {
  409. url: String,
  410. password: Option<String>,
  411. child: CommandChild,
  412. health_check: server::HealthCheck,
  413. },
  414. }
  415. async fn setup_server_connection(app: AppHandle) -> ServerConnection {
  416. let custom_url = get_saved_server_url(&app).await;
  417. println!("Attempting server connection to custom url: {custom_url:?}");
  418. if let Some(url) = custom_url
  419. && server::check_health_or_ask_retry(&app, &url).await
  420. {
  421. println!("Connected to custom server: {}", url);
  422. return ServerConnection::Existing { url: url.clone() };
  423. }
  424. let local_port = get_sidecar_port();
  425. let hostname = "127.0.0.1";
  426. let local_url = format!("http://{hostname}:{local_port}");
  427. println!("Checking health of server '{}'", local_url);
  428. if server::check_health(&local_url, None).await {
  429. println!("Health check OK, using existing server");
  430. return ServerConnection::Existing { url: local_url };
  431. }
  432. let password = uuid::Uuid::new_v4().to_string();
  433. println!("Spawning new local server");
  434. let (child, health_check) =
  435. server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
  436. ServerConnection::CLI {
  437. url: local_url,
  438. password: Some(password),
  439. child,
  440. health_check,
  441. }
  442. }
  443. fn get_sidecar_port() -> u32 {
  444. option_env!("OPENCODE_PORT")
  445. .map(|s| s.to_string())
  446. .or_else(|| std::env::var("OPENCODE_PORT").ok())
  447. .and_then(|port_str| port_str.parse().ok())
  448. .unwrap_or_else(|| {
  449. TcpListener::bind("127.0.0.1:0")
  450. .expect("Failed to bind to find free port")
  451. .local_addr()
  452. .expect("Failed to get local address")
  453. .port()
  454. }) as u32
  455. }
  456. fn sqlite_file_exists() -> bool {
  457. let Ok(path) = opencode_db_path() else {
  458. return true;
  459. };
  460. path.exists()
  461. }
  462. fn opencode_db_path() -> Result<PathBuf, &'static str> {
  463. let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
  464. let data_home = match xdg_data_home {
  465. Some(v) => PathBuf::from(v),
  466. None => {
  467. let home = dirs::home_dir().ok_or("cannot determine home directory")?;
  468. home.join(".local").join("share")
  469. }
  470. };
  471. Ok(data_home.join("opencode").join("opencode.db"))
  472. }
  473. // Creates a `once` listener for the specified event and returns a future that resolves
  474. // when the listener is fired.
  475. // Since the future creation and awaiting can be done separately, it's possible to create the listener
  476. // synchronously before doing something, then awaiting afterwards.
  477. fn event_once_fut<T: tauri_specta::Event + serde::de::DeserializeOwned>(
  478. app: &AppHandle,
  479. ) -> impl Future<Output = ()> {
  480. let (tx, rx) = oneshot::channel();
  481. T::once(app, |_| {
  482. let _ = tx.send(());
  483. });
  484. async {
  485. let _ = rx.await;
  486. }
  487. }