mod sidecar; use tauri::Manager; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::{Duration, Instant}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct BodySpec { mode: String, raw: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct HttpSpec { method: String, url: String, #[serde(default)] headers: HashMap, query: Option>, body: Option, timeout_ms: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct HttpResult { status: u16, headers: HashMap, body: String, time_ms: u128, size: usize, } #[derive(Debug, Deserialize)] struct WsSpec { url: String, protocols: Option>, headers: Option>, } fn validate_http_url(raw: &str) -> Result { let parsed = url::Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?; match parsed.scheme() { "http" | "https" => {} _ => return Err("only http/https schemes are allowed".into()), } match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() { "169.254.169.254" | "metadata.google.internal" => { return Err("blocked metadata host".into()) } _ => {} } Ok(parsed) } #[tauri::command] async fn http_send(spec: HttpSpec) -> Result { let mut url = validate_http_url(&spec.url)?; if let Some(query) = &spec.query { let mut pairs = url.query_pairs_mut(); for (key, value) in query { pairs.append_pair(key, value); } } let method = reqwest::Method::from_bytes(spec.method.as_bytes()) .map_err(|e| format!("invalid method: {e}"))?; let timeout = Duration::from_millis(spec.timeout_ms.unwrap_or(30_000)); let client = reqwest::Client::builder() .timeout(timeout) .build() .map_err(|e| e.to_string())?; let mut request = client.request(method, url); for (key, value) in spec.headers { request = request.header(&key, &value); } if let Some(body) = spec.body { if body.mode != "none" { request = request.body(body.raw.unwrap_or_default()); } } let started = Instant::now(); let response = request.send().await.map_err(|e| e.to_string())?; let status = response.status().as_u16(); let headers = response .headers() .iter() .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); let bytes = response.bytes().await.map_err(|e| e.to_string())?; let size = bytes.len(); let body = String::from_utf8_lossy(&bytes).into_owned(); Ok(HttpResult { status, headers, body, time_ms: started.elapsed().as_millis(), size, }) } #[tauri::command] async fn ws_connect(spec: WsSpec) -> Result { let _ = (&spec.protocols, &spec.headers); // TODO(week2): hold WebSocket sessions in managed state and emit messages. Err(format!( "WebSocket native tunnel is not implemented yet for {} (use browser WS in MVP)", spec.url )) } #[tauri::command] async fn ws_send(session_id: String, data: String) -> Result<(), String> { let _ = (session_id, data); Err("WebSocket native send is a Week 2 stub".into()) } #[tauri::command] async fn ws_close(session_id: String) -> Result<(), String> { let _ = session_id; Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(sidecar::SidecarState::default()) .setup(|app| { let state = app.state::(); sidecar::ensure_backend(app.handle(), &state); Ok(()) }) .invoke_handler(tauri::generate_handler![ http_send, ws_connect, ws_send, ws_close ]) .build(tauri::generate_context!()) .expect("error while building OpsxDB desktop") .run(|app, event| { let state = app.state::(); sidecar::on_run_event(app, &event, &state); }); }