server.rs 7.0 KB

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