From 63b44583b6d2ba64d7e6cdb45d7b9195e3d40c66 Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 20 Feb 2026 23:29:40 -0800 Subject: [PATCH 01/10] refactor(tauri): split lib.rs and harden oauth parsing --- src-tauri/Cargo.lock | 6 + src-tauri/Cargo.toml | 7 +- src-tauri/src/http_client.rs | 208 +++++++++ src-tauri/src/lib.rs | 361 +-------------- src-tauri/src/models.rs | 88 ++++ src-tauri/src/network_utils.rs | 37 ++ src-tauri/src/oauth.rs | 784 +++++++++++++++++++++++++++++++++ src-tauri/src/streaming.rs | 205 +++++++++ 8 files changed, 1355 insertions(+), 341 deletions(-) create mode 100644 src-tauri/src/http_client.rs create mode 100644 src-tauri/src/models.rs create mode 100644 src-tauri/src/network_utils.rs create mode 100644 src-tauri/src/oauth.rs create mode 100644 src-tauri/src/streaming.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f41d62b..0fa1293 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2178,15 +2178,19 @@ name = "litepost" version = "0.2.0" dependencies = [ "base64 0.21.7", + "futures-util", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-opener", "tauri-plugin-updater", + "tokio", ] [[package]] @@ -3348,10 +3352,12 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "winreg 0.50.0", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 200545b..215770e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,9 +24,12 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-http = "2" tauri-plugin-fs = "2" -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.11", features = ["json", "stream"] } base64 = "0.21" +futures-util = "0.3" +tokio = { version = "1", features = ["sync", "net", "time", "macros", "io-util"] } +sha2 = "0.10" +rand = "0.8" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" - diff --git a/src-tauri/src/http_client.rs b/src-tauri/src/http_client.rs new file mode 100644 index 0000000..20311f4 --- /dev/null +++ b/src-tauri/src/http_client.rs @@ -0,0 +1,208 @@ +use base64::engine::general_purpose; +use base64::Engine as _; +use std::collections::HashMap; +use std::str::FromStr; +use tauri::http::method::Method; +use tauri::Url; + +use crate::models::{ + ClientWrapper, RedirectInfo, RequestOptions, ResponseData, ResponseSize, ResponseTiming, +}; +use crate::network_utils::{build_request_headers, now_millis}; + +fn is_binary_content_type(content_type: Option<&String>) -> bool { + let Some(content_type) = content_type else { + return false; + }; + + let ct = content_type.to_ascii_lowercase(); + (ct.starts_with("image/") && !ct.starts_with("image/svg")) + || ct.starts_with("application/octet-stream") + || ct.starts_with("application/pdf") + || ct.starts_with("application/zip") + || ct.starts_with("application/gzip") + || ct.starts_with("application/x-tar") + || ct.starts_with("application/x-gzip") + || ct.starts_with("application/x-bzip2") + || ct.starts_with("application/x-7z-compressed") + || ct.starts_with("application/vnd.ms-") + || ct.starts_with("application/vnd.openxmlformats-") + || ct.starts_with("application/wasm") + || ct.starts_with("font/") + || ct.starts_with("audio/") + || ct.starts_with("video/") +} + +#[tauri::command] +pub async fn send_request( + options: RequestOptions, + client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let client = &client_wrapper.client; + let cookie_jar = &client_wrapper.cookie_jar; + let start_time = now_millis(); + + let request_url = Url::parse(&options.url).map_err(|e| e.to_string())?; + for cookie in &options.cookies { + let cookie_str = format!("{}={}", cookie.name, cookie.value); + cookie_jar.add_cookie_str(&cookie_str, &request_url); + } + + let headers = build_request_headers(&options)?; + let method = Method::from_str(&options.method).map_err(|e| e.to_string())?; + let original_body = options.body.clone(); + + let mut current_url = options.url; + let mut redirect_chain = Vec::new(); + let mut response = None; + + for redirect_index in 0..10 { + let mut request = client + .request(method.clone(), ¤t_url) + .headers(headers.clone()); + + if let Some(body) = &original_body { + request = request.body(body.clone()); + } + + let request_start = now_millis(); + let resp = request.send().await.map_err(|e| e.to_string())?; + let first_byte_time = now_millis(); + + let status = resp.status(); + let resp_headers: HashMap = resp + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default().to_string())) + .collect(); + + let headers_size = resp_headers + .iter() + .map(|(k, v)| k.len() + v.len() + 4) + .sum(); + + if status.is_redirection() { + if let Some(location) = resp.headers().get("location") { + let location = location.to_str().map_err(|e| e.to_string())?; + let next_url = Url::parse(¤t_url) + .map_err(|e| e.to_string())? + .join(location) + .map_err(|e| e.to_string())? + .to_string(); + + let redirect_cookies: Vec = resp + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|h| h.to_str().ok()) + .map(String::from) + .collect(); + + let end_time = now_millis(); + redirect_chain.push(RedirectInfo { + url: current_url.clone(), + status: status.as_u16(), + status_text: status.to_string(), + headers: resp_headers, + cookies: redirect_cookies, + timing: Some(ResponseTiming { + start: request_start, + end: end_time, + duration: end_time - request_start, + dns: None, + tcp: None, + tls: None, + request: None, + first_byte: Some(first_byte_time - request_start), + download: Some(end_time - first_byte_time), + total: end_time - request_start, + }), + size: Some(ResponseSize { + headers: headers_size, + body: 0, + total: headers_size, + }), + }); + + if redirect_index == 9 { + return Err( + "Maximum redirect limit (10) exceeded. The server might be in a redirect loop." + .to_string(), + ); + } + + current_url = next_url; + continue; + } + } + + response = Some((resp, request_start, first_byte_time)); + break; + } + + let (final_response, request_start, first_byte_time) = + response.ok_or_else(|| "No response received".to_string())?; + + let status = final_response.status(); + let headers: HashMap = final_response + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default().to_string())) + .collect(); + + let headers_size = headers.iter().map(|(k, v)| k.len() + v.len() + 4).sum(); + + let mut all_cookies = Vec::new(); + for redirect in &redirect_chain { + all_cookies.extend(redirect.cookies.clone()); + } + all_cookies.extend( + final_response + .headers() + .get_all("set-cookie") + .iter() + .filter_map(|h| h.to_str().ok()) + .map(String::from), + ); + + let is_binary = is_binary_content_type(headers.get("content-type")); + + let (body, body_size, is_base64) = if is_binary { + let bytes = final_response.bytes().await.map_err(|e| e.to_string())?; + let size = bytes.len(); + (general_purpose::STANDARD.encode(bytes), size, true) + } else { + let text = final_response.text().await.map_err(|e| e.to_string())?; + let size = text.len(); + (text, size, false) + }; + + let end_time = now_millis(); + + Ok(ResponseData { + status: status.as_u16(), + status_text: status.to_string(), + headers, + body, + is_base64, + redirect_chain, + cookies: all_cookies, + timing: Some(ResponseTiming { + start: start_time, + end: end_time, + duration: end_time - start_time, + dns: None, + tcp: None, + tls: None, + request: None, + first_byte: Some(first_byte_time - request_start), + download: Some(end_time - first_byte_time), + total: end_time - start_time, + }), + size: Some(ResponseSize { + headers: headers_size, + body: body_size, + total: headers_size + body_size, + }), + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1fe9719..c437a91 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,341 +1,14 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -use base64; -use base64::engine::general_purpose; -use base64::Engine as _; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::str::FromStr; -use std::sync::Arc; -use tauri::http::method::Method; -use tauri::Url; -use tauri_plugin_http::reqwest::{ - self, - header::{HeaderMap, HeaderName, HeaderValue}, - Client, -}; - -#[derive(Debug, Serialize, Deserialize)] -struct RequestOptions { - method: String, - url: String, - headers: HashMap, - body: Option, - content_type: Option, - cookies: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -struct Cookie { - name: String, - value: String, - domain: Option, - path: Option, - expires: Option, - secure: Option, - http_only: Option, -} - -#[derive(Debug, Serialize)] -struct ResponseTiming { - start: u128, - end: u128, - duration: u128, - dns: Option, - tcp: Option, - tls: Option, - request: Option, - first_byte: Option, - download: Option, - total: u128, -} - -#[derive(Debug, Serialize)] -struct ResponseSize { - headers: usize, - body: usize, - total: usize, -} - -#[derive(Debug, Serialize)] -struct RedirectInfo { - url: String, - status: u16, - status_text: String, - headers: HashMap, - cookies: Vec, - timing: Option, - size: Option, -} - -#[derive(Debug, Serialize)] -struct ResponseData { - status: u16, - status_text: String, - headers: HashMap, - body: String, - is_base64: bool, - redirect_chain: Vec, - cookies: Vec, - timing: Option, - size: Option, -} - -// Add new struct for application state -struct AppState { - client: Client, -} - -#[derive(Clone)] -struct ClientWrapper { - client: Client, - cookie_jar: Arc, -} - -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} - -#[tauri::command] -async fn send_request( - options: RequestOptions, - client_wrapper: tauri::State<'_, ClientWrapper>, -) -> Result { - let client = &client_wrapper.client; - let cookie_jar = &client_wrapper.cookie_jar; - let start_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - // Add UI cookies to the cookie store - let url = Url::parse(&options.url).map_err(|e| e.to_string())?; - for cookie in &options.cookies { - let cookie_str = format!("{}={}", cookie.name, cookie.value); - cookie_jar.add_cookie_str(&cookie_str, &url); - } - - let mut headers = HeaderMap::new(); - for (key, value) in options.headers { - // Skip Cookie header as we're handling cookies separately - if key.to_lowercase() != "cookie" { - headers.insert( - HeaderName::from_str(&key).map_err(|e| e.to_string())?, - HeaderValue::from_str(&value).map_err(|e| e.to_string())?, - ); - } - } - - // Add content type header if body is present - if options.body.is_some() && !headers.contains_key(HeaderName::from_static("content-type")) { - if let Some(content_type) = options.content_type { - headers.insert( - HeaderName::from_static("content-type"), - HeaderValue::from_str(&content_type).map_err(|e| e.to_string())?, - ); - } - } - - let mut request = client - .request( - Method::from_str(&options.method).map_err(|e| e.to_string())?, - &options.url, - ) - .headers(headers); - - if let Some(body) = options.body { - request = request.body(body); - } +mod http_client; +mod models; +mod network_utils; +mod oauth; +mod streaming; - let mut current_url = options.url; - let mut redirect_chain = Vec::new(); - let mut response = None; - - for i in 0..10 { - // Max 10 redirects - let dns_start = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - let resp = request - .try_clone() - .ok_or_else(|| "Failed to clone request".to_string())? - .send() - .await - .map_err(|e| e.to_string())?; - - let first_byte_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - let status = resp.status(); - let headers: HashMap = resp - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - // Calculate headers size - let headers_size = headers - .iter() - .map(|(k, v)| k.len() + v.len() + 4) // +4 for ": " and "\r\n" - .sum(); - - if status.is_redirection() { - if let Some(location) = resp.headers().get("location") { - let location = location.to_str().map_err(|e| e.to_string())?; - let next_url = Url::parse(¤t_url) - .map_err(|e| e.to_string())? - .join(location) - .map_err(|e| e.to_string())? - .to_string(); - - let redirect_cookies: Vec = resp - .headers() - .get_all("set-cookie") - .iter() - .filter_map(|h| h.to_str().ok()) - .map(String::from) - .collect(); - - let end_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); - - redirect_chain.push(RedirectInfo { - url: current_url.clone(), - status: status.as_u16(), - status_text: status.to_string(), - headers: headers.clone(), - cookies: redirect_cookies, - timing: Some(ResponseTiming { - start: dns_start, - end: end_time, - duration: end_time - dns_start, - dns: Some(first_byte_time - dns_start), - tcp: None, - tls: None, - request: None, - first_byte: Some(first_byte_time - dns_start), - download: Some(end_time - first_byte_time), - total: end_time - dns_start, - }), - size: Some(ResponseSize { - headers: headers_size, - body: 0, // Redirects don't typically have bodies - total: headers_size, - }), - }); - - if i == 9 { - return Err("Maximum redirect limit (10) exceeded. The server might be in a redirect loop.".to_string()); - } - - current_url = next_url; - request = client.request( - Method::from_str(&options.method).map_err(|e| e.to_string())?, - ¤t_url, - ); - continue; - } - } - - response = Some((resp, dns_start, first_byte_time)); - break; - } - - let (final_response, dns_start, first_byte_time) = - response.ok_or_else(|| "No response received".to_string())?; - let status = final_response.status(); - let headers: HashMap = final_response - .headers() - .iter() - .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) - .collect(); - - // Calculate headers size - let headers_size = headers - .iter() - .map(|(k, v)| k.len() + v.len() + 4) // +4 for ": " and "\r\n" - .sum(); - - let mut all_cookies = Vec::new(); - - // Collect cookies from redirect chain - for redirect in &redirect_chain { - all_cookies.extend(redirect.cookies.clone()); - } - - // Add cookies from final response - all_cookies.extend( - final_response - .headers() - .get_all("set-cookie") - .iter() - .filter_map(|h| h.to_str().ok()) - .map(String::from), - ); - - // Check content type to determine if response is binary - let content_type = headers.get("content-type").map(|s| s.to_lowercase()); - let is_binary = content_type.as_ref().map_or(false, |ct| { - (ct.starts_with("image/") && !ct.starts_with("image/svg")) || // SVG is text-based XML - ct.starts_with("application/octet-stream") || - ct.starts_with("audio/") || - ct.starts_with("video/") - }); - - let (body, body_size, is_base64) = if is_binary { - // For binary data, get bytes and base64 encode - let bytes = final_response.bytes().await.map_err(|e| e.to_string())?; - let size = bytes.len(); - ( - base64::engine::general_purpose::STANDARD.encode(bytes), - size, - true, - ) - } else { - // For text data (including SVG), get as string - let text = final_response.text().await.map_err(|e| e.to_string())?; - let size = text.len(); - (text, size, false) - }; - - let end_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis(); +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tauri_plugin_http::reqwest::{self, Client}; - Ok(ResponseData { - status: status.as_u16(), - status_text: status.to_string(), - headers, - body, - is_base64, - redirect_chain, - cookies: all_cookies, - timing: Some(ResponseTiming { - start: start_time, - end: end_time, - duration: end_time - start_time, - dns: Some(first_byte_time - dns_start), - tcp: None, - tls: None, - request: None, - first_byte: Some(first_byte_time - dns_start), - download: Some(end_time - first_byte_time), - total: end_time - start_time, - }), - size: Some(ResponseSize { - headers: headers_size, - body: body_size, - total: headers_size + body_size, - }), - }) -} +use models::{ActiveStreams, ClientWrapper}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -343,10 +16,15 @@ pub fn run() { let client = Client::builder() .redirect(reqwest::redirect::Policy::none()) .cookie_provider(Arc::clone(&cookie_jar)) + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(10)) .build() - .unwrap(); + .expect("Failed to build HTTP client"); let client_wrapper = ClientWrapper { client, cookie_jar }; + let active_streams = ActiveStreams { + streams: Mutex::new(HashMap::new()), + }; tauri::Builder::default() .plugin(tauri_plugin_updater::Builder::new().build()) @@ -354,9 +32,14 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) .manage(client_wrapper) + .manage(active_streams) .invoke_handler(tauri::generate_handler![ - greet, - send_request, + http_client::send_request, + streaming::stream_sse, + streaming::cancel_stream, + oauth::oauth2_token_exchange, + oauth::oauth2_auth_code_flow, + oauth::oauth2_refresh, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs new file mode 100644 index 0000000..fa5ebc2 --- /dev/null +++ b/src-tauri/src/models.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tauri_plugin_http::reqwest::{self, Client}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RequestOptions { + pub method: String, + pub url: String, + pub headers: HashMap, + pub body: Option, + pub content_type: Option, + pub cookies: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Cookie { + pub name: String, + pub value: String, + pub domain: Option, + pub path: Option, + pub expires: Option, + pub secure: Option, + pub http_only: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ResponseTiming { + pub start: u128, + pub end: u128, + pub duration: u128, + pub dns: Option, + pub tcp: Option, + pub tls: Option, + pub request: Option, + pub first_byte: Option, + pub download: Option, + pub total: u128, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ResponseSize { + pub headers: usize, + pub body: usize, + pub total: usize, +} + +#[derive(Debug, Serialize, Clone)] +pub struct RedirectInfo { + pub url: String, + pub status: u16, + pub status_text: String, + pub headers: HashMap, + pub cookies: Vec, + pub timing: Option, + pub size: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ResponseData { + pub status: u16, + pub status_text: String, + pub headers: HashMap, + pub body: String, + pub is_base64: bool, + pub redirect_chain: Vec, + pub cookies: Vec, + pub timing: Option, + pub size: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StreamChunk { + pub id: Option, + pub event: Option, + pub data: String, + pub is_done: bool, +} + +#[derive(Clone)] +pub struct ClientWrapper { + pub client: Client, + pub cookie_jar: Arc, +} + +pub struct ActiveStreams { + pub streams: Mutex>>, +} diff --git a/src-tauri/src/network_utils.rs b/src-tauri/src/network_utils.rs new file mode 100644 index 0000000..9d8e15d --- /dev/null +++ b/src-tauri/src/network_utils.rs @@ -0,0 +1,37 @@ +use std::str::FromStr; +use tauri_plugin_http::reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + +use crate::models::RequestOptions; + +pub fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +pub fn build_request_headers(options: &RequestOptions) -> Result { + let mut headers = HeaderMap::new(); + + for (key, value) in &options.headers { + if key.eq_ignore_ascii_case("cookie") { + continue; + } + + headers.insert( + HeaderName::from_str(key).map_err(|e| e.to_string())?, + HeaderValue::from_str(value).map_err(|e| e.to_string())?, + ); + } + + if options.body.is_some() && !headers.contains_key(HeaderName::from_static("content-type")) { + if let Some(content_type) = options.content_type.as_deref() { + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_str(content_type).map_err(|e| e.to_string())?, + ); + } + } + + Ok(headers) +} diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs new file mode 100644 index 0000000..fc0cbab --- /dev/null +++ b/src-tauri/src/oauth.rs @@ -0,0 +1,784 @@ +use base64::engine::general_purpose; +use base64::Engine as _; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use tauri::Url; +use tauri_plugin_http::reqwest; +use tauri_plugin_opener::OpenerExt; + +use crate::models::ClientWrapper; + +const OAUTH_TOKEN_ACCEPT_HEADER: &str = + "application/json, application/x-www-form-urlencoded, text/plain"; +const TOKEN_CONTAINER_KEYS: &[&str] = &["data", "token", "result", "response"]; + +#[derive(Debug, Deserialize)] +pub struct OAuth2TokenExchangeOptions { + token_url: String, + grant_type: String, + client_id: String, + client_secret: Option, + scope: Option, + username: Option, + password: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct OAuth2TokenResponse { + access_token: String, + token_type: Option, + expires_in: Option, + refresh_token: Option, + scope: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OAuth2AuthCodeOptions { + auth_url: String, + token_url: String, + client_id: String, + client_secret: Option, + scope: Option, + use_pkce: Option, + redirect_uri: Option, +} + +#[derive(Debug, Deserialize)] +pub struct OAuth2RefreshOptions { + token_url: String, + client_id: String, + client_secret: Option, + refresh_token: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokenBodyFormat { + Json, + Form, +} + +fn required_field(value: String, field_name: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{} is required", field_name)); + } + Ok(trimmed.to_string()) +} + +fn normalize_optional_input(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn insert_optional_param(params: &mut HashMap, key: &str, value: Option) { + if let Some(value) = normalize_optional_input(value) { + params.insert(key.to_string(), value); + } +} + +fn oauth_body_preview(body: &str, max_chars: usize) -> String { + let trimmed = body.trim(); + let mut preview = trimmed.chars().take(max_chars).collect::(); + if trimmed.chars().count() > max_chars { + preview.push_str("..."); + } + preview +} + +fn lookup_value<'a>(map: &'a Map, keys: &[&str]) -> Option<&'a Value> { + for key in keys { + if let Some(value) = map.get(*key) { + return Some(value); + } + } + + for container_key in TOKEN_CONTAINER_KEYS { + if let Some(Value::Object(inner)) = map.get(*container_key) { + for key in keys { + if let Some(value) = inner.get(*key) { + return Some(value); + } + } + } + } + + None +} + +fn value_as_non_empty_string(value: &Value) -> Option { + match value { + Value::String(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + _ => None, + } +} + +fn parse_required_string_field( + map: &Map, + aliases: &[&str], + field_name: &str, +) -> Result { + let value = lookup_value(map, aliases).ok_or_else(|| format!("missing {}", field_name))?; + value_as_non_empty_string(value) + .ok_or_else(|| format!("{} must be a non-empty string", field_name)) +} + +fn parse_optional_string_field( + map: &Map, + aliases: &[&str], + field_name: &str, +) -> Result, String> { + match lookup_value(map, aliases) { + None | Some(Value::Null) => Ok(None), + Some(value) => { + let parsed = value_as_non_empty_string(value) + .ok_or_else(|| format!("{} must be a string", field_name))?; + Ok(Some(parsed)) + } + } +} + +fn parse_optional_expires_in(map: &Map) -> Result, String> { + match lookup_value(map, &["expires_in", "expiresIn"]) { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(value)) => { + if let Some(as_u64) = value.as_u64() { + return Ok(Some(as_u64)); + } + if let Some(as_f64) = value.as_f64() { + if as_f64.is_finite() && as_f64 >= 0.0 && as_f64.fract() == 0.0 { + return Ok(Some(as_f64 as u64)); + } + } + Err("expires_in must be a whole non-negative number".to_string()) + } + Some(Value::String(value)) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let parsed = trimmed + .parse::() + .map_err(|_| "expires_in must be a whole non-negative number".to_string())?; + Ok(Some(parsed)) + } + Some(_) => Err("expires_in must be a number or string".to_string()), + } +} + +fn extract_oauth_error(map: &Map) -> Option { + let error = lookup_value(map, &["error", "error_code"]).and_then(value_as_non_empty_string)?; + let description = lookup_value( + map, + &[ + "error_description", + "errorDescription", + "error_message", + "message", + ], + ) + .and_then(value_as_non_empty_string); + let error_uri = + lookup_value(map, &["error_uri", "errorUri"]).and_then(value_as_non_empty_string); + + let mut message = error; + if let Some(description) = description { + message.push_str(": "); + message.push_str(&description); + } + if let Some(error_uri) = error_uri { + message.push_str(" ("); + message.push_str(&error_uri); + message.push(')'); + } + + Some(message) +} + +fn map_to_token_response(map: &Map) -> Result { + let access_token_aliases = &["access_token", "accessToken"]; + let has_access_token = lookup_value(map, access_token_aliases).is_some(); + + if !has_access_token { + if let Some(provider_error) = extract_oauth_error(map) { + return Err(format!("OAuth provider error: {}", provider_error)); + } + } + + Ok(OAuth2TokenResponse { + access_token: parse_required_string_field(map, access_token_aliases, "access_token")?, + token_type: parse_optional_string_field(map, &["token_type", "tokenType"], "token_type")?, + expires_in: parse_optional_expires_in(map)?, + refresh_token: parse_optional_string_field( + map, + &["refresh_token", "refreshToken"], + "refresh_token", + )?, + scope: parse_optional_string_field(map, &["scope"], "scope")?, + }) +} + +fn parse_json_map(body: &str) -> Result, String> { + let value: Value = + serde_json::from_str(body).map_err(|e| format!("JSON parse error: {}", e))?; + + match value { + Value::Object(map) => Ok(map), + _ => Err("JSON token response must be an object".to_string()), + } +} + +fn parse_form_map(body: &str) -> Result, String> { + let form = body.trim_start_matches('?'); + if form.is_empty() { + return Err("form-encoded response body is empty".to_string()); + } + + let fake_url = format!("http://localhost/?{}", form); + let url = Url::parse(&fake_url).map_err(|e| format!("form-encoded parse error: {}", e))?; + + let mut values = Map::new(); + for (key, value) in url.query_pairs() { + values.insert(key.into_owned(), Value::String(value.into_owned())); + } + + if values.is_empty() { + return Err("no form fields found".to_string()); + } + + Ok(values) +} + +fn looks_like_json(body: &str) -> bool { + body.starts_with('{') || body.starts_with('[') +} + +fn looks_like_form(body: &str) -> bool { + body.contains('=') && !looks_like_json(body) +} + +fn detect_parse_order(trimmed_body: &str, content_type: &str) -> Vec { + let mut order = Vec::new(); + + if content_type.contains("json") { + order.push(TokenBodyFormat::Json); + order.push(TokenBodyFormat::Form); + } else if content_type.contains("x-www-form-urlencoded") + || content_type.contains("form-urlencoded") + { + order.push(TokenBodyFormat::Form); + order.push(TokenBodyFormat::Json); + } else if looks_like_json(trimmed_body) { + order.push(TokenBodyFormat::Json); + order.push(TokenBodyFormat::Form); + } else if looks_like_form(trimmed_body) { + order.push(TokenBodyFormat::Form); + order.push(TokenBodyFormat::Json); + } else { + order.push(TokenBodyFormat::Json); + order.push(TokenBodyFormat::Form); + } + + order +} + +fn parse_oauth_token_body(body: &str, content_type: &str) -> Result { + let trimmed = body.trim(); + if trimmed.is_empty() { + return Err("token response body is empty".to_string()); + } + + let mut errors = Vec::new(); + + for format in detect_parse_order(trimmed, content_type) { + let result = match format { + TokenBodyFormat::Json => { + parse_json_map(trimmed).and_then(|map| map_to_token_response(&map)) + } + TokenBodyFormat::Form => { + parse_form_map(trimmed).and_then(|map| map_to_token_response(&map)) + } + }; + + match result { + Ok(token) => return Ok(token), + Err(error) => errors.push(error), + } + } + + Err(errors.join("; ")) +} + +fn parse_oauth_error_body(body: &str, content_type: &str) -> Option { + let trimmed = body.trim(); + if trimmed.is_empty() { + return None; + } + + for format in detect_parse_order(trimmed, content_type) { + let parsed = match format { + TokenBodyFormat::Json => parse_json_map(trimmed), + TokenBodyFormat::Form => parse_form_map(trimmed), + }; + + if let Ok(map) = parsed { + if let Some(error) = extract_oauth_error(&map) { + return Some(error); + } + } + } + + None +} + +async fn parse_oauth_token_response(res: reqwest::Response) -> Result { + let content_type = res + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + + let body = res + .text() + .await + .map_err(|e| format!("Failed to read token response body: {}", e))?; + + parse_oauth_token_body(&body, &content_type).map_err(|error| { + let content_type_display = if content_type.is_empty() { + "" + } else { + &content_type + }; + format!( + "Failed to parse token response: {} (content-type: {}, body preview: {})", + error, + content_type_display, + oauth_body_preview(&body, 300) + ) + }) +} + +async fn oauth_http_error(prefix: &str, res: reqwest::Response) -> String { + let status = res.status(); + let content_type = res + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + + let body = res.text().await.unwrap_or_default(); + if let Some(error) = parse_oauth_error_body(&body, &content_type) { + return format!("{} ({}): {}", prefix, status, error); + } + + let preview = oauth_body_preview(&body, 300); + if preview.is_empty() { + format!("{} ({})", prefix, status) + } else { + format!("{} ({}): {}", prefix, status, preview) + } +} + +#[tauri::command] +pub async fn oauth2_token_exchange( + options: OAuth2TokenExchangeOptions, + client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let token_url = required_field(options.token_url, "token_url")?; + let grant_type = required_field(options.grant_type, "grant_type")?.to_ascii_lowercase(); + let client_id = required_field(options.client_id, "client_id")?; + + if grant_type != "client_credentials" && grant_type != "password" { + return Err(format!( + "Unsupported grant_type: {}. Supported values: client_credentials, password", + grant_type + )); + } + + let mut params = HashMap::new(); + params.insert("grant_type".to_string(), grant_type.clone()); + params.insert("client_id".to_string(), client_id); + + insert_optional_param(&mut params, "client_secret", options.client_secret); + insert_optional_param(&mut params, "scope", options.scope); + + if grant_type == "password" { + let username = normalize_optional_input(options.username) + .ok_or_else(|| "username is required for password grant".to_string())?; + let password = normalize_optional_input(options.password) + .ok_or_else(|| "password is required for password grant".to_string())?; + + params.insert("username".to_string(), username); + params.insert("password".to_string(), password); + } + + let client = &client_wrapper.client; + let res = client + .post(&token_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Token request failed: {}", e))?; + + if !res.status().is_success() { + return Err(oauth_http_error("Token request failed", res).await); + } + + parse_oauth_token_response(res).await +} + +#[tauri::command] +pub async fn oauth2_auth_code_flow( + options: OAuth2AuthCodeOptions, + app: tauri::AppHandle, + client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let auth_url = required_field(options.auth_url, "auth_url")?; + let token_url = required_field(options.token_url, "token_url")?; + let client_id = required_field(options.client_id, "client_id")?; + + let custom_redirect_uri = normalize_optional_input(options.redirect_uri); + let (listener, redirect_uri) = if let Some(custom_uri) = custom_redirect_uri { + let url = Url::parse(&custom_uri).map_err(|e| format!("Invalid redirect URI: {}", e))?; + + if url.scheme() != "http" && url.scheme() != "https" { + return Err("Redirect URI must use http or https".to_string()); + } + + let host = url + .host_str() + .ok_or_else(|| "Redirect URI must include a host".to_string())?; + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + return Err( + "Redirect URI host must be localhost, 127.0.0.1, or ::1 for local callback" + .to_string(), + ); + } + + let port = url + .port() + .ok_or_else(|| "Redirect URI must include an explicit port".to_string())?; + + let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port)) + .await + .map_err(|e| format!("Failed to bind callback server on port {}: {}", port, e))?; + + (listener, custom_uri) + } else { + let listener = tokio::net::TcpListener::bind("127.0.0.1:17823") + .await + .map_err(|e| format!("Failed to bind callback server on port 17823: {}", e))?; + (listener, "http://localhost:17823/callback".to_string()) + }; + + let pkce = if options.use_pkce.unwrap_or(false) { + Some(generate_pkce()) + } else { + None + }; + + let state = generate_state(); + + let mut auth_uri = Url::parse(&auth_url).map_err(|e| format!("Invalid auth URL: {}", e))?; + auth_uri + .query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("state", &state); + + if let Some(scope) = normalize_optional_input(options.scope) { + auth_uri.query_pairs_mut().append_pair("scope", &scope); + } + + if let Some((_, code_challenge)) = &pkce { + auth_uri + .query_pairs_mut() + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + } + + app.opener() + .open_url(auth_uri.as_str(), None::<&str>) + .map_err(|e| format!("Failed to open browser: {}", e))?; + + let (code, received_state) = tokio::time::timeout( + std::time::Duration::from_secs(120), + wait_for_callback(listener), + ) + .await + .map_err(|_| "Authorization timed out after 2 minutes".to_string())? + .map_err(|e| format!("Callback error: {}", e))?; + + if received_state != state { + return Err("State mismatch - possible CSRF attack".to_string()); + } + + let mut params = HashMap::new(); + params.insert("grant_type".to_string(), "authorization_code".to_string()); + params.insert("code".to_string(), code); + params.insert("redirect_uri".to_string(), redirect_uri); + params.insert("client_id".to_string(), client_id); + + insert_optional_param(&mut params, "client_secret", options.client_secret); + + if let Some((code_verifier, _)) = pkce { + params.insert("code_verifier".to_string(), code_verifier); + } + + let client = &client_wrapper.client; + let res = client + .post(&token_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Token exchange failed: {}", e))?; + + if !res.status().is_success() { + return Err(oauth_http_error("Token exchange failed", res).await); + } + + parse_oauth_token_response(res).await +} + +async fn wait_for_callback(listener: tokio::net::TcpListener) -> Result<(String, String), String> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (mut stream, _) = listener + .accept() + .await + .map_err(|e| format!("Accept error: {}", e))?; + + let mut buf = Vec::with_capacity(4096); + loop { + let mut chunk = [0u8; 1024]; + let bytes_read = stream + .read(&mut chunk) + .await + .map_err(|e| format!("Read error: {}", e))?; + + if bytes_read == 0 { + break; + } + + buf.extend_from_slice(&chunk[..bytes_read]); + + if buf.len() >= 16 * 1024 || buf.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + if buf.is_empty() { + return Err("Empty callback request".to_string()); + } + + let request = String::from_utf8_lossy(&buf).to_string(); + let first_line = request + .lines() + .next() + .ok_or_else(|| "Empty request".to_string())?; + let path = first_line + .split_whitespace() + .nth(1) + .ok_or_else(|| "No path in request".to_string())?; + + let url = Url::parse(&format!("http://localhost{}", path)) + .map_err(|e| format!("Failed to parse callback URL: {}", e))?; + + let mut code = None; + let mut state = String::new(); + let mut error = None; + let mut error_description = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.to_string()), + "state" => state = value.to_string(), + "error" => error = Some(value.to_string()), + "error_description" => error_description = Some(value.to_string()), + _ => {} + } + } + + let html = if error.is_some() { + "

Authorization Failed

You can close this window.

" + } else { + "

Authorization Successful

You can close this window and return to LitePost.

" + }; + + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ); + let _ = stream.write_all(response.as_bytes()).await; + + if let Some(error) = error { + if let Some(description) = error_description { + return Err(format!("Authorization denied: {}: {}", error, description)); + } + return Err(format!("Authorization denied: {}", error)); + } + + let code = code.ok_or_else(|| "No authorization code received".to_string())?; + Ok((code, state)) +} + +fn generate_state() -> String { + let mut bytes = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// Generate PKCE code_verifier and code_challenge (S256). +/// Returns (code_verifier, code_challenge). +fn generate_pkce() -> (String, String) { + use sha2::{Digest, Sha256}; + + let mut bytes = [0u8; 64]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + + let code_verifier = general_purpose::URL_SAFE_NO_PAD.encode(bytes); + + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge = general_purpose::URL_SAFE_NO_PAD.encode(hasher.finalize()); + + (code_verifier, code_challenge) +} + +#[tauri::command] +pub async fn oauth2_refresh( + options: OAuth2RefreshOptions, + client_wrapper: tauri::State<'_, ClientWrapper>, +) -> Result { + let token_url = required_field(options.token_url, "token_url")?; + let client_id = required_field(options.client_id, "client_id")?; + let refresh_token = required_field(options.refresh_token, "refresh_token")?; + + let mut params = HashMap::new(); + params.insert("grant_type".to_string(), "refresh_token".to_string()); + params.insert("refresh_token".to_string(), refresh_token); + params.insert("client_id".to_string(), client_id); + + insert_optional_param(&mut params, "client_secret", options.client_secret); + + let client = &client_wrapper.client; + let res = client + .post(&token_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Refresh token request failed: {}", e))?; + + if !res.status().is_success() { + return Err(oauth_http_error("Refresh failed", res).await); + } + + parse_oauth_token_response(res).await +} + +#[cfg(test)] +mod oauth_parser_tests { + use super::*; + + #[test] + fn parses_json_token_response() { + let body = r#"{"access_token":"abc123","token_type":"bearer","expires_in":3600}"#; + let token = parse_oauth_token_body(body, "application/json").unwrap(); + + assert_eq!(token.access_token, "abc123"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + assert_eq!(token.expires_in, Some(3600)); + } + + #[test] + fn parses_form_encoded_token_response() { + let body = "access_token=xyz789&token_type=bearer&scope=repo%20user&expires_in=7200"; + let token = parse_oauth_token_body(body, "application/x-www-form-urlencoded").unwrap(); + + assert_eq!(token.access_token, "xyz789"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + assert_eq!(token.scope.as_deref(), Some("repo user")); + assert_eq!(token.expires_in, Some(7200)); + } + + #[test] + fn parses_json_with_string_expires_in() { + let body = r#"{"access_token":"token","expires_in":"1800"}"#; + let token = parse_oauth_token_body(body, "application/json").unwrap(); + + assert_eq!(token.access_token, "token"); + assert_eq!(token.expires_in, Some(1800)); + } + + #[test] + fn falls_back_to_json_when_content_type_is_text_plain() { + let body = r#"{"access_token":"from_text_plain","token_type":"bearer"}"#; + let token = parse_oauth_token_body(body, "text/plain").unwrap(); + + assert_eq!(token.access_token, "from_text_plain"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + } + + #[test] + fn falls_back_to_form_when_content_type_is_text_plain() { + let body = "access_token=from_text_plain_form&token_type=bearer"; + let token = parse_oauth_token_body(body, "text/plain").unwrap(); + + assert_eq!(token.access_token, "from_text_plain_form"); + assert_eq!(token.token_type.as_deref(), Some("bearer")); + } + + #[test] + fn parses_wrapped_camel_case_token_payload() { + let body = r#"{"data":{"accessToken":"wrapped","tokenType":"Bearer","expiresIn":"60"}}"#; + let token = parse_oauth_token_body(body, "application/json").unwrap(); + + assert_eq!(token.access_token, "wrapped"); + assert_eq!(token.token_type.as_deref(), Some("Bearer")); + assert_eq!(token.expires_in, Some(60)); + } + + #[test] + fn surfaces_json_provider_errors() { + let body = r#"{"error":"invalid_client","error_description":"Bad client secret"}"#; + let error = parse_oauth_token_body(body, "application/json").unwrap_err(); + + assert!(error.contains("invalid_client")); + assert!(error.contains("Bad client secret")); + } + + #[test] + fn surfaces_form_provider_errors() { + let body = "error=invalid_grant&error_description=Code+expired"; + let error = parse_oauth_token_body(body, "application/x-www-form-urlencoded").unwrap_err(); + + assert!(error.contains("invalid_grant")); + assert!(error.contains("Code expired")); + } +} diff --git a/src-tauri/src/streaming.rs b/src-tauri/src/streaming.rs new file mode 100644 index 0000000..1ede7d9 --- /dev/null +++ b/src-tauri/src/streaming.rs @@ -0,0 +1,205 @@ +use futures_util::StreamExt; +use std::collections::HashMap; +use std::str::FromStr; +use tauri::http::method::Method; +use tauri::Emitter; + +use crate::models::{ActiveStreams, ClientWrapper, RequestOptions, StreamChunk}; +use crate::network_utils::{build_request_headers, now_millis}; + +#[tauri::command] +pub async fn stream_sse( + options: RequestOptions, + request_id: String, + window: tauri::Window, + client_wrapper: tauri::State<'_, ClientWrapper>, + active_streams: tauri::State<'_, ActiveStreams>, +) -> Result<(), String> { + let client = &client_wrapper.client; + let start_time = now_millis(); + + let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); + { + let mut streams = active_streams + .streams + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + streams.insert(request_id.clone(), cancel_tx); + } + + let headers = build_request_headers(&options)?; + + let mut request = client + .request( + Method::from_str(&options.method).map_err(|e| e.to_string())?, + &options.url, + ) + .headers(headers) + .timeout(std::time::Duration::from_secs(300)); + + if let Some(body) = &options.body { + request = request.body(body.clone()); + } + + let header_event = format!("sse-headers-{}", request_id); + let chunk_event = format!("sse-chunk-{}", request_id); + let done_event = format!("sse-done-{}", request_id); + + let res = match request.send().await { + Ok(res) => res, + Err(e) => { + if let Ok(mut streams) = active_streams.streams.lock() { + streams.remove(&request_id); + } + let _ = window.emit( + &done_event, + serde_json::json!({ + "error": e.to_string(), + "cancelled": false, + "duration": now_millis() - start_time, + }), + ); + return Err(e.to_string()); + } + }; + + let status = res.status(); + let resp_headers: HashMap = res + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or_default().to_string())) + .collect(); + + window + .emit( + &header_event, + serde_json::json!({ + "status": status.as_u16(), + "statusText": status.to_string(), + "headers": resp_headers, + }), + ) + .map_err(|e| e.to_string())?; + + let mut stream = res.bytes_stream(); + let mut buffer = String::new(); + let mut current_event: Option = None; + let mut current_id: Option = None; + let mut current_data: Vec = Vec::new(); + let mut cancelled = false; + + loop { + tokio::select! { + _ = cancel_rx.changed() => { + if *cancel_rx.borrow() { + cancelled = true; + break; + } + } + chunk = stream.next() => { + match chunk { + Some(Ok(bytes)) => { + buffer.push_str(&String::from_utf8_lossy(&bytes)); + + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim_end_matches('\r').to_string(); + buffer = buffer[newline_pos + 1..].to_string(); + + if line.is_empty() { + if !current_data.is_empty() { + let data = current_data.join("\n"); + let _ = window.emit(&chunk_event, StreamChunk { + id: current_id.take(), + event: current_event.take(), + data, + is_done: false, + }); + current_data.clear(); + } + } else if let Some(data) = line.strip_prefix("data:") { + current_data.push(data.trim_start().to_string()); + } else if let Some(event) = line.strip_prefix("event:") { + current_event = Some(event.trim_start().to_string()); + } else if let Some(id) = line.strip_prefix("id:") { + current_id = Some(id.trim_start().to_string()); + } else if line.starts_with(':') { + // SSE comment line + } else { + let _ = window.emit(&chunk_event, StreamChunk { + id: None, + event: None, + data: line + "\n", + is_done: false, + }); + } + } + } + Some(Err(e)) => { + let _ = window.emit(&done_event, serde_json::json!({ + "error": e.to_string(), + "cancelled": false, + "duration": now_millis() - start_time, + })); + if let Ok(mut streams) = active_streams.streams.lock() { + streams.remove(&request_id); + } + return Err(e.to_string()); + } + None => { + if !current_data.is_empty() { + let data = current_data.join("\n"); + let _ = window.emit(&chunk_event, StreamChunk { + id: current_id.take(), + event: current_event.take(), + data, + is_done: false, + }); + } + + let remaining = buffer.trim().to_string(); + if !remaining.is_empty() { + let _ = window.emit(&chunk_event, StreamChunk { + id: None, + event: None, + data: remaining, + is_done: false, + }); + } + break; + } + } + } + } + } + + if let Ok(mut streams) = active_streams.streams.lock() { + streams.remove(&request_id); + } + + let _ = window.emit( + &done_event, + serde_json::json!({ + "cancelled": cancelled, + "duration": now_millis() - start_time, + }), + ); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_stream( + request_id: String, + active_streams: tauri::State<'_, ActiveStreams>, +) -> Result<(), String> { + let streams = active_streams + .streams + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + + if let Some(tx) = streams.get(&request_id) { + let _ = tx.send(true); + } + + Ok(()) +} From db080ce038f9d58f5684a5458ec9a93da5949e16 Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 20 Feb 2026 23:30:21 -0800 Subject: [PATCH 02/10] refactor(frontend): modularize oauth and streaming flows --- src/App.tsx | 12 +- src/components/AuthConfigurator.tsx | 13 +- src/components/OAuthConfigurator.tsx | 239 +++++++++++++++++++++++++++ src/components/RequestPanel.tsx | 128 +++++++++++++- src/components/RequestUrlBar.tsx | 34 +++- src/components/ResponsePanel.tsx | 34 ++-- src/components/ResponseStreamer.tsx | 165 ++++++++++++++++++ src/components/ui/badge.tsx | 39 +++++ src/hooks/useOAuth2TokenActions.ts | 99 +++++++++++ src/hooks/useStreamingResponse.ts | 169 +++++++++++++++++++ src/services/oauth.ts | 110 ++++++++++++ src/types/index.ts | 89 ++++++++-- src/utils/auth.ts | 123 ++++++++++++++ src/utils/streaming.ts | 84 ++++++++++ 14 files changed, 1289 insertions(+), 49 deletions(-) create mode 100644 src/components/OAuthConfigurator.tsx create mode 100644 src/components/ResponseStreamer.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/hooks/useOAuth2TokenActions.ts create mode 100644 src/hooks/useStreamingResponse.ts create mode 100644 src/services/oauth.ts create mode 100644 src/utils/auth.ts create mode 100644 src/utils/streaming.ts diff --git a/src/App.tsx b/src/App.tsx index 4497d5b..76ea44f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -172,12 +172,22 @@ function App() { onTestScriptsChange={(testScripts) => updateTab(currentTab.id, { testScripts })} onTestAssertionsChange={(testAssertions) => updateTab(currentTab.id, { testAssertions })} onTestResultsChange={(testResults) => updateTab(currentTab.id, { testResults })} + onStreamingStateChange={(streaming, cancelStream) => + updateTab(currentTab.id, { + streaming, + cancelStream: cancelStream || undefined, + }) + } onSend={() => handleSend(currentTab.id)} /> - + )} diff --git a/src/components/AuthConfigurator.tsx b/src/components/AuthConfigurator.tsx index 79e18c9..2027326 100644 --- a/src/components/AuthConfigurator.tsx +++ b/src/components/AuthConfigurator.tsx @@ -2,6 +2,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Input } from "@/components/ui/input" import { AuthConfig, AuthType } from "@/types" import { useThemeClass } from "@/hooks/useThemeClass" +import { OAuthConfigurator } from "./OAuthConfigurator" interface AuthConfiguratorProps { auth: AuthConfig @@ -13,6 +14,7 @@ const AUTH_TYPES = [ { value: 'basic', label: 'Basic Auth' }, { value: 'bearer', label: 'Bearer Token' }, { value: 'api-key', label: 'API Key' }, + { value: 'oauth2', label: 'OAuth 2.0' }, ] export function AuthConfigurator({ auth, onAuthChange }: AuthConfiguratorProps) { @@ -73,8 +75,8 @@ export function AuthConfigurator({ auth, onAuthChange }: AuthConfiguratorProps) value={auth.value || ''} onChange={(e) => onAuthChange({ ...auth, value: e.target.value })} /> - )} + + {auth.type === 'oauth2' && ( + onAuthChange({ ...auth, oauth2 })} + /> + )} ) } \ No newline at end of file diff --git a/src/components/OAuthConfigurator.tsx b/src/components/OAuthConfigurator.tsx new file mode 100644 index 0000000..107f681 --- /dev/null +++ b/src/components/OAuthConfigurator.tsx @@ -0,0 +1,239 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { OAuth2Config, OAuth2GrantType } from "@/types" +import { useThemeClass } from "@/hooks/useThemeClass" +import { useOAuth2TokenActions } from "@/hooks/useOAuth2TokenActions" +import { Loader2, KeyRound, RefreshCw } from "lucide-react" + +interface OAuthConfiguratorProps { + oauth2: OAuth2Config + onOAuth2Change: (config: OAuth2Config) => void +} + +const GRANT_TYPES: { value: OAuth2GrantType; label: string }[] = [ + { value: 'authorization_code', label: 'Authorization Code' }, + { value: 'client_credentials', label: 'Client Credentials' }, + { value: 'password', label: 'Password' }, +] + +export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorProps) { + const themeClass = useThemeClass() + const { + isLoading, + tokenError, + getNewToken, + refreshToken, + clearToken, + isExpired, + expiresIn, + } = useOAuth2TokenActions({ oauth2, onOAuth2Change }) + + const updateField = (field: keyof OAuth2Config, value: string) => { + onOAuth2Change({ ...oauth2, [field]: value }) + } + + return ( +
+ {/* Grant Type */} +
+ + +
+ + {/* Auth URL & PKCE - for Authorization Code only */} + {oauth2.grantType === 'authorization_code' && ( + <> +
+ + updateField('authUrl', e.target.value)} + /> +
+
+ + updateField('redirectUri', e.target.value)} + /> +

+ Register this URL as a redirect URI with your OAuth provider. + Leave blank to use the default. +

+
+ + + )} + + {/* Token URL - for all grant types */} +
+ + updateField('tokenUrl', e.target.value)} + /> +
+ + {/* Client ID */} +
+ + updateField('clientId', e.target.value)} + /> +
+ + {/* Client Secret */} +
+ + updateField('clientSecret', e.target.value)} + /> +
+ + {/* Username/Password - for Password grant only */} + {oauth2.grantType === 'password' && ( + <> +
+ + updateField('username', e.target.value)} + /> +
+
+ + updateField('password', e.target.value)} + /> +
+ + )} + + {/* Scope */} +
+ + updateField('scope', e.target.value)} + /> +
+ + {/* Token Status */} + {oauth2.accessToken && ( +
+
+ + Token + {isExpired ? ( + Expired + ) : ( + Active + )} + {expiresIn !== null && !isExpired && ( + + Expires in {expiresIn > 3600 ? `${Math.round(expiresIn / 3600)}h` : `${Math.round(expiresIn / 60)}m`} + + )} +
+
+ {oauth2.tokenType || 'Bearer'} {oauth2.accessToken.substring(0, 40)}... +
+
+ )} + + {/* Error */} + {tokenError && ( +
+ {tokenError} +
+ )} + + {/* Actions */} +
+ + + {oauth2.refreshToken && ( + + )} + + {oauth2.accessToken && ( + + )} +
+
+ ) +} diff --git a/src/components/RequestPanel.tsx b/src/components/RequestPanel.tsx index f896bf6..9a552b9 100644 --- a/src/components/RequestPanel.tsx +++ b/src/components/RequestPanel.tsx @@ -2,7 +2,7 @@ import { Card } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { ScrollArea } from "@/components/ui/scroll-area" import { useEffect, useState } from "react" -import { AuthConfig, URLParam, Header, Cookie, TestScript, TestAssertion, TestResult, Response } from "@/types" +import { AuthConfig, URLParam, Header, Cookie, TestScript, TestAssertion, TestResult, Response, StreamingResponse } from "@/types" import { getRequestNameFromUrl } from "@/utils/url" import { KeyValueList } from "./KeyValueList" import { AuthConfigurator } from "./AuthConfigurator" @@ -14,7 +14,8 @@ import { SaveRequestDialog } from "./SaveRequestDialog" import { RequestBodyEditor } from "./RequestBodyEditor" import { CodeSnippetViewer } from "./CodeSnippetViewer" import { CookieEditor } from "./CookieEditor" - +import { useStreamingResponse } from "@/hooks/useStreamingResponse" +import { applyAuthToRequest } from "@/utils/auth" interface RequestPanelProps { method: string @@ -41,6 +42,10 @@ interface RequestPanelProps { onTestScriptsChange: (scripts: TestScript[]) => void onTestAssertionsChange: (assertions: TestAssertion[]) => void onTestResultsChange: (results: TestResult | null) => void + onStreamingStateChange?: ( + streaming: StreamingResponse | null, + cancelStream: (() => Promise) | (() => void) | null + ) => void onSend: () => void } @@ -69,16 +74,46 @@ export function RequestPanel({ onTestScriptsChange, onTestAssertionsChange, onTestResultsChange, + onStreamingStateChange, onSend, }: RequestPanelProps) { const { collections, addRequest, addCollection } = useCollectionStore() const [saveDialogOpen, setSaveDialogOpen] = useState(false) + const { streaming, isStreaming, startStream, cancelStream, resetStream } = useStreamingResponse() + const [streamingError, setStreamingError] = useState(null); + + // Share streaming data with parent component + useEffect(() => { + const updatedStreamingState = streaming && streaming.isComplete && streaming.error + ? null // If streaming is complete with an error, send null to re-enable buttons + : streaming + + onStreamingStateChange?.(updatedStreamingState, streaming ? cancelStream : null) + + // Set local error state + if (streaming?.error) { + setStreamingError(streaming.error) + // Reset streaming state + if (streaming.isComplete) { + setTimeout(() => { + resetStream() + }, 100) + } + } + }, [streaming, cancelStream, resetStream, onStreamingStateChange]) + + // Reset error state when starting a new request + useEffect(() => { + if (isStreaming) { + setStreamingError(null) + } + }, [isStreaming]) // Add keyboard event handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Check if Enter is pressed and Ctrl/Cmd is not held down (to avoid conflicts with newlines in body) - if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !loading) { + if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !loading && !isStreaming) { // Only trigger if we're not in a textarea or contenteditable element const activeElement = document.activeElement const isInTextArea = activeElement?.tagName === 'TEXTAREA' @@ -93,7 +128,7 @@ export function RequestPanel({ window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [loading, onSend]) + }, [loading, isStreaming, onSend]) const handleSaveToCollection = (collectionId: string) => { const requestData = { @@ -146,17 +181,100 @@ export function RequestPanel({ onTestResultsChange(results) } + // Prepare request data based on current state + const prepareRequestData = () => { + // Build URL with params + let finalUrl = url + if (params.length > 0) { + // Add query parameters to URL + const searchParams = new URLSearchParams() + params.forEach(param => { + if (param.enabled && param.key) { + searchParams.append(param.key, param.value || '') + } + }) + + // Check if URL already has query parameters + const hasQueryParams = finalUrl.includes('?') + + // Append params to URL + if (hasQueryParams) { + finalUrl = `${finalUrl}&${searchParams.toString()}` + } else if (searchParams.toString()) { + finalUrl = `${finalUrl}?${searchParams.toString()}` + } + } + + // Prepare headers with auth + const preparedHeaders: Record = {} + const headersWithAuth = applyAuthToRequest(headers, auth) + + headersWithAuth.forEach((header: Header) => { + if (header.enabled && header.key) { + preparedHeaders[header.key] = header.value || '' + } + }) + + // Prepare cookies + const preparedCookies = cookies.filter(cookie => cookie.name) + + return { + finalUrl, + preparedHeaders, + preparedBody: body, + preparedCookies + } + } + + const handleStreamRequest = async () => { + if (loading) return + + // Reset previous streaming errors + setStreamingError(null) + + // If currently streaming, cancel it + if (isStreaming) { + await cancelStream() + return + } + + const { finalUrl, preparedHeaders, preparedBody, preparedCookies } = prepareRequestData() + + try { + await startStream({ + method, + url: finalUrl, + headers: preparedHeaders, + body: preparedBody, + content_type: contentType, + cookies: preparedCookies, + }) + } catch (err) { + setStreamingError(err instanceof Error ? err.message : String(err)) + // Make sure streaming state is reset + resetStream() + } + } + return ( setSaveDialogOpen(true)} + onStreamSSE={handleStreamRequest} /> + + {streamingError && ( +
+ Error: {streamingError} +
+ )}
) -} \ No newline at end of file +} diff --git a/src/components/RequestUrlBar.tsx b/src/components/RequestUrlBar.tsx index 6138be6..f72c046 100644 --- a/src/components/RequestUrlBar.tsx +++ b/src/components/RequestUrlBar.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { Save } from "lucide-react" +import { Save, Wifi, Loader2, XCircle } from "lucide-react" import { Select, SelectContent, @@ -16,20 +16,24 @@ interface RequestUrlBarProps { method: string url: string loading: boolean + isStreaming?: boolean onMethodChange: (value: string) => void onUrlChange: (value: string) => void onSend: () => void onSave: () => void + onStreamSSE?: () => void } export function RequestUrlBar({ method, url, loading, + isStreaming = false, onMethodChange, onUrlChange, onSend, onSave, + onStreamSSE, }: RequestUrlBarProps) { const themeClass = useThemeClass() @@ -71,9 +75,35 @@ export function RequestUrlBar({ Save - + {onStreamSSE && ( + + )} ) } \ No newline at end of file diff --git a/src/components/ResponsePanel.tsx b/src/components/ResponsePanel.tsx index 4e97ff6..e75387e 100644 --- a/src/components/ResponsePanel.tsx +++ b/src/components/ResponsePanel.tsx @@ -1,7 +1,7 @@ import { Card } from "@/components/ui/card" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { ScrollArea } from "@/components/ui/scroll-area" -import { Response } from "@/types" +import { Response, StreamingResponse } from "@/types" import { useEffect, useState } from "react" import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' @@ -12,14 +12,24 @@ import { ImageViewer } from "./ImageViewer" import { HeadersView } from "./HeadersView" import { TimingView } from "./TimingView" import { Send } from "lucide-react" +import { ResponseStreamer } from "./ResponseStreamer" interface ResponsePanelProps { response: Response | null + streamingResponse?: StreamingResponse | null + onCancelStream?: () => void } export function ResponsePanel({ response, + streamingResponse, + onCancelStream }: ResponsePanelProps) { + // If streaming is active, show the streaming component instead + if (streamingResponse) { + return {})} /> + } + const isErrorStatus = response?.status && response.status >= 400 const statusClass = isErrorStatus ? "text-red-400 font-medium" : "text-muted-foreground" const [activeTab, setActiveTab] = useState("response") @@ -28,35 +38,17 @@ export function ResponsePanel({ const [rawResponse, setRawResponse] = useState("") const { jsonViewer } = useSettingsStore() - // Add debug logging - useEffect(() => { - if (response) { - console.log('Response in ResponsePanel:', { - hasRedirectChain: !!response.redirectChain, - redirectChainLength: response.redirectChain?.length, - fullResponse: response - }); - } - }, [response]); - useEffect(() => { if (response?.body) { try { const contentType = response.headers['content-type'] || '' const body = response.body.trim() - - console.log('Response format detection:', { - contentType, - bodyStart: body.slice(0, 100), - isBase64: response.is_base64 - }); - + // Store raw response setRawResponse(response.body) // Check for Image if (contentType.startsWith('image/')) { - console.log('Detected image format'); setResponseFormat("image") setParsedJSON(null) } @@ -416,4 +408,4 @@ export function ResponsePanel({ ) -} \ No newline at end of file +} diff --git a/src/components/ResponseStreamer.tsx b/src/components/ResponseStreamer.tsx new file mode 100644 index 0000000..2525be7 --- /dev/null +++ b/src/components/ResponseStreamer.tsx @@ -0,0 +1,165 @@ +import { Card } from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { StreamingResponse } from "@/types" +import { useEffect, useMemo, useRef, useState } from "react" +import { useSettingsStore } from "@/store/settings" +import { CopyButton } from "./CopyButton" +import { CollapsibleJSON } from "./CollapsibleJSON" +import { HeadersView } from "./HeadersView" +import { PlayIcon, PauseIcon, AlertCircle } from "lucide-react" +import { Badge } from "./ui/badge" +import { Button } from "./ui/button" +import { tryParseStreamingJson } from "@/utils/streaming" + +interface ResponseStreamerProps { + streaming: StreamingResponse | null + onCancel: () => void +} + +export function ResponseStreamer({ + streaming, + onCancel +}: ResponseStreamerProps) { + const [activeTab, setActiveTab] = useState("stream") + const [isPaused, setIsPaused] = useState(false) + const scrollAreaRef = useRef(null) + const isErrorStatus = streaming?.status && streaming.status >= 400 + const { jsonViewer } = useSettingsStore() + + const parsedJSON = useMemo(() => { + if (!streaming?.currentContent) { + return null + } + + const contentType = streaming.headers["content-type"] || "" + if (!contentType.includes("application/json")) { + return null + } + + return tryParseStreamingJson(streaming.currentContent) + }, [streaming?.currentContent, streaming?.headers]) + + const isJsonContent = parsedJSON !== null + + useEffect(() => { + if (!streaming?.currentContent || isPaused) { + return + } + + requestAnimationFrame(() => { + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight + } + }) + }, [streaming?.currentContent, streaming?.chunks.length, isPaused]) + + const togglePause = () => { + setIsPaused((prev) => !prev) + } + + if (!streaming) { + return null + } + + const statusClass = isErrorStatus ? "text-red-400 font-medium" : "text-muted-foreground" + const getElapsedTime = () => { + if (!streaming.timing) return "0ms" + return `${Math.round(streaming.timing.duration)}ms` + } + + return ( + + +
+
+ + Stream + Headers + +
+ Status: {streaming.statusText} + + {streaming.isComplete ? "Complete" : `Streaming (${streaming.chunks.length} chunks)...`} + + + Time: {getElapsedTime()} + +
+
+
+ +
+ + + {streaming.currentContent && ( + + )} +
+ +
+ {streaming.error ? ( +
+                  Error: {streaming.error}
+                
+ ) : ( + isJsonContent && parsedJSON ? ( +
+ +
+ ) : ( +
+                    {streaming.currentContent || ""}
+                  
+ ) + )} + + {/* Visual indicator for new chunks */} + {!streaming.isComplete && streaming.chunks.length > 0 && ( +
+ )} +
+
+
+ + + + + +
+
+ ) +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..774be0e --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,39 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + success: + "border-transparent bg-green-500 text-white hover:bg-green-500/80", + warning: + "border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/src/hooks/useOAuth2TokenActions.ts b/src/hooks/useOAuth2TokenActions.ts new file mode 100644 index 0000000..3b173a5 --- /dev/null +++ b/src/hooks/useOAuth2TokenActions.ts @@ -0,0 +1,99 @@ +import { useMemo, useState } from 'react' +import { OAuth2Config } from '@/types' +import { + OAuthTokenResponse, + applyTokenResponse, + refreshOAuthToken, + requestOAuthToken, +} from '@/services/oauth' + +interface UseOAuth2TokenActionsOptions { + oauth2: OAuth2Config + onOAuth2Change: (config: OAuth2Config) => void +} + +interface OAuth2TokenActions { + isLoading: boolean + tokenError: string | null + getNewToken: () => Promise + refreshToken: () => Promise + clearToken: () => void + isExpired: boolean + expiresIn: number | null +} + +export function useOAuth2TokenActions({ + oauth2, + onOAuth2Change, +}: UseOAuth2TokenActionsOptions): OAuth2TokenActions { + const [isLoading, setIsLoading] = useState(false) + const [tokenError, setTokenError] = useState(null) + + const handleTokenResponse = (token: OAuthTokenResponse) => { + onOAuth2Change(applyTokenResponse(oauth2, token)) + } + + const getNewToken = async () => { + setIsLoading(true) + setTokenError(null) + + try { + const token = await requestOAuthToken(oauth2) + handleTokenResponse(token) + } catch (err) { + setTokenError(err instanceof Error ? err.message : String(err)) + } finally { + setIsLoading(false) + } + } + + const refreshToken = async () => { + if (!oauth2.refreshToken || !oauth2.tokenUrl) { + return + } + + setIsLoading(true) + setTokenError(null) + + try { + const token = await refreshOAuthToken(oauth2) + handleTokenResponse(token) + } catch (err) { + setTokenError(err instanceof Error ? err.message : String(err)) + } finally { + setIsLoading(false) + } + } + + const clearToken = () => { + onOAuth2Change({ + ...oauth2, + accessToken: undefined, + refreshToken: undefined, + tokenType: undefined, + expiresAt: undefined, + }) + } + + const isExpired = useMemo(() => { + return oauth2.expiresAt ? Date.now() > oauth2.expiresAt : false + }, [oauth2.expiresAt]) + + const expiresIn = useMemo(() => { + if (!oauth2.expiresAt) { + return null + } + + return Math.max(0, Math.round((oauth2.expiresAt - Date.now()) / 1000)) + }, [oauth2.expiresAt]) + + return { + isLoading, + tokenError, + getNewToken, + refreshToken, + clearToken, + isExpired, + expiresIn, + } +} diff --git a/src/hooks/useStreamingResponse.ts b/src/hooks/useStreamingResponse.ts new file mode 100644 index 0000000..fdac37f --- /dev/null +++ b/src/hooks/useStreamingResponse.ts @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import { StreamChunk, StreamingResponse } from '@/types' +import { + StreamDonePayload, + StreamHeaderPayload, + StreamRequestOptions, + createInitialStreamingResponse, + createStreamEventNames, + createStreamingTiming, + shouldIgnoreStreamChunk, + toErrorMessage, +} from '@/utils/streaming' + +type UnlistenFn = () => void + +export function useStreamingResponse() { + const [streaming, setStreaming] = useState(null) + const [isStreaming, setIsStreaming] = useState(false) + const [error, setError] = useState(null) + + const unlistenRef = useRef(null) + const requestId = useRef('') + const startTime = useRef(0) + + const cleanupListeners = useCallback(() => { + if (unlistenRef.current) { + unlistenRef.current() + unlistenRef.current = null + } + }, []) + + const startStream = async (options: StreamRequestOptions) => { + cleanupListeners() + + const currentRequestId = crypto.randomUUID() + requestId.current = currentRequestId + startTime.current = Date.now() + + setError(null) + setIsStreaming(true) + setStreaming(createInitialStreamingResponse(startTime.current)) + + try { + const events = createStreamEventNames(currentRequestId) + + const headerListener = await listen(events.headers, (event) => { + const payload = event.payload + setStreaming((prev) => { + if (!prev) return null + return { + ...prev, + status: payload.status, + statusText: payload.statusText, + headers: payload.headers, + } + }) + }) + + const chunkListener = await listen(events.chunk, (event) => { + const chunk: StreamChunk = { + ...event.payload, + timestamp: Date.now(), + } + + if (shouldIgnoreStreamChunk(chunk)) { + return + } + + setStreaming((prev) => { + if (!prev) return null + return { + ...prev, + chunks: [...prev.chunks, chunk], + currentContent: prev.currentContent + chunk.data, + timing: createStreamingTiming(startTime.current), + } + }) + }) + + const doneListener = await listen(events.done, (event) => { + const payload = event.payload ?? {} + const streamError = payload.cancelled + ? 'Request cancelled by user' + : payload.error + + setStreaming((prev) => { + if (!prev) return null + return { + ...prev, + isComplete: true, + error: streamError || prev.error, + timing: createStreamingTiming(startTime.current), + } + }) + setIsStreaming(false) + }) + + unlistenRef.current = () => { + headerListener() + chunkListener() + doneListener() + } + + await invoke('stream_sse', { options, requestId: currentRequestId }) + } catch (err) { + const errorMessage = toErrorMessage(err) + setError(errorMessage) + setIsStreaming(false) + setStreaming((prev) => { + const base = prev ?? createInitialStreamingResponse(startTime.current || Date.now()) + return { + ...base, + error: errorMessage, + isComplete: true, + timing: createStreamingTiming(startTime.current || Date.now()), + } + }) + cleanupListeners() + } + } + + const cancelStream = async () => { + if (requestId.current) { + try { + await invoke('cancel_stream', { requestId: requestId.current }) + } catch { + // If backend cancellation fails, still close stream state in UI. + } + } + + cleanupListeners() + setStreaming((prev) => { + if (!prev) return null + return { + ...prev, + isComplete: true, + error: 'Request cancelled by user', + timing: createStreamingTiming(startTime.current), + } + }) + setIsStreaming(false) + } + + const resetStream = useCallback(() => { + cleanupListeners() + requestId.current = '' + startTime.current = 0 + setStreaming(null) + setIsStreaming(false) + setError(null) + }, [cleanupListeners]) + + useEffect(() => { + return () => { + resetStream() + } + }, [resetStream]) + + return { + streaming, + isStreaming, + error, + startStream, + cancelStream, + resetStream, + } +} diff --git a/src/services/oauth.ts b/src/services/oauth.ts new file mode 100644 index 0000000..02ca73e --- /dev/null +++ b/src/services/oauth.ts @@ -0,0 +1,110 @@ +import { invoke } from '@tauri-apps/api/core' +import { OAuth2Config, OAuth2GrantType } from '@/types' + +export interface OAuthTokenResponse { + access_token: string + token_type?: string + expires_in?: number + refresh_token?: string + scope?: string +} + +function requiredField(value: string | undefined, fieldName: string): string { + const trimmed = value?.trim() + if (!trimmed) { + throw new Error(`${fieldName} is required`) + } + return trimmed +} + +function optionalOrNull(value?: string): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function assertGrantRequirements(config: OAuth2Config) { + requiredField(config.clientId, 'Client ID') + + switch (config.grantType) { + case 'authorization_code': + requiredField(config.authUrl, 'Authorization URL') + requiredField(config.tokenUrl, 'Token URL') + return + case 'client_credentials': + requiredField(config.tokenUrl, 'Token URL') + return + case 'password': + requiredField(config.tokenUrl, 'Token URL') + requiredField(config.username, 'Username') + requiredField(config.password, 'Password') + return + default: { + const _never: never = config.grantType + throw new Error(`Unsupported grant type: ${_never}`) + } + } +} + +function createTokenExchangeOptions(config: OAuth2Config, grantType: OAuth2GrantType) { + return { + token_url: requiredField(config.tokenUrl, 'Token URL'), + grant_type: grantType, + client_id: requiredField(config.clientId, 'Client ID'), + client_secret: optionalOrNull(config.clientSecret), + scope: optionalOrNull(config.scope), + username: optionalOrNull(config.username), + password: optionalOrNull(config.password), + } +} + +export function applyTokenResponse(config: OAuth2Config, token: OAuthTokenResponse): OAuth2Config { + return { + ...config, + accessToken: token.access_token, + tokenType: token.token_type || 'Bearer', + refreshToken: token.refresh_token || config.refreshToken, + expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined, + } +} + +export async function requestOAuthToken(config: OAuth2Config): Promise { + assertGrantRequirements(config) + + switch (config.grantType) { + case 'authorization_code': + return invoke('oauth2_auth_code_flow', { + options: { + auth_url: requiredField(config.authUrl, 'Authorization URL'), + token_url: requiredField(config.tokenUrl, 'Token URL'), + client_id: requiredField(config.clientId, 'Client ID'), + client_secret: optionalOrNull(config.clientSecret), + scope: optionalOrNull(config.scope), + use_pkce: config.usePkce ?? true, + redirect_uri: optionalOrNull(config.redirectUri), + }, + }) + case 'client_credentials': + return invoke('oauth2_token_exchange', { + options: createTokenExchangeOptions(config, 'client_credentials'), + }) + case 'password': + return invoke('oauth2_token_exchange', { + options: createTokenExchangeOptions(config, 'password'), + }) + default: { + const _never: never = config.grantType + throw new Error(`Unsupported grant type: ${_never}`) + } + } +} + +export async function refreshOAuthToken(config: OAuth2Config): Promise { + return invoke('oauth2_refresh', { + options: { + token_url: requiredField(config.tokenUrl, 'Token URL'), + client_id: requiredField(config.clientId, 'Client ID'), + client_secret: optionalOrNull(config.clientSecret), + refresh_token: requiredField(config.refreshToken, 'Refresh token'), + }, + }) +} diff --git a/src/types/index.ts b/src/types/index.ts index e369f94..3f7c368 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,6 +21,29 @@ export interface Response { size?: ResponseSize } +export interface StreamingResponse { + status: number + statusText: string + headers: Record + chunks: StreamChunk[] + currentContent: string + isComplete: boolean + error?: string + timing?: { + start: number + current: number + duration: number + } + streamType?: 'sse' | 'chunked' | 'unknown' +} + +export interface StreamChunk { + id?: string + event?: string + data: string + timestamp: number +} + export interface ResponseTiming { start: number end: number @@ -64,7 +87,28 @@ export interface Header { enabled: boolean } -export type AuthType = 'none' | 'basic' | 'bearer' | 'api-key' +export type AuthType = 'none' | 'basic' | 'bearer' | 'api-key' | 'oauth2' + +export type OAuth2GrantType = 'authorization_code' | 'client_credentials' | 'password' + +export interface OAuth2Config { + grantType: OAuth2GrantType + authUrl?: string + tokenUrl?: string + clientId: string + clientSecret?: string + scope?: string + usePkce?: boolean + redirectUri?: string + // Password grant only + username?: string + password?: string + // Token state (stored with request) + accessToken?: string + refreshToken?: string + tokenType?: string + expiresAt?: number +} export interface AuthConfig { type: AuthType @@ -74,6 +118,7 @@ export interface AuthConfig { key?: string value?: string addTo?: 'header' | 'query' + oauth2?: OAuth2Config } export interface Session { @@ -138,6 +183,8 @@ export interface Tab { testScripts: TestScript[] testAssertions: TestAssertion[] testResults: TestResult | null + streaming?: StreamingResponse | null + cancelStream?: (() => void) | (() => Promise) } export interface Cookie { @@ -154,20 +201,26 @@ export interface Collection { id: string name: string description?: string - requests: { - id: string - name: string - method: string - url: string - rawUrl: string - params: URLParam[] - headers: Header[] - body: string - contentType: string - auth: AuthConfig - cookies: Cookie[] - testScripts: TestScript[] - testAssertions: TestAssertion[] - testResults: TestResult | null - }[] -} \ No newline at end of file + requests: SavedRequest[] + createdAt?: Date + updatedAt?: Date +} + +export interface SavedRequest { + id: string + name: string + method: string + url: string + rawUrl: string + params: URLParam[] + headers: Header[] + body: string + contentType: string + auth: AuthConfig + cookies: Cookie[] + testScripts: TestScript[] + testAssertions: TestAssertion[] + testResults: TestResult | null + createdAt?: Date + updatedAt?: Date +} diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..5316a67 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,123 @@ +import { AuthConfig, Header } from '@/types' + +/** + * Applies authentication settings to request headers + * @param headers Current request headers + * @param auth Authentication configuration + * @returns Headers with authentication applied + */ +export function applyAuthToRequest(headers: Header[], auth: AuthConfig): Header[] { + // Create a copy of the headers to avoid modifying the original + const headersWithAuth = [...headers] + + if (!auth || auth.type === 'none') { + return headersWithAuth + } + + // Handle each auth type + switch (auth.type) { + case 'basic': { + if (auth.username) { + const credentials = auth.password + ? `${auth.username}:${auth.password}` + : auth.username + + const encodedCredentials = btoa(credentials) + + // Check if authorization header already exists + const existingAuthHeader = headersWithAuth.findIndex( + header => header.key.toLowerCase() === 'authorization' + ) + + if (existingAuthHeader >= 0) { + headersWithAuth[existingAuthHeader] = { + ...headersWithAuth[existingAuthHeader], + value: `Basic ${encodedCredentials}`, + enabled: true + } + } else { + headersWithAuth.push({ + key: 'Authorization', + value: `Basic ${encodedCredentials}`, + enabled: true + }) + } + } + break + } + + case 'bearer': { + if (auth.token) { + // Check if authorization header already exists + const existingAuthHeader = headersWithAuth.findIndex( + header => header.key.toLowerCase() === 'authorization' + ) + + if (existingAuthHeader >= 0) { + headersWithAuth[existingAuthHeader] = { + ...headersWithAuth[existingAuthHeader], + value: `Bearer ${auth.token}`, + enabled: true + } + } else { + headersWithAuth.push({ + key: 'Authorization', + value: `Bearer ${auth.token}`, + enabled: true + }) + } + } + break + } + + case 'api-key': { + if (auth.key && auth.value && auth.addTo === 'header') { + const existingHeader = headersWithAuth.findIndex( + header => header.key.toLowerCase() === auth.key?.toLowerCase() + ) + + if (existingHeader >= 0) { + headersWithAuth[existingHeader] = { + ...headersWithAuth[existingHeader], + value: auth.value, + enabled: true + } + } else if (auth.key) { + headersWithAuth.push({ + key: auth.key, + value: auth.value, + enabled: true + }) + } + } + break + } + + case 'oauth2': { + if (auth.oauth2?.accessToken) { + const tokenType = auth.oauth2.tokenType || 'Bearer' + const value = `${tokenType} ${auth.oauth2.accessToken}` + const existingAuthHeader = headersWithAuth.findIndex( + header => header.key.toLowerCase() === 'authorization' + ) + + if (existingAuthHeader >= 0) { + headersWithAuth[existingAuthHeader] = { + ...headersWithAuth[existingAuthHeader], + value, + enabled: true + } + } else { + headersWithAuth.push({ + key: 'Authorization', + value, + enabled: true + }) + } + } + break + } + } + + return headersWithAuth +} \ No newline at end of file diff --git a/src/utils/streaming.ts b/src/utils/streaming.ts new file mode 100644 index 0000000..fcfa127 --- /dev/null +++ b/src/utils/streaming.ts @@ -0,0 +1,84 @@ +import { Cookie, StreamChunk, StreamingResponse } from '@/types' + +export interface StreamRequestOptions { + method: string + url: string + headers: Record + body?: string + content_type?: string + cookies: Cookie[] +} + +export interface StreamHeaderPayload { + status: number + statusText: string + headers: Record +} + +export interface StreamDonePayload { + cancelled?: boolean + error?: string +} + +export function createStreamEventNames(requestId: string) { + return { + headers: `sse-headers-${requestId}`, + chunk: `sse-chunk-${requestId}`, + done: `sse-done-${requestId}`, + } +} + +export function createStreamingTiming(startTime: number) { + const now = Date.now() + return { + start: startTime, + current: now, + duration: now - startTime, + } +} + +export function createInitialStreamingResponse(startTime: number): StreamingResponse { + return { + status: 0, + statusText: 'Pending', + headers: {}, + chunks: [], + currentContent: '', + isComplete: false, + streamType: 'sse', + timing: createStreamingTiming(startTime), + } +} + +export function shouldIgnoreStreamChunk(chunk: Pick): boolean { + if (!chunk.data || chunk.data.trim() === '') { + return true + } + + return chunk.event === 'stats' || chunk.event === 'ping' +} + +export function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return String(error) +} + +export function tryParseStreamingJson(content: string): unknown { + const trimmed = content.trim() + if (!trimmed) { + return null + } + + try { + if (trimmed.startsWith('[') && !trimmed.endsWith(']')) { + return JSON.parse(`${trimmed}]`) + } + + return JSON.parse(trimmed) + } catch { + return null + } +} From 35fd7a0be209d825f7dcf174312b950cadf74945 Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 20 Feb 2026 23:34:18 -0800 Subject: [PATCH 03/10] chore(frontend): add collection/openapi cleanup, tests, and CI --- .github/workflows/ci.yml | 49 ++++ src/components/CollectionsPanel.tsx | 248 +++++------------- src/components/ImageViewer.tsx | 8 +- src/components/OpenapiImportModal.tsx | 83 ++++++ src/components/OpenapiUrlImportModal.tsx | 120 +++++++++ src/components/UpdateChecker.tsx | 20 +- src/components/collections/CollectionCard.tsx | 168 ++++++++++++ src/components/collections/collectionUtils.ts | 33 +++ src/hooks/useRequest.ts | 19 +- src/store/collections.ts | 75 +++--- src/test/CollectionsPanel.test.tsx | 4 +- src/test/ImageViewer.test.tsx | 1 - src/test/OpenapiImport.test.tsx | 185 +++++++++++++ src/test/UpdateChecker.test.tsx | 214 +++++++++++++++ src/test/setup.ts | 24 +- src/utils/collection-converter.ts | 92 ++++++- src/utils/persistence.ts | 14 +- src/utils/url.ts | 42 ++- 18 files changed, 1116 insertions(+), 283 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/components/OpenapiImportModal.tsx create mode 100644 src/components/OpenapiUrlImportModal.tsx create mode 100644 src/components/collections/CollectionCard.tsx create mode 100644 src/components/collections/collectionUtils.ts create mode 100644 src/test/OpenapiImport.test.tsx create mode 100644 src/test/UpdateChecker.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a4bc93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + frontend: + name: Frontend Quality Gates + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test:run + + - name: Build frontend + run: pnpm build + + rust: + name: Rust Quality Gate + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cargo check + working-directory: src-tauri + run: cargo check diff --git a/src/components/CollectionsPanel.tsx b/src/components/CollectionsPanel.tsx index 9564b60..68e180e 100644 --- a/src/components/CollectionsPanel.tsx +++ b/src/components/CollectionsPanel.tsx @@ -7,10 +7,8 @@ import { SheetDescription, } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" import { ScrollArea } from "@/components/ui/scroll-area" -import { Folder, FolderPlus, MoreVertical, ChevronRight, ChevronDown, Save, Trash2, RotateCw, Download, Upload } from "lucide-react" +import { Folder, FolderPlus, Download, Upload } from "lucide-react" import { useCollectionStore } from "@/store/collections" import { Tab } from "@/types" import { getRequestNameFromUrl } from "@/utils/url" @@ -22,18 +20,10 @@ import { } from "@/components/ui/dropdown-menu" import { useState, forwardRef, useRef } from "react" import { toast } from "sonner" -import { cn } from "@/lib/utils" import { useThemeClass } from "@/hooks/useThemeClass" - -const methodColors: Record = { - GET: "bg-blue-500/10 text-blue-500", - POST: "bg-green-500/10 text-green-500", - PUT: "bg-yellow-500/10 text-yellow-500", - PATCH: "bg-orange-500/10 text-orange-500", - DELETE: "bg-red-500/10 text-red-500", - HEAD: "bg-purple-500/10 text-purple-500", - OPTIONS: "bg-cyan-500/10 text-cyan-500" -} +import { importFromOpenapi } from '@/utils/collection-converter' +import { CollectionCard } from "./collections/CollectionCard" +import { savedRequestToTab } from "./collections/collectionUtils" interface CollectionsPanelProps { open: boolean @@ -60,6 +50,10 @@ export const CollectionsPanel = forwardRef>(new Set()) const fileInputRef = useRef(null) const themeClass = useThemeClass() + const shouldLogImportErrors = + typeof import.meta !== "undefined" && + Boolean(import.meta.env?.DEV) && + import.meta.env?.MODE !== "test" const toggleCollection = (id: string) => { setExpandedCollections(prev => { @@ -87,6 +81,24 @@ export const CollectionsPanel = forwardRef { + onRequestSelect(request) + onOpenChange(false) + } + + const handleSelectSavedRequest = (request: Parameters[0]) => { + handleSelectRequest(savedRequestToTab(request)) + } + + const handleRestoreAllRequests = (collectionId: string) => { + const targetCollection = collections.find((collection) => collection.id === collectionId) + if (!targetCollection) return + targetCollection.requests.forEach((request) => { + onRequestSelect(savedRequestToTab(request)) + }) + onOpenChange(false) + } + const handleExport = () => { const blob = new Blob([exportCollections()], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -122,7 +134,9 @@ export const CollectionsPanel = forwardRef { + const openapiUrl = window.prompt("Enter the URL for the OpenAPI JSON file:"); + if (!openapiUrl) return; + try { + const response = await fetch(openapiUrl); + if (!response.ok) { + throw new Error("Failed to fetch the OpenAPI document."); + } + const apiDoc = await response.json(); + const baseUrl = window.prompt("Enter the base URL for the API:"); + if (!baseUrl) return; + const importedCollections = importFromOpenapi(apiDoc, baseUrl); + importCollections(importedCollections); + toast.success("OpenAPI collections imported successfully"); + } catch (error) { + if (shouldLogImportErrors) { + console.error("Error importing OpenAPI:", error); + } + toast.error(error instanceof Error ? error.message : "Invalid OpenAPI format"); + } + }; + return ( @@ -215,6 +253,9 @@ export const CollectionsPanel = forwardRef Postman Format + + OpenAPI Format + @@ -247,166 +288,21 @@ export const CollectionsPanel = forwardRef
{collections.map((collection) => ( -
-
-
toggleCollection(collection.id)} - > - - updateCollection(collection.id, { name: e.target.value })} - onClick={(e) => e.stopPropagation()} - className="h-8 bg-background text-foreground" - aria-label={`Collection Name ${collection.name}`} - /> -
-
- {currentRequest && ( - - )} - - -
-
- - {collection.description && ( -