Merge branch 'main' of github.com:hoppscotch/hoppscotch

This commit is contained in:
Gusram
2024-12-04 16:39:53 +08:00
543 changed files with 50313 additions and 17422 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "hoppscotch-desktop"
version = "24.7.0"
version = "24.11.0"
description = "A Tauri App"
authors = ["you"]
license = ""
@@ -10,10 +10,10 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.5.0", features = [] }
tauri-build = { version = "1.5.5", features = [] }
[dependencies]
tauri = { version = "1.5.3", features = [
tauri = { version = "1.8.1", features = [
"dialog-save",
"fs-write-file",
"http-all",
@@ -21,21 +21,27 @@ tauri = { version = "1.5.3", features = [
"shell-open",
"window-start-dragging",
"http-multipart",
"devtools"
] }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" }
tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-window-state = "0.1.0"
reqwest = { version = "0.11.22", features = ["native-tls"] }
serde_json = "1.0.108"
url = "2.5.0"
hex_color = "3.0.0"
time = "0.3.36"
serde = { version = "1.0.203", features = ["derive"] }
tauri-plugin-window-state = "0.1.1"
hoppscotch-relay = { path = "../../hoppscotch-relay" }
serde_json = "1.0.128"
url = "2.5.2"
hex_color = "3.0.0"
serde = { version = "1.0.210", features = ["derive"] }
dashmap = "5.5.3"
tokio = { version = "1.38.0", features = ["macros"] }
tokio-util = "0.7.11"
tokio = { version = "1.40.0", features = ["macros"] }
tokio-util = "0.7.12"
log = "0.4.22"
thiserror = "1.0.64"
[dev-dependencies]
tauri = { version = "1.8.1", features = ["devtools", "test"] }
env_logger = "0.11.5"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"

View File

@@ -1,320 +1,90 @@
use dashmap::DashMap;
use reqwest::{header::{HeaderMap, HeaderName, HeaderValue}, Certificate, ClientBuilder, Identity};
use serde::{Deserialize, Serialize};
use tauri::{plugin::{Builder, TauriPlugin}, Manager, Runtime, State};
use hoppscotch_relay::{RequestWithMetadata, ResponseWithMetadata};
use serde::Serialize;
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime, State,
};
use thiserror::Error;
use tokio_util::sync::CancellationToken;
#[derive(Default)]
struct InterceptorState {
cancellation_tokens: DashMap<usize, CancellationToken>
cancellation_tokens: DashMap<usize, CancellationToken>,
}
#[derive(Debug, Serialize, Deserialize)]
struct KeyValuePair {
key: String,
value: String
}
#[derive(Debug, Deserialize)]
enum FormDataValue {
Text(String),
File {
filename: String,
data: Vec<u8>,
mime: String
}
}
#[derive(Debug, Deserialize)]
struct FormDataEntry {
key: String,
value: FormDataValue
}
#[derive(Debug, Deserialize)]
enum BodyDef {
Text(String),
URLEncoded(Vec<KeyValuePair>),
FormData(Vec<FormDataEntry>)
}
#[derive(Debug, Deserialize)]
enum ClientCertDef {
PEMCert {
certificate_pem: Vec<u8>,
key_pem: Vec<u8>
},
PFXCert {
certificate_pfx: Vec<u8>,
password: String
}
}
#[derive(Debug, Deserialize)]
struct RequestDef {
req_id: usize,
method: String,
endpoint: String,
parameters: Vec<KeyValuePair>,
headers: Vec<KeyValuePair>,
body: Option<BodyDef>,
validate_certs: bool,
root_cert_bundle_files: Vec<Vec<u8>>,
client_cert: Option<ClientCertDef>
}
fn get_identity_from_req(req: &RequestDef) -> Result<Option<Identity>, reqwest::Error> {
let result = match &req.client_cert {
None => return Ok(None),
Some(ClientCertDef::PEMCert { certificate_pem, key_pem }) => Identity::from_pkcs8_pem(&certificate_pem, &key_pem),
Some(ClientCertDef::PFXCert { certificate_pfx, password }) => Identity::from_pkcs12_der(&certificate_pfx, &password)
};
Ok(Some(result?))
}
fn parse_root_certs(req: &RequestDef) -> Result<Vec<Certificate>, reqwest::Error> {
let mut result = vec![];
for cert_bundle_file in &req.root_cert_bundle_files {
let mut certs = Certificate::from_pem_bundle(&cert_bundle_file)?;
result.append(&mut certs);
}
Ok(result)
}
enum ReqBodyAction {
Body(reqwest::Body),
UrlEncodedForm(Vec<(String, String)>),
MultipartForm(reqwest::multipart::Form)
}
fn convert_bodydef_to_req_action(req: &RequestDef) -> Option<ReqBodyAction> {
match &req.body {
None => None,
Some(BodyDef::Text(text)) => Some(ReqBodyAction::Body(text.clone().into())),
Some(BodyDef::URLEncoded(entries)) =>
Some(
ReqBodyAction::UrlEncodedForm(
entries.iter()
.map(|KeyValuePair { key, value }| (key.clone(), value.clone()))
.collect()
)
),
Some(BodyDef::FormData(entries)) => {
let mut form = reqwest::multipart::Form::new();
for entry in entries {
form = match &entry.value {
FormDataValue::Text(value) => form.text(entry.key.clone(), value.clone()),
FormDataValue::File { filename, data, mime } =>
form.part(
entry.key.clone(),
reqwest::multipart::Part::bytes(data.clone())
.file_name(filename.clone())
.mime_str(mime.as_str()).expect("Error while setting File enum")
),
}
}
Some(ReqBodyAction::MultipartForm(form))
}
}
}
#[derive(Serialize)]
struct RunRequestResponse {
status: u16,
status_text: String,
headers: Vec<KeyValuePair>,
data: Vec<u8>,
time_start_ms: u128,
time_end_ms: u128
}
#[derive(Serialize)]
enum RunRequestError {
RequestCancelled,
ClientCertError,
RootCertError,
InvalidMethod,
InvalidUrl,
InvalidHeaders,
RequestRunError(String)
}
async fn execute_request(req_builder: reqwest::RequestBuilder) -> Result<RunRequestResponse, RunRequestError> {
let start_time_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let response = req_builder.send()
.await
.map_err(|err| RunRequestError::RequestRunError(err.to_string()))?;
// We hold on to these values becase we lose ownership of response
// when we read the body
let res_status = response.status();
let res_headers = response.headers().clone();
let res_body_bytes = response.bytes()
.await
.map_err(|err| RunRequestError::RequestRunError(err.to_string()))?;
// Reqwest resolves the send before all the response is loaded, to keep the timing
// correctly, we load the response as well.
let end_time_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let response_status = res_status.as_u16();
let response_status_text = res_status
.canonical_reason()
.unwrap_or("Unknown Status")
.to_owned();
let response_headers = res_headers
.iter()
.map(|(key, value)|
KeyValuePair {
key: key.as_str().to_owned(),
value: value.to_str().unwrap_or("").to_owned()
}
)
.collect();
Ok(
RunRequestResponse {
status: response_status,
status_text: response_status_text,
headers: response_headers,
data: res_body_bytes.into(),
time_start_ms: start_time_ms,
time_end_ms: end_time_ms
}
)
#[derive(Debug, Serialize, Error)]
pub enum RunRequestError {
#[error("Request cancelled")]
RequestCancelled,
#[error("Internal server error")]
InternalServerError,
#[error("Relay error: {0}")]
Relay(#[from] hoppscotch_relay::RelayError),
}
#[tauri::command]
async fn run_request(req: RequestDef, state: State<'_, InterceptorState>) -> Result<RunRequestResponse, RunRequestError> {
let method = reqwest::Method::from_bytes(req.method.as_bytes())
.map_err(|_| RunRequestError::InvalidMethod)?;
async fn run_request(
req: RequestWithMetadata,
state: State<'_, InterceptorState>,
) -> Result<ResponseWithMetadata, RunRequestError> {
let req_id = req.req_id;
let cancel_token = CancellationToken::new();
// NOTE: This will drop reference to an existing cancellation token
// if you send a request with the same request id as an existing one,
// thereby, dropping any means to cancel a running operation with the old token.
// This is done so because, on FE side, we may lose cancel token info upon reloads
// and this allows us to work around that.
state
.cancellation_tokens
.insert(req_id, cancel_token.clone());
let endpoint_url = reqwest::Url::parse(&req.endpoint)
.map_err(|_| RunRequestError::InvalidUrl)?;
let cancel_token_clone = cancel_token.clone();
// Execute the HTTP request in a blocking thread pool and handles cancellation.
//
// It:
// 1. Uses `spawn_blocking` to run the sync `run_request_task`
// without blocking the main Tokio runtime.
// 2. Uses `select!` to concurrently wait for either
// a. the task to complete,
// b. or a cancellation signal.
//
// Why spawn_blocking?
// - `run_request_task` uses synchronous curl operations which would block
// the async runtime if not run in a separate thread.
// - `spawn_blocking` moves this operation to a thread pool designed for
// blocking tasks, so other async operations to continue unblocked.
let result = tokio::select! {
res = tokio::task::spawn_blocking(move || hoppscotch_relay::run_request_task(&req, cancel_token_clone)) => {
match res {
Ok(task_result) => Ok(task_result?),
Err(_) => Err(RunRequestError::InternalServerError),
}
},
_ = cancel_token.cancelled() => {
Err(RunRequestError::RequestCancelled)
}
};
let headers = req.headers
.iter()
.map(|KeyValuePair { key, value }|
Ok(
(
key.parse::<HeaderName>().map_err(|_| ())?,
value.parse::<HeaderValue>().map_err(|_| ())?
)
)
)
.collect::<Result<HeaderMap, ()>>()
.map_err(|_| RunRequestError::InvalidHeaders)?;
state.cancellation_tokens.remove(&req_id);
let body_action = convert_bodydef_to_req_action(&req);
let client_identity = get_identity_from_req(&req)
.map_err(|_| RunRequestError::ClientCertError)?;
let root_certs = parse_root_certs(&req)
.map_err(|_| RunRequestError::RootCertError)?;
let mut client_builder = ClientBuilder::new()
.danger_accept_invalid_certs(!req.validate_certs);
// NOTE: Root Certificates are not currently implemented into the Hoppscotch UI
// This is done so as the current mechanism doesn't allow for v1 X.509 certificates
// to be accepted. Reqwest supports `native-tls` and `rustls`.
// `native-tls` should support v1 X.509 in Linux [OpenSSL] (and hopefully on Win [SChannel]), but on
// macOS the Security Framework system in it blocks certiticates pretty harshly and blocks v1.
// `rustls` doesn't allow v1 x.509 as well as documented here: https://github.com/rustls/webpki/issues/29
// We will fully introduce the feature when the dilemma is solved (or demand is voiced), until
// then, disabling SSL verification should yield same results
for root_cert in root_certs {
client_builder = client_builder.add_root_certificate(root_cert);
}
if let Some(identity) = client_identity {
client_builder = client_builder.identity(identity);
}
let client = client_builder.build()
.expect("TLS Backend couldn't be initialized");
let mut req_builder = client.request(method, endpoint_url)
.query(
&req.parameters
.iter()
.map(|KeyValuePair { key, value }| (key, value))
.collect::<Vec<_>>()
)
.headers(headers);
req_builder = match body_action {
None => req_builder,
Some(ReqBodyAction::Body(body)) => req_builder.body(body),
Some(ReqBodyAction::UrlEncodedForm(entries)) => req_builder.form(&entries),
Some(ReqBodyAction::MultipartForm(form)) => req_builder.multipart(form)
};
let cancel_token = CancellationToken::new();
// NOTE: This will drop reference to an existing cancellation token
// if you send a request with the same request id as an existing one,
// thereby, dropping any means to cancel a running operation with the old token.
// This is done so because, on FE side, we may lose cancel token info upon reloads
// and this allows us to work around that.
state.cancellation_tokens.insert(req.req_id, cancel_token.clone());
// Races between whether cancellation happened or requext execution happened
let result = tokio::select! {
_ = cancel_token.cancelled() => { None },
result = execute_request(req_builder) => {
// Remove cancellation token since the request has now completed
state.cancellation_tokens.remove(&req.req_id);
Some(result)
}
};
result
.unwrap_or(Err(RunRequestError::RequestCancelled))
result
}
#[tauri::command]
fn cancel_request(req_id: usize, state: State<'_, InterceptorState>) {
if let Some((_, cancel_token)) = state.cancellation_tokens.remove(&req_id) {
cancel_token.cancel();
}
if let Some((_, cancel_token)) = state.cancellation_tokens.remove(&req_id) {
cancel_token.cancel();
}
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("hopp_native_interceptor")
.invoke_handler(
tauri::generate_handler![
run_request,
cancel_request
]
)
.setup(|app_handle| {
app_handle.manage(InterceptorState::default());
Builder::new("hopp_native_interceptor")
.invoke_handler(tauri::generate_handler![run_request, cancel_request])
.setup(|app_handle| {
app_handle.manage(InterceptorState::default());
Ok(())
})
.build()
Ok(())
})
.build()
}

View File

@@ -0,0 +1 @@
pub(crate) mod startup;

View File

@@ -0,0 +1,53 @@
/// Error handling module for startup-related operations.
///
/// This module defines custom error types and a result type used for startup process of the app.
/// Essentially provides a way to handle and communicate errors
/// that may occur during the initialization and window management phases.
use serde::Serialize;
use thiserror::Error;
/// Represents errors related to window lookup failures.
///
/// Provide more specific information about which window that could not be found.
///
/// Derives `Serialize` mainly for sending it over to the frontend for info/logging purposes.
#[derive(Debug, Error, Serialize)]
pub(crate) enum WindowNotFoundError {
/// Indicates that the `main` window of the app could not be found.
///
/// This typically occurs if there's a mismatch between the expected
/// window labels and the actual windows created by the application.
#[error("No window labeled 'main' found")]
Main,
}
/// Represents errors that can occur during the startup process.
///
/// Derives `Serialize` mainly for sending it over to the frontend for info/logging purposes.
#[derive(Debug, Error, Serialize)]
pub(crate) enum StartupError {
/// Represents errors related to window lookup failures.
#[error("Window not found: {0}")]
WindowNotFound(WindowNotFoundError),
/// Represents a general error from the Tauri runtime.
///
/// This variant is used for any errors originating from Tauri that don't
/// fit into more specific categories.
#[error("Tauri error: {0}")]
Tauri(String),
}
/// Functions that are part of the startup process should return this result type.
/// This allows for consistent error handling and makes it clear that the function
/// is part of the startup flow.
///
/// ```
/// use your_crate::error::{StartupResult, StartupError};
///
/// fn some_startup_function() -> StartupResult<()> {
/// // Function implementation
/// Ok(())
/// }
/// ```
pub(crate) type StartupResult<T> = std::result::Result<T, StartupError>;

View File

@@ -0,0 +1,186 @@
use log::{error, info};
use tauri::{Manager, Runtime, Window};
use super::error::{StartupError, StartupResult, WindowNotFoundError};
/// Shows the `main` labeled application window.
///
/// This function is designed to be called as a Tauri command.
///
/// # Arguments
///
/// * `window` - A `Window` instance representing the current window. This is automatically
/// provided by Tauri when the command is invoked.
///
/// # Returns
///
/// Returns a `StartupResult<(), String>`:
/// - `Ok(())` if showing main window operation succeed.
/// - `Err(StartupError)` containing an error message if any operation fails.
///
/// # Errors
///
/// This function will return an error if:
/// - The "main" window is not found.
/// - Showing the main window fails.
///
/// # Example
///
/// ```rust,no_run
/// #[tauri::command]
/// async fn invoke_interop_startup_init(window: tauri::Window) {
/// match interop_startup_init(window).await {
/// Ok(_) => println!("`main` window shown successfully"),
/// Err(e) => eprintln!("Error: {}", e),
/// }
/// }
/// ```
#[tauri::command]
pub async fn interop_startup_init<R: Runtime>(window: Window<R>) -> StartupResult<()> {
let main_window = window.get_window("main").ok_or_else(|| {
error!("No window labeled 'main' found");
StartupError::WindowNotFound(WindowNotFoundError::Main)
})?;
main_window.show().map_err(|e| {
error!("Failed to show `main` window: {}", e);
StartupError::Tauri(format!("Failed to show `main` window: {}", e))
})?;
info!("`main` window shown successfully");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tauri::test::{assert_ipc_response, mock_builder, mock_context, noop_assets};
use tauri::{InvokePayload, WindowBuilder, WindowUrl};
fn create_app<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::App<R> {
builder
.invoke_handler(tauri::generate_handler![interop_startup_init])
.build(mock_context(noop_assets()))
.expect("failed to build mock app")
}
/// Test: Main window shown successfully in isolation
///
/// Rationale:
/// This test verifies the core functionality of `interop_startup_init`.
/// A failure indicates a fundamental issue with the app's initialization process.
///
/// Context:
/// The "main" window is typically the primary interface,
/// so ensuring it shows correctly is important.
///
/// Key Points:
/// - We use a mock Tauri application to isolate the window showing behavior.
/// - The test focuses solely on the "main" window to verify the basic case works correctly.
///
/// Assumptions:
/// - The Tauri runtime is functioning correctly.
/// - A window labeled "main" exists in the application.
/// For this see `tauri.conf.json`:
/// ```json
/// {
/// ...
/// "label": "main",
/// "title": "Hoppscotch",
/// ...
/// ...
/// }
/// ```
///
/// Implications of Failure:
/// 1. The window labeling system is broken.
/// 2. There's an issue with Tauri's window management.
/// 3. The `interop_startup_init` function is not correctly implemented.
#[tokio::test]
async fn test_interop_startup_init_main_window_shown_successfully() {
let app = create_app(mock_builder());
let window = app.get_window("main").expect("`main` window not found");
let result = interop_startup_init(window).await;
assert!(result.is_ok(), "Expected Ok, but got {:?}", result);
}
/// Test: Main window found and shown amongst other windows
///
/// Rationale:
/// This test ensures `interop_startup_init` can correctly identify and show the main window
/// in a more complex scenario with multiple windows.
///
/// Context:
/// As applications grow, they may introduce additional windows for various purposes. The ability
/// to consistently identify and manipulate the main window is important for maintaining
/// expected behavior.
///
/// Key Points:
/// - We create an additional "other" window to simulate another window.
/// - The test verifies that the presence of other windows doesn't interfere with main window operations.
///
/// Assumptions:
/// - The window labeling system consistently identifies the "main" window regardless of other windows.
/// - The order of window creation doesn't affect the ability to find the main window.
///
/// Implications of Failure:
/// 1. The window identification logic breaks with multiple windows.
#[tokio::test]
async fn test_interop_startup_init_main_window_found_amongst_others() {
let app = create_app(mock_builder());
let _ = WindowBuilder::new(&app, "other", WindowUrl::default())
.build()
.expect("Failed to create other window");
let window = app.get_window("other").expect("`other` window not found");
let result = interop_startup_init(window).await;
assert!(result.is_ok(), "Expected `Ok(())`, but got {:?}", result);
}
/// Test: IPC invocation of interop startup init
///
/// Rationale:
/// This test makes sure that `interop_startup_init` can be correctly invoked through Tauri's IPC mechanism.
/// It's important because it verifies the integration between the Rust backend and the frontend
/// that would typically call this function.
///
/// Context:
/// This test simulates scenarios where operations are initiated from the frontend via IPC calls.
///
/// Key Points:
/// - We're testing the IPC invocation, not just the direct function call.
/// - This verifies both the function's behavior and its correct registration with Tauri's IPC system.
///
/// Assumptions:
/// - The Tauri IPC system is functioning correctly.
/// - The `interop_startup_init` function is properly registered as a Tauri command.
///
/// Implications of Failure:
/// 1. There's a mismatch between how the frontend tries to call the function and how it's implemented.
/// 2. The Tauri command registration is incorrect.
/// 3. The function isn't properly handling the IPC context.
#[tokio::test]
async fn test_ipc_interop_startup_init() {
let app = create_app(mock_builder());
let window = app.get_window("main").expect("main window not found");
let payload = InvokePayload {
cmd: "interop_startup_init".into(),
tauri_module: None,
callback: tauri::api::ipc::CallbackFn(0),
error: tauri::api::ipc::CallbackFn(1),
inner: json!(null),
invoke_key: Some("__invoke-key__".to_string()),
};
assert_ipc_response(&window, payload, Ok(()));
}
}

View File

@@ -0,0 +1,7 @@
//! Startup management module.
//!
//! This module contains functionality related to managing the application's startup
//! like controlling visibility and lifecycle of the main application windows.
pub(crate) mod init;
pub(crate) mod error;

View File

@@ -16,6 +16,7 @@ mod mac;
mod win;
mod interceptor;
mod interop;
use tauri::Manager;
@@ -24,7 +25,31 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.invoke_handler(tauri::generate_handler![
interop::startup::init::interop_startup_init
])
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
// NOTE:
// The app (window labeled "main") manages its visible state via `interop_startup_init`.
// See `tauri.conf.json`:
// ```json
// {
// "label": "main",
// "title": "Hoppscotch",
// ...
// ...
// "visible": false, // This is the important part.
// ...
// ...
// }
// ```
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE,
)
.build(),
)
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(interceptor::init())
.setup(|app| {

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Hoppscotch",
"version": "24.7.0"
"version": "24.11.0"
},
"tauri": {
"allowlist": {
@@ -56,9 +56,11 @@
},
"windows": [
{
"label": "main",
"title": "Hoppscotch",
"visible": false,
"fullscreen": false,
"resizable": true,
"title": "Hoppscotch",
"width": 800,
"height": 600,
"fileDropEnabled": false