server.rs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. use std::time::{Duration, Instant};
  2. use tauri::AppHandle;
  3. use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
  4. use tauri_plugin_shell::process::CommandChild;
  5. use tauri_plugin_store::StoreExt;
  6. use tokio::task::JoinHandle;
  7. use crate::{
  8. cli,
  9. constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
  10. };
  11. #[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)]
  12. pub struct WslConfig {
  13. pub enabled: bool,
  14. }
  15. impl Default for WslConfig {
  16. fn default() -> Self {
  17. Self { enabled: false }
  18. }
  19. }
  20. #[tauri::command]
  21. #[specta::specta]
  22. pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
  23. let store = app
  24. .store(SETTINGS_STORE)
  25. .map_err(|e| format!("Failed to open settings store: {}", e))?;
  26. let value = store.get(DEFAULT_SERVER_URL_KEY);
  27. match value {
  28. Some(v) => Ok(v.as_str().map(String::from)),
  29. None => Ok(None),
  30. }
  31. }
  32. #[tauri::command]
  33. #[specta::specta]
  34. pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
  35. let store = app
  36. .store(SETTINGS_STORE)
  37. .map_err(|e| format!("Failed to open settings store: {}", e))?;
  38. match url {
  39. Some(u) => {
  40. store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
  41. }
  42. None => {
  43. store.delete(DEFAULT_SERVER_URL_KEY);
  44. }
  45. }
  46. store
  47. .save()
  48. .map_err(|e| format!("Failed to save settings: {}", e))?;
  49. Ok(())
  50. }
  51. #[tauri::command]
  52. #[specta::specta]
  53. pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
  54. let store = app
  55. .store(SETTINGS_STORE)
  56. .map_err(|e| format!("Failed to open settings store: {}", e))?;
  57. let enabled = store
  58. .get(WSL_ENABLED_KEY)
  59. .as_ref()
  60. .and_then(|v| v.as_bool())
  61. .unwrap_or(false);
  62. Ok(WslConfig { enabled })
  63. }
  64. #[tauri::command]
  65. #[specta::specta]
  66. pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
  67. let store = app
  68. .store(SETTINGS_STORE)
  69. .map_err(|e| format!("Failed to open settings store: {}", e))?;
  70. store.set(WSL_ENABLED_KEY, serde_json::Value::Bool(config.enabled));
  71. store
  72. .save()
  73. .map_err(|e| format!("Failed to save settings: {}", e))?;
  74. Ok(())
  75. }
  76. pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
  77. if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
  78. tracing::info!(%url, "Using desktop-specific custom URL");
  79. return Some(url);
  80. }
  81. if let Some(cli_config) = cli::get_config(app).await
  82. && let Some(url) = get_server_url_from_config(&cli_config)
  83. {
  84. tracing::info!(%url, "Using custom server URL from config");
  85. return Some(url);
  86. }
  87. None
  88. }
  89. pub fn spawn_local_server(
  90. app: AppHandle,
  91. hostname: String,
  92. port: u32,
  93. password: String,
  94. ) -> (CommandChild, HealthCheck) {
  95. let (child, exit) = cli::serve(&app, &hostname, port, &password);
  96. let health_check = HealthCheck(tokio::spawn(async move {
  97. let url = format!("http://{hostname}:{port}");
  98. let timestamp = Instant::now();
  99. let ready = async {
  100. loop {
  101. tokio::time::sleep(Duration::from_millis(100)).await;
  102. if check_health(&url, Some(&password)).await {
  103. tracing::info!(elapsed = ?timestamp.elapsed(), "Server ready");
  104. return Ok(());
  105. }
  106. }
  107. };
  108. let terminated = async {
  109. match exit.await {
  110. Ok(payload) => Err(format!(
  111. "Sidecar terminated before becoming healthy (code={:?} signal={:?})",
  112. payload.code, payload.signal
  113. )),
  114. Err(_) => Err("Sidecar terminated before becoming healthy".to_string()),
  115. }
  116. };
  117. tokio::select! {
  118. res = ready => res,
  119. res = terminated => res,
  120. }
  121. }));
  122. (child, health_check)
  123. }
  124. pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
  125. pub async fn check_health(url: &str, password: Option<&str>) -> bool {
  126. let Ok(url) = reqwest::Url::parse(url) else {
  127. return false;
  128. };
  129. let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
  130. if url_is_localhost(&url) {
  131. // Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
  132. // excluding loopback. reqwest respects these by default, which can prevent the desktop
  133. // app from reaching its own local sidecar server.
  134. builder = builder.no_proxy();
  135. };
  136. let Ok(client) = builder.build() else {
  137. return false;
  138. };
  139. let Ok(health_url) = url.join("/global/health") else {
  140. return false;
  141. };
  142. let mut req = client.get(health_url);
  143. if let Some(password) = password {
  144. req = req.basic_auth("opencode", Some(password));
  145. }
  146. req.send()
  147. .await
  148. .map(|r| r.status().is_success())
  149. .unwrap_or(false)
  150. }
  151. fn url_is_localhost(url: &reqwest::Url) -> bool {
  152. url.host_str().is_some_and(|host| {
  153. host.eq_ignore_ascii_case("localhost")
  154. || host
  155. .parse::<std::net::IpAddr>()
  156. .is_ok_and(|ip| ip.is_loopback())
  157. })
  158. }
  159. /// Converts a bind address hostname to a valid URL hostname for connection.
  160. /// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
  161. /// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
  162. fn normalize_hostname_for_url(hostname: &str) -> String {
  163. // Wildcard bind addresses -> localhost equivalents
  164. if hostname == "0.0.0.0" {
  165. return "127.0.0.1".to_string();
  166. }
  167. if hostname == "::" {
  168. return "[::1]".to_string();
  169. }
  170. // IPv6 addresses need brackets in URLs
  171. if hostname.contains(':') && !hostname.starts_with('[') {
  172. return format!("[{}]", hostname);
  173. }
  174. hostname.to_string()
  175. }
  176. fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
  177. let server = config.server.as_ref()?;
  178. let port = server.port?;
  179. tracing::debug!(port, "server.port found in OC config");
  180. let hostname = server
  181. .hostname
  182. .as_ref()
  183. .map(|v| normalize_hostname_for_url(v))
  184. .unwrap_or_else(|| "127.0.0.1".to_string());
  185. Some(format!("http://{}:{}", hostname, port))
  186. }
  187. pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
  188. tracing::debug!(%url, "Checking health");
  189. loop {
  190. if check_health(url, None).await {
  191. return true;
  192. }
  193. const RETRY: &str = "Retry";
  194. let res = app.dialog()
  195. .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
  196. .title("Connection Failed")
  197. .buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
  198. .blocking_show_with_result();
  199. match res {
  200. MessageDialogResult::Custom(name) if name == RETRY => {
  201. continue;
  202. }
  203. _ => {
  204. break;
  205. }
  206. }
  207. }
  208. false
  209. }