lib.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. mod cli;
  2. mod window_customizer;
  3. use cli::{get_sidecar_path, install_cli, sync_cli};
  4. use futures::FutureExt;
  5. use std::{
  6. collections::VecDeque,
  7. net::{SocketAddr, TcpListener},
  8. sync::{Arc, Mutex},
  9. time::{Duration, Instant},
  10. };
  11. use tauri::{
  12. path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
  13. WebviewWindow,
  14. };
  15. use tauri_plugin_shell::process::{CommandChild, CommandEvent};
  16. use tauri_plugin_shell::ShellExt;
  17. use tauri_plugin_store::StoreExt;
  18. use tokio::net::TcpSocket;
  19. use crate::window_customizer::PinchZoomDisablePlugin;
  20. #[derive(Clone)]
  21. struct ServerState {
  22. child: Arc<Mutex<Option<CommandChild>>>,
  23. status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
  24. }
  25. impl ServerState {
  26. pub fn new(
  27. child: Option<CommandChild>,
  28. status: tokio::sync::oneshot::Receiver<Result<(), String>>,
  29. ) -> Self {
  30. Self {
  31. child: Arc::new(Mutex::new(child)),
  32. status: status.shared(),
  33. }
  34. }
  35. pub fn set_child(&self, child: Option<CommandChild>) {
  36. *self.child.lock().unwrap() = child;
  37. }
  38. }
  39. #[derive(Clone)]
  40. struct LogState(Arc<Mutex<VecDeque<String>>>);
  41. const MAX_LOG_ENTRIES: usize = 200;
  42. const GLOBAL_STORAGE: &str = "opencode.global.dat";
  43. /// Check if a URL's origin matches any configured server in the store.
  44. /// Returns true if the URL should be allowed for internal navigation.
  45. fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
  46. // Always allow localhost and 127.0.0.1
  47. if let Some(host) = url.host_str() {
  48. if host == "localhost" || host == "127.0.0.1" {
  49. return true;
  50. }
  51. }
  52. // Try to read the server list from the store
  53. let Ok(store) = app.store(GLOBAL_STORAGE) else {
  54. return false;
  55. };
  56. let Some(server_data) = store.get("server") else {
  57. return false;
  58. };
  59. // Parse the server list from the stored JSON
  60. let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
  61. return false;
  62. };
  63. // Get the origin of the navigation URL (scheme + host + port)
  64. let url_origin = format!(
  65. "{}://{}{}",
  66. url.scheme(),
  67. url.host_str().unwrap_or(""),
  68. url.port().map(|p| format!(":{}", p)).unwrap_or_default()
  69. );
  70. // Check if any configured server matches the URL's origin
  71. for server in list {
  72. let Some(server_url) = server.as_str() else {
  73. continue;
  74. };
  75. // Parse the server URL to extract its origin
  76. let Ok(parsed) = tauri::Url::parse(server_url) else {
  77. continue;
  78. };
  79. let server_origin = format!(
  80. "{}://{}{}",
  81. parsed.scheme(),
  82. parsed.host_str().unwrap_or(""),
  83. parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
  84. );
  85. if url_origin == server_origin {
  86. return true;
  87. }
  88. }
  89. false
  90. }
  91. #[tauri::command]
  92. fn kill_sidecar(app: AppHandle) {
  93. let Some(server_state) = app.try_state::<ServerState>() else {
  94. println!("Server not running");
  95. return;
  96. };
  97. let Some(server_state) = server_state
  98. .child
  99. .lock()
  100. .expect("Failed to acquire mutex lock")
  101. .take()
  102. else {
  103. println!("Server state missing");
  104. return;
  105. };
  106. let _ = server_state.kill();
  107. println!("Killed server");
  108. }
  109. async fn get_logs(app: AppHandle) -> Result<String, String> {
  110. let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
  111. let logs = log_state
  112. .0
  113. .lock()
  114. .map_err(|_| "Failed to acquire log lock")?;
  115. Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
  116. }
  117. #[tauri::command]
  118. async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
  119. state
  120. .status
  121. .clone()
  122. .await
  123. .map_err(|_| "Failed to get server status".to_string())?
  124. }
  125. fn get_sidecar_port() -> u32 {
  126. option_env!("OPENCODE_PORT")
  127. .map(|s| s.to_string())
  128. .or_else(|| std::env::var("OPENCODE_PORT").ok())
  129. .and_then(|port_str| port_str.parse().ok())
  130. .unwrap_or_else(|| {
  131. TcpListener::bind("127.0.0.1:0")
  132. .expect("Failed to bind to find free port")
  133. .local_addr()
  134. .expect("Failed to get local address")
  135. .port()
  136. }) as u32
  137. }
  138. fn get_user_shell() -> String {
  139. std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
  140. }
  141. fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
  142. let log_state = app.state::<LogState>();
  143. let log_state_clone = log_state.inner().clone();
  144. let state_dir = app
  145. .path()
  146. .resolve("", BaseDirectory::AppLocalData)
  147. .expect("Failed to resolve app local data dir");
  148. #[cfg(target_os = "windows")]
  149. let (mut rx, child) = app
  150. .shell()
  151. .sidecar("opencode-cli")
  152. .unwrap()
  153. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  154. .env("OPENCODE_CLIENT", "desktop")
  155. .env("XDG_STATE_HOME", &state_dir)
  156. .args(["serve", &format!("--port={port}")])
  157. .spawn()
  158. .expect("Failed to spawn opencode");
  159. #[cfg(not(target_os = "windows"))]
  160. let (mut rx, child) = {
  161. let sidecar = get_sidecar_path();
  162. let shell = get_user_shell();
  163. app.shell()
  164. .command(&shell)
  165. .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
  166. .env("OPENCODE_CLIENT", "desktop")
  167. .env("XDG_STATE_HOME", &state_dir)
  168. .args([
  169. "-il",
  170. "-c",
  171. &format!("\"{}\" serve --port={}", sidecar.display(), port),
  172. ])
  173. .spawn()
  174. .expect("Failed to spawn opencode")
  175. };
  176. tauri::async_runtime::spawn(async move {
  177. while let Some(event) = rx.recv().await {
  178. match event {
  179. CommandEvent::Stdout(line_bytes) => {
  180. let line = String::from_utf8_lossy(&line_bytes);
  181. print!("{line}");
  182. // Store log in shared state
  183. if let Ok(mut logs) = log_state_clone.0.lock() {
  184. logs.push_back(format!("[STDOUT] {}", line));
  185. // Keep only the last MAX_LOG_ENTRIES
  186. while logs.len() > MAX_LOG_ENTRIES {
  187. logs.pop_front();
  188. }
  189. }
  190. }
  191. CommandEvent::Stderr(line_bytes) => {
  192. let line = String::from_utf8_lossy(&line_bytes);
  193. eprint!("{line}");
  194. // Store log in shared state
  195. if let Ok(mut logs) = log_state_clone.0.lock() {
  196. logs.push_back(format!("[STDERR] {}", line));
  197. // Keep only the last MAX_LOG_ENTRIES
  198. while logs.len() > MAX_LOG_ENTRIES {
  199. logs.pop_front();
  200. }
  201. }
  202. }
  203. _ => {}
  204. }
  205. }
  206. });
  207. child
  208. }
  209. async fn is_server_running(port: u32) -> bool {
  210. TcpSocket::new_v4()
  211. .unwrap()
  212. .connect(SocketAddr::new(
  213. "127.0.0.1".parse().expect("Failed to parse IP"),
  214. port as u16,
  215. ))
  216. .await
  217. .is_ok()
  218. }
  219. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  220. pub fn run() {
  221. let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
  222. let mut builder = tauri::Builder::default()
  223. .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
  224. // Focus existing window when another instance is launched
  225. if let Some(window) = app.get_webview_window("main") {
  226. let _ = window.set_focus();
  227. let _ = window.unminimize();
  228. }
  229. }))
  230. .plugin(tauri_plugin_os::init())
  231. .plugin(tauri_plugin_window_state::Builder::new().build())
  232. .plugin(tauri_plugin_store::Builder::new().build())
  233. .plugin(tauri_plugin_dialog::init())
  234. .plugin(tauri_plugin_shell::init())
  235. .plugin(tauri_plugin_process::init())
  236. .plugin(tauri_plugin_opener::init())
  237. .plugin(tauri_plugin_clipboard_manager::init())
  238. .plugin(tauri_plugin_http::init())
  239. .plugin(tauri_plugin_notification::init())
  240. .plugin(PinchZoomDisablePlugin)
  241. .invoke_handler(tauri::generate_handler![
  242. kill_sidecar,
  243. install_cli,
  244. ensure_server_started
  245. ])
  246. .setup(move |app| {
  247. let app = app.handle().clone();
  248. // Initialize log state
  249. app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
  250. // Get port and create window immediately for faster perceived startup
  251. let port = get_sidecar_port();
  252. let primary_monitor = app.primary_monitor().ok().flatten();
  253. let size = primary_monitor
  254. .map(|m| m.size().to_logical(m.scale_factor()))
  255. .unwrap_or(LogicalSize::new(1920, 1080));
  256. // Create window immediately with serverReady = false
  257. let app_for_nav = app.clone();
  258. let mut window_builder =
  259. WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
  260. .title("OpenCode")
  261. .inner_size(size.width as f64, size.height as f64)
  262. .decorations(true)
  263. .zoom_hotkeys_enabled(true)
  264. .disable_drag_drop_handler()
  265. .on_navigation(move |url| {
  266. // Allow internal navigation (tauri:// scheme)
  267. if url.scheme() == "tauri" {
  268. return true;
  269. }
  270. // Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
  271. if is_allowed_server(&app_for_nav, url) {
  272. return true;
  273. }
  274. // Open external http/https URLs in default browser
  275. if url.scheme() == "http" || url.scheme() == "https" {
  276. let _ = app_for_nav.shell().open(url.as_str(), None);
  277. return false; // Cancel internal navigation
  278. }
  279. true
  280. })
  281. .initialization_script(format!(
  282. r#"
  283. window.__OPENCODE__ ??= {{}};
  284. window.__OPENCODE__.updaterEnabled = {updater_enabled};
  285. window.__OPENCODE__.port = {port};
  286. "#
  287. ));
  288. #[cfg(target_os = "macos")]
  289. {
  290. window_builder = window_builder
  291. .title_bar_style(tauri::TitleBarStyle::Overlay)
  292. .hidden_title(true);
  293. }
  294. let window = window_builder.build().expect("Failed to create window");
  295. let (tx, rx) = tokio::sync::oneshot::channel();
  296. app.manage(ServerState::new(None, rx));
  297. {
  298. let app = app.clone();
  299. tauri::async_runtime::spawn(async move {
  300. let should_spawn_sidecar = !is_server_running(port).await;
  301. let (child, res) = if should_spawn_sidecar {
  302. let child = spawn_sidecar(&app, port);
  303. let timestamp = Instant::now();
  304. let res = loop {
  305. if timestamp.elapsed() > Duration::from_secs(7) {
  306. break Err(format!(
  307. "Failed to spawn OpenCode Server. Logs:\n{}",
  308. get_logs(app.clone()).await.unwrap()
  309. ));
  310. }
  311. tokio::time::sleep(Duration::from_millis(10)).await;
  312. if is_server_running(port).await {
  313. // give the server a little bit more time to warm up
  314. tokio::time::sleep(Duration::from_millis(10)).await;
  315. break Ok(());
  316. }
  317. };
  318. println!("Server ready after {:?}", timestamp.elapsed());
  319. (Some(child), res)
  320. } else {
  321. (None, Ok(()))
  322. };
  323. app.state::<ServerState>().set_child(child);
  324. if res.is_ok() {
  325. let _ = window.eval("window.__OPENCODE__.serverReady = true;");
  326. }
  327. let _ = tx.send(res);
  328. });
  329. }
  330. {
  331. let app = app.clone();
  332. tauri::async_runtime::spawn(async move {
  333. if let Err(e) = sync_cli(app) {
  334. eprintln!("Failed to sync CLI: {e}");
  335. }
  336. });
  337. }
  338. Ok(())
  339. });
  340. if updater_enabled {
  341. builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
  342. }
  343. builder
  344. .build(tauri::generate_context!())
  345. .expect("error while running tauri application")
  346. .run(|app, event| {
  347. if let RunEvent::Exit = event {
  348. println!("Received Exit");
  349. kill_sidecar(app.clone());
  350. }
  351. });
  352. }