Cookie Support (#19)

This commit is contained in:
Gregory Schier
2024-01-28 14:39:51 -08:00
committed by GitHub
parent 0555420ad9
commit 7d183c6580
45 changed files with 1152 additions and 145 deletions

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO cookie_jars (\n id,\n workspace_id,\n name,\n cookies\n )\n VALUES (?, ?, ?, ?)\n ON CONFLICT (id) DO UPDATE SET\n updated_at = CURRENT_TIMESTAMP,\n name = excluded.name,\n cookies = excluded.cookies\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "b5ed4dc77f8cf21de1a06f146e47821bdb51fcfe747170bea41e7a366d736bda"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n DELETE FROM cookie_jars\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b98609f65dd3a6bbd1ea8dc8bed2840a6d5d13fec1bbc0aa61ca4f60de98a09c"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n workspace_id,\n name,\n cookies AS \"cookies!: sqlx::types::Json<Vec<JsonValue>>\"\n FROM cookie_jars WHERE workspace_id = ?\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "workspace_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "cookies!: sqlx::types::Json<Vec<JsonValue>>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "cb939b45a715d91f7631dea6b2d1bdc59fb3dffbd44ff99bc15adb34ea7093f7"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n model,\n created_at,\n updated_at,\n workspace_id,\n name,\n cookies AS \"cookies!: sqlx::types::Json<Vec<JsonValue>>\"\n FROM cookie_jars WHERE id = ?\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "model",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "updated_at",
"ordinal": 3,
"type_info": "Datetime"
},
{
"name": "workspace_id",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "cookies!: sqlx::types::Json<Vec<JsonValue>>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "f2ba4708d4a9ff9ce74c407a730040bd7883e9a5c0eb79ef0d8a6782a8eae299"
}

129
src-tauri/Cargo.lock generated
View File

@@ -83,9 +83,9 @@ checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.5" version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc2d0cfb2a7388d34f590e76686704c494ed7aaceed62ee1ba35cbf363abc2a5" checksum = "a116f46a969224200a0a97f29cfd4c50e7534e4b4826bd23ea2c3c533039c82c"
dependencies = [ dependencies = [
"brotli", "brotli",
"flate2", "flate2",
@@ -614,6 +614,72 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "cookie"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
dependencies = [
"percent-encoding",
"time",
"version_check 0.9.4",
]
[[package]]
name = "cookie"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24"
dependencies = [
"percent-encoding",
"time",
"version_check 0.9.4",
]
[[package]]
name = "cookie"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd91cf61412820176e137621345ee43b3f4423e589e7ae4e50d601d93e35ef8"
dependencies = [
"time",
"version_check 0.9.4",
]
[[package]]
name = "cookie_store"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d606d0fba62e13cf04db20536c05cb7f13673c161cb47a47a82b9b9e7d3f1daa"
dependencies = [
"cookie 0.16.2",
"idna 0.2.3",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "cookie_store"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "387461abbc748185c3a6e1673d826918b450b87ff22639429c694619a83b6cf6"
dependencies = [
"cookie 0.17.0",
"idna 0.3.0",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.3" version = "0.9.3"
@@ -1955,6 +2021,27 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
dependencies = [
"matches",
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "idna"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "0.4.0" version = "0.4.0"
@@ -3143,6 +3230,22 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "publicsuffix"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457"
dependencies = [
"idna 0.3.0",
"psl-types",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.31.0" version = "0.31.0"
@@ -3339,13 +3442,15 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.11.22" version = "0.11.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046cd98826c46c2ac8ddecae268eb5c2e58628688a5fc7a2643704a73faba95b" checksum = "37b1ae8d9ac08420c66222fb9096fc5de435c3c48542bc5336c51892cffafb41"
dependencies = [ dependencies = [
"async-compression", "async-compression",
"base64 0.21.5", "base64 0.21.5",
"bytes", "bytes",
"cookie 0.16.2",
"cookie_store 0.16.2",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
"futures-util", "futures-util",
@@ -3379,6 +3484,18 @@ dependencies = [
"winreg 0.50.0", "winreg 0.50.0",
] ]
[[package]]
name = "reqwest_cookie_store"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba529055ea150e42e4eb9c11dcd380a41025ad4d594b0cb4904ef28b037e1061"
dependencies = [
"bytes",
"cookie_store 0.20.0",
"reqwest",
"url",
]
[[package]] [[package]]
name = "rfd" name = "rfd"
version = "0.10.0" version = "0.10.0"
@@ -4974,7 +5091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5"
dependencies = [ dependencies = [
"form_urlencoded", "form_urlencoded",
"idna", "idna 0.4.0",
"percent-encoding", "percent-encoding",
"serde", "serde",
] ]
@@ -5805,6 +5922,7 @@ dependencies = [
"boa_runtime", "boa_runtime",
"chrono", "chrono",
"cocoa 0.25.0", "cocoa 0.25.0",
"cookie 0.18.0",
"datetime", "datetime",
"futures", "futures",
"http", "http",
@@ -5813,6 +5931,7 @@ dependencies = [
"openssl-sys", "openssl-sys",
"rand 0.8.5", "rand 0.8.5",
"reqwest", "reqwest",
"reqwest_cookie_store",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",

View File

@@ -28,7 +28,8 @@ chrono = { version = "0.4.31", features = ["serde"] }
futures = "0.3.26" futures = "0.3.26"
http = "0.2.8" http = "0.2.8"
rand = "0.8.5" rand = "0.8.5"
reqwest = { version = "0.11.14", features = ["json", "multipart", "gzip", "brotli", "deflate"] } reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate"] }
cookie = { version = "0.18.0" }
serde = { version = "1.0.195", features = ["derive"] } serde = { version = "1.0.195", features = ["derive"] }
serde_json = { version = "1.0.111", features = ["raw_value"] } serde_json = { version = "1.0.111", features = ["raw_value"] }
sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] } sqlx = { version = "0.7.2", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
@@ -57,6 +58,7 @@ uuid = "1.3.0"
log = "0.4.20" log = "0.4.20"
datetime = "0.5.2" datetime = "0.5.2"
window-shadows = "0.2.2" window-shadows = "0.2.2"
reqwest_cookie_store = "0.6.0"
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode

View File

@@ -0,0 +1,10 @@
CREATE TABLE cookie_jars
(
id TEXT NOT NULL PRIMARY KEY,
model TEXT DEFAULT 'cookie_jar' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
name TEXT NOT NULL,
cookies TEXT DEFAULT '[]' NOT NULL,
workspace_id TEXT NOT NULL
);

View File

@@ -14,6 +14,7 @@ pub enum AnalyticsResource {
App, App,
Sidebar, Sidebar,
Workspace, Workspace,
CookieJar,
Environment, Environment,
Folder, Folder,
HttpRequest, HttpRequest,
@@ -28,6 +29,7 @@ impl AnalyticsResource {
"Sidebar" => Some(AnalyticsResource::Sidebar), "Sidebar" => Some(AnalyticsResource::Sidebar),
"Workspace" => Some(AnalyticsResource::Workspace), "Workspace" => Some(AnalyticsResource::Workspace),
"Environment" => Some(AnalyticsResource::Environment), "Environment" => Some(AnalyticsResource::Environment),
"CookieJar" => Some(AnalyticsResource::CookieJar),
"Folder" => Some(AnalyticsResource::Folder), "Folder" => Some(AnalyticsResource::Folder),
"HttpRequest" => Some(AnalyticsResource::HttpRequest), "HttpRequest" => Some(AnalyticsResource::HttpRequest),
"HttpResponse" => Some(AnalyticsResource::HttpResponse), "HttpResponse" => Some(AnalyticsResource::HttpResponse),
@@ -50,6 +52,8 @@ pub enum AnalyticsAction {
Send, Send,
Toggle, Toggle,
Duplicate, Duplicate,
Import,
Export,
} }
impl AnalyticsAction { impl AnalyticsAction {
@@ -66,6 +70,8 @@ impl AnalyticsAction {
"Send" => Some(AnalyticsAction::Send), "Send" => Some(AnalyticsAction::Send),
"Duplicate" => Some(AnalyticsAction::Duplicate), "Duplicate" => Some(AnalyticsAction::Duplicate),
"Toggle" => Some(AnalyticsAction::Toggle), "Toggle" => Some(AnalyticsAction::Toggle),
"Import" => Some(AnalyticsAction::Import),
"Export" => Some(AnalyticsAction::Export),
_ => None, _ => None,
} }
} }
@@ -77,6 +83,7 @@ fn resource_name(resource: AnalyticsResource) -> &'static str {
AnalyticsResource::Sidebar => "sidebar", AnalyticsResource::Sidebar => "sidebar",
AnalyticsResource::Workspace => "workspace", AnalyticsResource::Workspace => "workspace",
AnalyticsResource::Environment => "environment", AnalyticsResource::Environment => "environment",
AnalyticsResource::CookieJar => "cookie_jar",
AnalyticsResource::Folder => "folder", AnalyticsResource::Folder => "folder",
AnalyticsResource::HttpRequest => "http_request", AnalyticsResource::HttpRequest => "http_request",
AnalyticsResource::HttpResponse => "http_response", AnalyticsResource::HttpResponse => "http_response",
@@ -97,6 +104,8 @@ fn action_name(action: AnalyticsAction) -> &'static str {
AnalyticsAction::Send => "send", AnalyticsAction::Send => "send",
AnalyticsAction::Duplicate => "duplicate", AnalyticsAction::Duplicate => "duplicate",
AnalyticsAction::Toggle => "toggle", AnalyticsAction::Toggle => "toggle",
AnalyticsAction::Import => "import",
AnalyticsAction::Export => "export",
} }
} }

View File

@@ -2,16 +2,18 @@ use std::fs;
use std::fs::{create_dir_all, File}; use std::fs::{create_dir_all, File};
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use base64::Engine; use base64::Engine;
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use http::header::{ACCEPT, USER_AGENT}; use http::header::{ACCEPT, USER_AGENT};
use http::{HeaderMap, HeaderName, HeaderValue, Method};
use log::{error, info, warn}; use log::{error, info, warn};
use reqwest::multipart;
use reqwest::redirect::Policy; use reqwest::redirect::Policy;
use reqwest::{multipart, Url};
use sqlx::types::{Json, JsonValue};
use sqlx::{Pool, Sqlite}; use sqlx::{Pool, Sqlite};
use sqlx::types::Json;
use tauri::{AppHandle, Wry}; use tauri::{AppHandle, Wry};
use crate::{emit_side_effect, models, render, response_err}; use crate::{emit_side_effect, models, render, response_err};
@@ -19,13 +21,13 @@ use crate::{emit_side_effect, models, render, response_err};
pub async fn send_http_request( pub async fn send_http_request(
request: models::HttpRequest, request: models::HttpRequest,
response: &models::HttpResponse, response: &models::HttpResponse,
environment_id: &str, environment: Option<models::Environment>,
cookie_jar: Option<models::CookieJar>,
app_handle: &AppHandle<Wry>, app_handle: &AppHandle<Wry>,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,
download_path: Option<PathBuf>, download_path: Option<PathBuf>,
) -> Result<models::HttpResponse, String> { ) -> Result<models::HttpResponse, String> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let environment = models::get_environment(environment_id, pool).await.ok();
let environment_ref = environment.as_ref(); let environment_ref = environment.as_ref();
let workspace = models::get_workspace(&request.workspace_id, pool) let workspace = models::get_workspace(&request.workspace_id, pool)
.await .await
@@ -49,6 +51,32 @@ pub async fn send_http_request(
.danger_accept_invalid_certs(!workspace.setting_validate_certificates) .danger_accept_invalid_certs(!workspace.setting_validate_certificates)
.tls_info(true); .tls_info(true);
// Add cookie store if specified
let maybe_cookie_manager = match cookie_jar.clone() {
Some(cj) => {
// HACK: Can't construct Cookie without serde, so we have to do this
let cookies = cj
.cookies
.0
.iter()
.map(|json_cookie| {
serde_json::from_value(json_cookie.clone())
.expect("Failed to deserialize cookie")
})
.map(|c| Ok(c))
.collect::<Vec<Result<_, ()>>>();
let store = reqwest_cookie_store::CookieStore::from_cookies(cookies, true)
.expect("Failed to create cookie store");
let cookie_store = reqwest_cookie_store::CookieStoreMutex::new(store);
let cookie_store = Arc::new(cookie_store);
client_builder = client_builder.cookie_provider(Arc::clone(&cookie_store));
Some((cookie_store, cj))
}
None => None,
};
if workspace.setting_request_timeout > 0 { if workspace.setting_request_timeout > 0 {
client_builder = client_builder.timeout(Duration::from_millis( client_builder = client_builder.timeout(Duration::from_millis(
workspace.setting_request_timeout.unsigned_abs(), workspace.setting_request_timeout.unsigned_abs(),
@@ -58,14 +86,37 @@ pub async fn send_http_request(
// .use_rustls_tls() // TODO: Make this configurable (maybe) // .use_rustls_tls() // TODO: Make this configurable (maybe)
let client = client_builder.build().expect("Failed to build client"); let client = client_builder.build().expect("Failed to build client");
let url = match Url::from_str(url_string.as_str()) {
Ok(u) => u,
Err(e) => {
return response_err(response, e.to_string(), app_handle, pool).await;
}
};
let m = Method::from_bytes(request.method.to_uppercase().as_bytes()) let m = Method::from_bytes(request.method.to_uppercase().as_bytes())
.expect("Failed to create method"); .expect("Failed to create method");
let mut request_builder = client.request(m, url_string.to_string()); let mut request_builder = client.request(m, url.clone());
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("yaak")); headers.insert(USER_AGENT, HeaderValue::from_static("yaak"));
headers.insert(ACCEPT, HeaderValue::from_static("*/*")); headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
// TODO: Set cookie header ourselves once we also handle redirects. We need to do this
// because reqwest doesn't give us a way to inspect the headers it sent (we have to do
// everything manually to know that).
// if let Some(cookie_store) = maybe_cookie_store.clone() {
// let values1 = cookie_store.get_request_values(&url);
// println!("COOKIE VLUAES: {:?}", values1.collect::<Vec<_>>());
// let raw_value = cookie_store.get_request_values(&url)
// .map(|(name, value)| format!("{}={}", name, value))
// .collect::<Vec<_>>()
// .join("; ");
// headers.insert(
// COOKIE,
// HeaderValue::from_str(&raw_value).expect("Failed to create cookie header"),
// );
// }
for h in request.headers.0 { for h in request.headers.0 {
if h.name.is_empty() && h.value.is_empty() { if h.name.is_empty() && h.value.is_empty() {
continue; continue;
@@ -252,10 +303,11 @@ pub async fn send_http_request(
match raw_response { match raw_response {
Ok(v) => { Ok(v) => {
let mut response = response.clone(); let mut response = response.clone();
let response_headers = v.headers().clone();
response.status = v.status().as_u16() as i64; response.status = v.status().as_u16() as i64;
response.status_reason = v.status().canonical_reason().map(|s| s.to_string()); response.status_reason = v.status().canonical_reason().map(|s| s.to_string());
response.headers = Json( response.headers = Json(
v.headers() response_headers
.iter() .iter()
.map(|(k, v)| models::HttpResponseHeader { .map(|(k, v)| models::HttpResponseHeader {
name: k.as_str().to_string(), name: k.as_str().to_string(),
@@ -304,15 +356,42 @@ pub async fn send_http_request(
match (download_path, response.body_path.clone()) { match (download_path, response.body_path.clone()) {
(Some(dl_path), Some(body_path)) => { (Some(dl_path), Some(body_path)) => {
info!("Downloading response body to {}", dl_path.display()); info!("Downloading response body to {}", dl_path.display());
fs::copy(body_path, dl_path).expect("Failed to copy file for response download"); fs::copy(body_path, dl_path)
.expect("Failed to copy file for response download");
} }
_ => {} _ => {}
}; };
// Add cookie store if specified
if let Some((cookie_store, mut cookie_jar)) = maybe_cookie_manager {
// let cookies = response_headers.get_all(SET_COOKIE).iter().map(|h| {
// println!("RESPONSE COOKIE: {}", h.to_str().unwrap());
// cookie_store::RawCookie::from_str(h.to_str().unwrap())
// .expect("Failed to parse cookie")
// });
// store.store_response_cookies(cookies, &url);
let json_cookies: Json<Vec<JsonValue>> = Json(
cookie_store
.lock()
.unwrap()
.iter_any()
.map(|c| serde_json::to_value(&c).expect("Failed to serialize cookie"))
.collect::<Vec<_>>(),
);
cookie_jar.cookies = json_cookies;
match models::upsert_cookie_jar(pool, &cookie_jar).await {
Ok(updated_jar) => {
emit_side_effect(app_handle, "updated_model", &updated_jar);
}
Err(e) => {
error!("Failed to update cookie jar: {}", e);
}
};
}
Ok(response) Ok(response)
} }
Err(e) => { Err(e) => response_err(response, e.to_string(), app_handle, pool).await,
response_err(response, e.to_string(), app_handle, pool).await
}
} }
} }

View File

@@ -3,27 +3,28 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
extern crate core;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
#[macro_use] #[macro_use]
extern crate objc; extern crate objc;
use std::collections::HashMap; use std::collections::HashMap;
use std::env::current_dir; use std::env::current_dir;
use std::fs::{create_dir_all, read_to_string, File}; use std::fs::{create_dir_all, File, read_to_string};
use std::process::exit; use std::process::exit;
use fern::colors::ColoredLevelConfig; use fern::colors::ColoredLevelConfig;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rand::random; use rand::random;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::{json, Value};
use sqlx::{Pool, Sqlite, SqlitePool};
use sqlx::migrate::Migrator; use sqlx::migrate::Migrator;
use sqlx::types::Json; use sqlx::types::Json;
use sqlx::{Pool, Sqlite, SqlitePool};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry}; use tauri::{AppHandle, RunEvent, State, Window, WindowUrl, Wry};
use tauri::{Manager, WindowEvent}; use tauri::{Manager, WindowEvent};
#[cfg(target_os = "macos")]
use tauri::TitleBarStyle;
use tauri_plugin_log::{fern, LogTarget}; use tauri_plugin_log::{fern, LogTarget};
use tauri_plugin_window_state::{StateFlags, WindowExt}; use tauri_plugin_window_state::{StateFlags, WindowExt};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -78,17 +79,36 @@ async fn migrate_db(
async fn send_ephemeral_request( async fn send_ephemeral_request(
mut request: models::HttpRequest, mut request: models::HttpRequest,
environment_id: Option<&str>, environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
app_handle: AppHandle<Wry>, app_handle: AppHandle<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::HttpResponse, String> { ) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
let response = models::HttpResponse::new(); let response = models::HttpResponse::new();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
request.id = "".to_string(); request.id = "".to_string();
let environment = match environment_id {
Some(id) => Some(
models::get_environment(id, pool)
.await
.expect("Failed to get environment"),
),
None => None,
};
let cookie_jar = match cookie_jar_id {
Some(id) => Some(
models::get_cookie_jar(id, pool)
.await
.expect("Failed to get cookie jar"),
),
None => None,
};
// let cookie_jar_id2 = cookie_jar_id.unwrap_or("").to_string();
send_http_request( send_http_request(
request, request,
&response, &response,
&environment_id2, environment,
cookie_jar,
&app_handle, &app_handle,
pool, pool,
None, None,
@@ -151,6 +171,13 @@ async fn import_data(
) )
.await .await
{ {
analytics::track_event(
&window.app_handle(),
AnalyticsResource::App,
AnalyticsAction::Import,
Some(json!({ "plugin": plugin_name })),
)
.await;
result = Some(r); result = Some(r);
break; break;
} }
@@ -217,8 +244,17 @@ async fn export_data(
serde_json::to_writer_pretty(&f, &export_data) serde_json::to_writer_pretty(&f, &export_data)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
.expect("Failed to write"); .expect("Failed to write");
f.sync_all().expect("Failed to sync"); f.sync_all().expect("Failed to sync");
info!("Exported Yaak workspace to {:?}", export_path);
analytics::track_event(
&app_handle,
AnalyticsResource::App,
AnalyticsAction::Export,
None,
)
.await;
Ok(()) Ok(())
} }
@@ -228,47 +264,56 @@ async fn send_request(
db_instance: State<'_, Mutex<Pool<Sqlite>>>, db_instance: State<'_, Mutex<Pool<Sqlite>>>,
request_id: &str, request_id: &str,
environment_id: Option<&str>, environment_id: Option<&str>,
cookie_jar_id: Option<&str>,
download_dir: Option<&str>, download_dir: Option<&str>,
) -> Result<models::HttpResponse, String> { ) -> Result<models::HttpResponse, String> {
let pool = &*db_instance.lock().await; let pool = &*db_instance.lock().await;
let app_handle = window.app_handle();
let req = models::get_request(request_id, pool) let request = models::get_request(request_id, pool)
.await .await
.expect("Failed to get request"); .expect("Failed to get request");
let response = models::create_response(&req.id, 0, "", 0, None, None, None, vec![], pool) let environment = match environment_id {
Some(id) => Some(
models::get_environment(id, pool)
.await
.expect("Failed to get environment"),
),
None => None,
};
let cookie_jar = match cookie_jar_id {
Some(id) => Some(
models::get_cookie_jar(id, pool)
.await
.expect("Failed to get cookie jar"),
),
None => None,
};
let response = models::create_response(&request.id, 0, "", 0, None, None, None, vec![], pool)
.await .await
.expect("Failed to create response"); .expect("Failed to create response");
let response2 = response.clone();
let environment_id2 = environment_id.unwrap_or("n/a").to_string();
let app_handle2 = window.app_handle().clone();
let pool2 = pool.clone();
let download_path = if let Some(p) = download_dir { let download_path = if let Some(p) = download_dir {
Some(std::path::Path::new(p).to_path_buf()) Some(std::path::Path::new(p).to_path_buf())
} else { } else {
None None
}; };
tokio::spawn(async move { emit_side_effect(&app_handle, "created_model", response.clone());
if let Err(e) = send_http_request(
req,
&response2,
&environment_id2,
&app_handle2,
&pool2,
download_path,
)
.await
{
response_err(&response2, e, &app_handle2, &pool2)
.await
.expect("Failed to update response");
}
});
emit_and_return(&window, "created_model", response) send_http_request(
request.clone(),
&response,
environment,
cookie_jar,
&app_handle,
&pool,
download_path,
)
.await
} }
async fn response_err( async fn response_err(
@@ -362,6 +407,57 @@ async fn create_workspace(
emit_and_return(&window, "created_model", created_workspace) emit_and_return(&window, "created_model", created_workspace)
} }
#[tauri::command]
async fn update_cookie_jar(
cookie_jar: models::CookieJar,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
println!("Updating cookie jar {}", cookie_jar.cookies.len());
let updated = models::upsert_cookie_jar(pool, &cookie_jar)
.await
.expect("Failed to update cookie jar");
emit_and_return(&window, "updated_model", updated)
}
#[tauri::command]
async fn delete_cookie_jar(
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
cookie_jar_id: &str,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
let req = models::delete_cookie_jar(cookie_jar_id, pool)
.await
.expect("Failed to delete cookie jar");
emit_and_return(&window, "deleted_model", req)
}
#[tauri::command]
async fn create_cookie_jar(
workspace_id: &str,
name: &str,
window: Window<Wry>,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
let created_cookie_jar = models::upsert_cookie_jar(
pool,
&models::CookieJar {
name: name.to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
},
)
.await
.expect("Failed to create cookie jar");
emit_and_return(&window, "created_model", created_cookie_jar)
}
#[tauri::command] #[tauri::command]
async fn create_environment( async fn create_environment(
workspace_id: &str, workspace_id: &str,
@@ -627,6 +723,44 @@ async fn get_request(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command]
async fn get_cookie_jar(
id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<models::CookieJar, String> {
let pool = &*db_instance.lock().await;
models::get_cookie_jar(id, pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_cookie_jars(
workspace_id: &str,
db_instance: State<'_, Mutex<Pool<Sqlite>>>,
) -> Result<Vec<models::CookieJar>, String> {
let pool = &*db_instance.lock().await;
let cookie_jars = models::find_cookie_jars(workspace_id, pool)
.await
.expect("Failed to find cookie jars");
if cookie_jars.is_empty() {
let cookie_jar = models::upsert_cookie_jar(
pool,
&models::CookieJar {
name: "Default".to_string(),
workspace_id: workspace_id.to_string(),
..Default::default()
},
)
.await
.expect("Failed to create CookieJar");
Ok(vec![cookie_jar])
} else {
Ok(cookie_jars)
}
}
#[tauri::command] #[tauri::command]
async fn get_environment( async fn get_environment(
id: &str, id: &str,
@@ -755,6 +889,7 @@ fn main() {
.level_for("tracing", log::LevelFilter::Info) .level_for("tracing", log::LevelFilter::Info)
.level_for("reqwest", log::LevelFilter::Info) .level_for("reqwest", log::LevelFilter::Info)
.level_for("tokio_util", log::LevelFilter::Info) .level_for("tokio_util", log::LevelFilter::Info)
.level_for("cookie_store", log::LevelFilter::Info)
.with_colors(ColoredLevelConfig::default()) .with_colors(ColoredLevelConfig::default())
.level(log::LevelFilter::Trace) .level(log::LevelFilter::Trace)
.build(), .build(),
@@ -807,11 +942,13 @@ fn main() {
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
check_for_updates, check_for_updates,
create_cookie_jar,
create_environment, create_environment,
create_folder, create_folder,
create_request, create_request,
create_workspace, create_workspace,
delete_all_responses, delete_all_responses,
delete_cookie_jar,
delete_environment, delete_environment,
delete_folder, delete_folder,
delete_request, delete_request,
@@ -820,13 +957,15 @@ fn main() {
duplicate_request, duplicate_request,
export_data, export_data,
filter_response, filter_response,
get_key_value, get_cookie_jar,
get_environment, get_environment,
get_folder, get_folder,
get_key_value,
get_request, get_request,
get_settings, get_settings,
get_workspace, get_workspace,
import_data, import_data,
list_cookie_jars,
list_environments, list_environments,
list_folders, list_folders,
list_requests, list_requests,
@@ -838,6 +977,7 @@ fn main() {
set_key_value, set_key_value,
set_update_mode, set_update_mode,
track_event, track_event,
update_cookie_jar,
update_environment, update_environment,
update_folder, update_folder,
update_request, update_request,

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use log::error;
use log::error;
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite}; use sqlx::{Pool, Sqlite};
@@ -57,6 +57,23 @@ impl Workspace {
} }
} }
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
pub struct CookieX {
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct CookieJar {
pub id: String,
pub model: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub workspace_id: String,
pub name: String,
pub cookies: Json<Vec<JsonValue>>,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)] #[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
pub struct Environment { pub struct Environment {
@@ -351,6 +368,96 @@ pub async fn delete_workspace(id: &str, pool: &Pool<Sqlite>) -> Result<Workspace
Ok(workspace) Ok(workspace)
} }
pub async fn get_cookie_jar(id: &str, pool: &Pool<Sqlite>) -> Result<CookieJar, sqlx::Error> {
sqlx::query_as!(
CookieJar,
r#"
SELECT
id,
model,
created_at,
updated_at,
workspace_id,
name,
cookies AS "cookies!: sqlx::types::Json<Vec<JsonValue>>"
FROM cookie_jars WHERE id = ?
"#,
id,
)
.fetch_one(pool)
.await
}
pub async fn find_cookie_jars(workspace_id: &str, pool: &Pool<Sqlite>) -> Result<Vec<CookieJar>, sqlx::Error> {
sqlx::query_as!(
CookieJar,
r#"
SELECT
id,
model,
created_at,
updated_at,
workspace_id,
name,
cookies AS "cookies!: sqlx::types::Json<Vec<JsonValue>>"
FROM cookie_jars WHERE workspace_id = ?
"#,
workspace_id,
)
.fetch_all(pool)
.await
}
pub async fn delete_cookie_jar(id: &str, pool: &Pool<Sqlite>) -> Result<CookieJar, sqlx::Error> {
let cookie_jar = get_cookie_jar(id, pool).await?;
let _ = sqlx::query!(
r#"
DELETE FROM cookie_jars
WHERE id = ?
"#,
id,
)
.execute(pool)
.await;
Ok(cookie_jar)
}
pub async fn upsert_cookie_jar(
pool: &Pool<Sqlite>,
cookie_jar: &CookieJar,
) -> Result<CookieJar, sqlx::Error> {
let id = match cookie_jar.id.as_str() {
"" => generate_id(Some("cj")),
_ => cookie_jar.id.to_string(),
};
let trimmed_name = cookie_jar.name.trim();
sqlx::query!(
r#"
INSERT INTO cookie_jars (
id,
workspace_id,
name,
cookies
)
VALUES (?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
updated_at = CURRENT_TIMESTAMP,
name = excluded.name,
cookies = excluded.cookies
"#,
id,
cookie_jar.workspace_id,
trimmed_name,
cookie_jar.cookies,
)
.execute(pool)
.await?;
get_cookie_jar(&id, pool).await
}
pub async fn find_environments( pub async fn find_environments(
workspace_id: &str, workspace_id: &str,
pool: &Pool<Sqlite>, pool: &Pool<Sqlite>,

View File

@@ -8,7 +8,7 @@
}, },
"package": { "package": {
"productName": "Yaak", "productName": "Yaak",
"version": "2024.1.0" "version": "2024.2.0"
}, },
"tauri": { "tauri": {
"windows": [], "windows": [],

View File

@@ -0,0 +1,74 @@
import { useCookieJars } from '../hooks/useCookieJars';
import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar';
import { cookieDomain } from '../lib/models';
import { Banner } from './core/Banner';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
interface Props {
cookieJarId: string | null;
}
export const CookieDialog = function ({ cookieJarId }: Props) {
const updateCookieJar = useUpdateCookieJar(cookieJarId ?? null);
const cookieJars = useCookieJars();
const cookieJar = cookieJars.find((c) => c.id === cookieJarId);
if (cookieJar == null) {
return <div>No cookie jar selected</div>;
}
if (cookieJar.cookies.length === 0) {
return (
<Banner>
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
</Banner>
);
}
return (
<div className="pb-2">
<table className="w-full text-xs mb-auto min-w-full max-w-full divide-y">
<thead>
<tr>
<th className="py-2 text-left">Domain</th>
<th className="py-2 text-left pl-4">Cookie</th>
<th className="py-2 pl-4"></th>
</tr>
</thead>
<tbody className="divide-y">
{cookieJar?.cookies.map((c) => (
<tr key={c.domain + c.raw_cookie}>
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
{cookieDomain(c)}
</td>
<td className="py-2 pl-4 select-text cursor-text font-mono text-gray-700 whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
{c.raw_cookie}
</td>
<td className="max-w-0 w-10">
<IconButton
icon="trash"
size="xs"
iconSize="sm"
title="Delete"
className="ml-auto"
onClick={async () => {
console.log(
'DELETE COOKIE',
c,
cookieJar.cookies.filter((c2) => c2 !== c).length,
);
await updateCookieJar.mutateAsync({
...cookieJar,
cookies: cookieJar.cookies.filter((c2) => c2 !== c),
});
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,93 @@
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useCookieJars } from '../hooks/useCookieJars';
import { useCreateCookieJar } from '../hooks/useCreateCookieJar';
import { useDeleteCookieJar } from '../hooks/useDeleteCookieJar';
import { usePrompt } from '../hooks/usePrompt';
import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar';
import { CookieDialog } from './CookieDialog';
import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { useDialog } from './DialogContext';
export function CookieDropdown() {
const cookieJars = useCookieJars();
const { activeCookieJar, setActiveCookieJarId } = useActiveCookieJar();
const updateCookieJar = useUpdateCookieJar(activeCookieJar?.id ?? null);
const deleteCookieJar = useDeleteCookieJar(activeCookieJar ?? null);
const createCookieJar = useCreateCookieJar();
const dialog = useDialog();
const prompt = usePrompt();
return (
<Dropdown
items={[
...cookieJars.map((j) => ({
key: j.id,
label: j.name,
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? 'check' : 'empty'} />,
onSelect: () => setActiveCookieJarId(j.id),
})),
...((cookieJars.length > 0 && activeCookieJar != null
? [
{ type: 'separator', label: activeCookieJar.name },
{
key: 'manage',
label: 'Manage Cookies',
leftSlot: <Icon icon="cookie" />,
onSelect: () => {
if (activeCookieJar == null) return;
dialog.show({
id: 'cookies',
title: 'Manage Cookies',
size: 'full',
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
});
},
},
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
title: 'Rename Cookie Jar',
description: (
<>
Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>
</>
),
name: 'name',
label: 'Name',
defaultValue: activeCookieJar?.name,
});
updateCookieJar.mutate({ name });
},
},
...((cookieJars.length > 1 // Never delete the last one
? [
{
key: 'delete',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
variant: 'danger',
onSelect: () => deleteCookieJar.mutateAsync(),
},
]
: []) as DropdownItem[]),
]
: []) as DropdownItem[]),
{ type: 'separator' },
{
key: 'create-cookie-jar',
label: 'New Cookie Jar',
leftSlot: <Icon icon="plus" />,
onSelect: () => createCookieJar.mutate(),
},
]}
>
<IconButton size="sm" icon="cookie" title="Cookie Jar" />
</Dropdown>
);
}

View File

@@ -54,23 +54,26 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
...((environments.length > 0 ...((environments.length > 0
? [{ type: 'separator', label: 'Environments' }] ? [{ type: 'separator', label: 'Environments' }]
: []) as DropdownItem[]), : []) as DropdownItem[]),
environments.length ...((environments.length > 0
? { ? [
key: 'edit', {
label: 'Manage Environments', key: 'edit',
hotKeyAction: 'environmentEditor.toggle', label: 'Manage Environments',
leftSlot: <Icon icon="box" />, hotKeyAction: 'environmentEditor.toggle',
onSelect: showEnvironmentDialog, leftSlot: <Icon icon="box" />,
} onSelect: showEnvironmentDialog,
: {
key: 'new',
label: 'New Environment',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createEnvironment.mutateAsync();
showEnvironmentDialog();
}, },
}, ]
: []) as DropdownItem[]),
{
key: 'new',
label: 'New Environment',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createEnvironment.mutateAsync();
showEnvironmentDialog();
},
},
], ],
[activeEnvironment?.id, createEnvironment, environments, routes, showEnvironmentDialog], [activeEnvironment?.id, createEnvironment, environments, routes, showEnvironmentDialog],
); );

View File

@@ -51,12 +51,12 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
return ( return (
<div <div
className={classNames( className={classNames(
'h-full grid gap-x-8 grid-rows-[minmax(0,1fr)]', 'h-full pt-1 grid gap-x-8 grid-rows-[minmax(0,1fr)]',
showSidebar ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]', showSidebar ? 'grid-cols-[auto_minmax(0,1fr)]' : 'grid-cols-[minmax(0,1fr)]',
)} )}
> >
{showSidebar && ( {showSidebar && (
<aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2"> <aside className="grid grid-rows-[minmax(0,1fr)_auto] gap-y-0.5 h-full max-w-[250px] pr-3 border-r border-gray-100 -ml-2 pb-4">
<div className="min-w-0 h-full w-full overflow-y-scroll"> <div className="min-w-0 h-full w-full overflow-y-scroll">
{environments.map((e) => ( {environments.map((e) => (
<SidebarButton <SidebarButton

View File

@@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window'; import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
import { keyValueQueryKey } from '../hooks/useKeyValue'; import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -55,6 +56,8 @@ export function GlobalHooks() {
? keyValueQueryKey(payload) ? keyValueQueryKey(payload)
: payload.model === 'settings' : payload.model === 'settings'
? settingsQueryKey() ? settingsQueryKey()
: payload.model === 'cookie_jar'
? cookieJarsQueryKey(payload)
: null; : null;
if (queryKey === null) { if (queryKey === null) {
@@ -80,6 +83,8 @@ export function GlobalHooks() {
? workspacesQueryKey(payload) ? workspacesQueryKey(payload)
: payload.model === 'key_value' : payload.model === 'key_value'
? keyValueQueryKey(payload) ? keyValueQueryKey(payload)
: payload.model === 'cookie_jar'
? cookieJarsQueryKey(payload)
: payload.model === 'settings' : payload.model === 'settings'
? settingsQueryKey() ? settingsQueryKey()
: null; : null;
@@ -115,6 +120,8 @@ export function GlobalHooks() {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload)); queryClient.setQueryData<HttpResponse[]>(responsesQueryKey(payload), removeById(payload));
} else if (payload.model === 'key_value') { } else if (payload.model === 'key_value') {
queryClient.setQueryData(keyValueQueryKey(payload), undefined); queryClient.setQueryData(keyValueQueryKey(payload), undefined);
} else if (payload.model === 'cookie_jar') {
queryClient.setQueryData(cookieJarsQueryKey(payload), undefined);
} else if (payload.model === 'settings') { } else if (payload.model === 'settings') {
queryClient.setQueryData(settingsQueryKey(), undefined); queryClient.setQueryData(settingsQueryKey(), undefined);
} }

View File

@@ -84,39 +84,41 @@ export function GraphQLEditor({ defaultValue, onChange, baseRequest, ...extraEdi
actions={ actions={
error || isLoading error || isLoading
? [ ? [
<Button <div key="introspection" className="!opacity-100">
key="introspection" <Button
size="xs" key="introspection"
color={error ? 'danger' : 'gray'} size="xs"
isLoading={isLoading} color={error ? 'danger' : 'gray'}
onClick={() => { isLoading={isLoading}
dialog.show({ onClick={() => {
title: 'Introspection Failed', dialog.show({
size: 'dynamic', title: 'Introspection Failed',
id: 'introspection-failed', size: 'dynamic',
render: () => ( id: 'introspection-failed',
<> render: () => (
<FormattedError>{error ?? 'unknown'}</FormattedError> <>
<div className="w-full mt-3"> <FormattedError>{error ?? 'unknown'}</FormattedError>
<Button <div className="w-full my-4">
onClick={() => { <Button
dialog.hide('introspection-failed'); onClick={() => {
refetch(); dialog.hide('introspection-failed');
}} refetch();
className="ml-auto" }}
color="secondary" className="ml-auto"
size="sm" color="secondary"
> size="sm"
Try Again >
</Button> Try Again
</div> </Button>
</> </div>
), </>
}); ),
}} });
> }}
{error ? 'Introspection Failed' : 'Introspecting'} >
</Button>, {error ? 'Introspection Failed' : 'Introspecting'}
</Button>
</div>,
] ]
: [] : []
} }

View File

@@ -3,7 +3,7 @@ import { HotKeyList } from './core/HotKeyList';
export const KeyboardShortcutsDialog = () => { export const KeyboardShortcutsDialog = () => {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full pb-2">
<HotKeyList hotkeys={hotkeyActions} /> <HotKeyList hotkeys={hotkeyActions} />
</div> </div>
); );

View File

@@ -99,7 +99,11 @@ export const ResponsePane = memo(function ResponsePane({ style, className }: Pro
'shadow shadow-gray-100 dark:shadow-gray-0 relative', 'shadow shadow-gray-100 dark:shadow-gray-0 relative',
)} )}
> >
{activeResponse?.error && <Banner className="m-2">{activeResponse.error}</Banner>} {activeResponse?.error && (
<Banner color="danger" className="m-2">
{activeResponse.error}
</Banner>
)}
{!activeResponse && ( {!activeResponse && (
<> <>
<span /> <span />

View File

@@ -20,7 +20,7 @@ export const SettingsDialog = () => {
} }
return ( return (
<VStack space={2} className="mb-2"> <VStack space={2} className="mb-4">
<Select <Select
name="appearance" name="appearance"
label="Appearance" label="Appearance"

View File

@@ -71,7 +71,7 @@ export function SettingsDropdown() {
size: 'sm', size: 'sm',
render: ({ hide }) => { render: ({ hide }) => {
return ( return (
<VStack space={3}> <VStack space={3} className="pb-4">
<p>Insomnia or Postman Collection v2/v2.1 formats are supported</p> <p>Insomnia or Postman Collection v2/v2.1 formats are supported</p>
<Button <Button
size="sm" size="sm"

View File

@@ -51,7 +51,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
), ),
render: ({ hide }) => { render: ({ hide }) => {
return ( return (
<HStack space={2} justifyContent="end" alignItems="center" className="mt-6"> <HStack space={2} justifyContent="end" alignItems="center" className="mt-4 mb-6">
<Button <Button
className="focus" className="focus"
color="gray" color="gray"
@@ -135,6 +135,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
label: 'Name', label: 'Name',
defaultValue: 'My Workspace', defaultValue: 'My Workspace',
title: 'New Workspace', title: 'New Workspace',
confirmLabel: 'Create',
}); });
createWorkspace.mutate({ name }); createWorkspace.mutate({ name });
}, },
@@ -143,6 +144,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
}, [ }, [
activeWorkspace?.name, activeWorkspace?.name,
activeWorkspaceId, activeWorkspaceId,
createWorkspace,
deleteWorkspace.mutate, deleteWorkspace.mutate,
dialog, dialog,
prompt, prompt,

View File

@@ -1,5 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React, { memo, useState } from 'react'; import React, { memo, useState } from 'react';
import { CookieDropdown } from './CookieDropdown';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown'; import { EnvironmentActionsDropdown } from './EnvironmentActionsDropdown';
@@ -27,6 +28,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
> >
<HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center"> <HStack space={0.5} className="flex-1 pointer-events-none" alignItems="center">
<SidebarActions /> <SidebarActions />
<CookieDropdown />
<HStack alignItems="center"> <HStack alignItems="center">
<WorkspaceActionsDropdown /> <WorkspaceActionsDropdown />
<Icon icon="chevronRight" className="text-gray-900 text-opacity-disabled" /> <Icon icon="chevronRight" className="text-gray-900 text-opacity-disabled" />

View File

@@ -4,14 +4,18 @@ import type { ReactNode } from 'react';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
color?: 'danger' | 'success' | 'gray';
} }
export function Banner({ children, className }: Props) { export function Banner({ children, className, color = 'gray' }: Props) {
return ( return (
<div> <div>
<div <div
className={classNames( className={classNames(
className, className,
'border border-red-500 bg-red-300/10 text-red-800 px-3 py-2 rounded select-auto cursor-text', 'border border-dashed italic px-3 py-2 rounded select-auto cursor-text',
color === 'gray' && 'border-gray-500/60 bg-gray-300/10 text-gray-800',
color === 'danger' && 'border-red-500/60 bg-red-300/10 text-red-800',
color === 'success' && 'border-green-500/60 bg-green-300/10 text-green-800',
)} )}
> >
{children} {children}

View File

@@ -5,20 +5,9 @@ import type { HotkeyAction } from '../../hooks/useHotKey';
import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey'; import { useFormattedHotkey, useHotKey } from '../../hooks/useHotKey';
import { Icon } from './Icon'; import { Icon } from './Icon';
const colorStyles = { export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color'> & {
custom: 'ring-blue-500/50',
default:
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
gray: 'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
primary: 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
secondary: 'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
warning: 'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
danger: 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
};
export type ButtonProps = HTMLAttributes<HTMLButtonElement> & {
innerClassName?: string; innerClassName?: string;
color?: keyof typeof colorStyles; color?: 'custom' | 'default' | 'gray' | 'primary' | 'secondary' | 'warning' | 'danger';
isLoading?: boolean; isLoading?: boolean;
size?: 'sm' | 'md' | 'xs'; size?: 'sm' | 'md' | 'xs';
justify?: 'start' | 'center'; justify?: 'start' | 'center';
@@ -64,7 +53,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
'flex-shrink-0 flex items-center', 'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring rounded-md', 'focus-visible-or-class:ring rounded-md',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto', disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
colorStyles[color || 'default'], color === 'custom' && 'ring-blue-500/50',
color === 'default' &&
'text-gray-700 enabled:hocus:bg-gray-700/10 enabled:hocus:text-gray-1000 ring-blue-500/50',
color === 'gray' &&
'text-gray-800 bg-highlight enabled:hocus:bg-gray-500/20 enabled:hocus:text-gray-1000 ring-blue-500/50',
color === 'primary' && 'bg-blue-400 text-white enabled:hocus:bg-blue-500 ring-blue-500/50',
color === 'secondary' &&
'bg-violet-400 text-white enabled:hocus:bg-violet-500 ring-violet-500/50',
color === 'warning' &&
'bg-orange-400 text-white enabled:hocus:bg-orange-500 ring-orange-500/50',
color === 'danger' && 'bg-red-400 text-white enabled:hocus:bg-red-500 ring-red-500/50',
justify === 'start' && 'justify-start', justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center', justify === 'center' && 'justify-center',
size === 'md' && 'h-md px-3', size === 'md' && 'h-md px-3',

View File

@@ -54,10 +54,10 @@ export function Dialog({
className={classNames( className={classNames(
className, className,
'gap-2 grid grid-rows-[auto_minmax(0,1fr)]', 'gap-2 grid grid-rows-[auto_minmax(0,1fr)]',
'relative bg-gray-50 pointer-events-auto', 'pt-4 relative bg-gray-50 pointer-events-auto',
'px-6 py-4 rounded-lg overflow-auto', 'rounded-lg',
'dark:border border-highlight shadow shadow-black/10', 'dark:border border-highlight shadow shadow-black/10',
'max-w-[90vw] max-h-[calc(100vh-8em)]', 'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
size === 'sm' && 'w-[25rem] max-h-[80vh]', size === 'sm' && 'w-[25rem] max-h-[80vh]',
size === 'md' && 'w-[45rem] max-h-[80vh]', size === 'md' && 'w-[45rem] max-h-[80vh]',
size === 'full' && 'w-[100vw] h-[100vh]', size === 'full' && 'w-[100vw] h-[100vh]',
@@ -65,19 +65,26 @@ export function Dialog({
)} )}
> >
{title ? ( {title ? (
<Heading size={1} id={titleId}> <Heading className="px-6 pt-4" size={1} id={titleId}>
{title} {title}
</Heading> </Heading>
) : ( ) : (
<span /> <span />
)} )}
{description && <p id={descriptionId}>{description}</p>} {description && (
<div className="h-full w-full grid grid-cols-[minmax(0,1fr)]">{children}</div> <p className="px-6" id={descriptionId}>
{description}
</p>
)}
<div className="h-full w-full grid grid-cols-[minmax(0,1fr)] overflow-y-auto px-6 py-2">
{children}
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/} {/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && ( {!hideX && (
<div className="ml-auto absolute right-1 top-1"> <div className="ml-auto absolute right-1 top-1">
<IconButton <IconButton
className="opacity-70 hover:opacity-100"
onClick={onClose} onClick={onClose}
title="Close dialog (Esc)" title="Close dialog (Esc)"
aria-label="Close" aria-label="Close"

View File

@@ -10,7 +10,7 @@ export function FormattedError({ children }: Props) {
<pre <pre
className={classNames( className={classNames(
'w-full text-sm select-auto cursor-text bg-gray-100 p-3 rounded', 'w-full text-sm select-auto cursor-text bg-gray-100 p-3 rounded',
'whitespace-pre border border-red-500 border-dashed overflow-x-auto', 'whitespace-pre-wrap border border-red-500 border-dashed overflow-x-auto',
)} )}
> >
{children} {children}

View File

@@ -11,6 +11,7 @@ const icons = {
check: lucide.CheckIcon, check: lucide.CheckIcon,
chevronDown: lucide.ChevronDownIcon, chevronDown: lucide.ChevronDownIcon,
chevronRight: lucide.ChevronRightIcon, chevronRight: lucide.ChevronRightIcon,
cookie: lucide.CookieIcon,
code: lucide.CodeIcon, code: lucide.CodeIcon,
copy: lucide.CopyIcon, copy: lucide.CopyIcon,
download: lucide.DownloadIcon, download: lucide.DownloadIcon,

View File

@@ -9,7 +9,7 @@ export interface AlertProps {
export function Alert({ onHide, body }: AlertProps) { export function Alert({ onHide, body }: AlertProps) {
return ( return (
<VStack space={3}> <VStack space={3} className="pb-4">
<div>{body}</div> <div>{body}</div>
<HStack space={2} justifyContent="end"> <HStack space={2} justifyContent="end">
<Button className="focus" color="primary" onClick={onHide}> <Button className="focus" color="primary" onClick={onHide}>

View File

@@ -30,7 +30,7 @@ export function Confirm({ onHide, onResult, variant = 'confirm' }: ConfirmProps)
}; };
return ( return (
<HStack space={2} justifyContent="end" className="mt-6"> <HStack space={2} justifyContent="end" className="mt-2 mb-4">
<Button className="focus" color="gray" onClick={handleHide}> <Button className="focus" color="gray" onClick={handleHide}>
Cancel Cancel
</Button> </Button>

View File

@@ -12,9 +12,18 @@ export interface PromptProps {
name: InputProps['name']; name: InputProps['name'];
defaultValue?: InputProps['defaultValue']; defaultValue?: InputProps['defaultValue'];
placeholder?: InputProps['placeholder']; placeholder?: InputProps['placeholder'];
confirmLabel?: string;
} }
export function Prompt({ onHide, label, name, defaultValue, placeholder, onResult }: PromptProps) { export function Prompt({
onHide,
label,
name,
defaultValue,
placeholder,
onResult,
confirmLabel = 'Save',
}: PromptProps) {
const [value, setValue] = useState<string>(defaultValue ?? ''); const [value, setValue] = useState<string>(defaultValue ?? '');
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => { (e: FormEvent<HTMLFormElement>) => {
@@ -27,7 +36,7 @@ export function Prompt({ onHide, label, name, defaultValue, placeholder, onResul
return ( return (
<form <form
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-6" className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<Input <Input
@@ -45,7 +54,7 @@ export function Prompt({ onHide, label, name, defaultValue, placeholder, onResul
Cancel Cancel
</Button> </Button>
<Button type="submit" className="focus" color="primary"> <Button type="submit" className="focus" color="primary">
Save {confirmLabel}
</Button> </Button>
</HStack> </HStack>
</form> </form>

View File

@@ -0,0 +1,22 @@
import { NAMESPACE_GLOBAL } from '../lib/keyValueStore';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { useCookieJars } from './useCookieJars';
import { useKeyValue } from './useKeyValue';
export function useActiveCookieJar() {
const workspaceId = useActiveWorkspaceId();
const cookieJars = useCookieJars();
const kv = useKeyValue<string | null>({
namespace: NAMESPACE_GLOBAL,
key: ['activeCookieJar', workspaceId ?? 'n/a'],
defaultValue: null,
});
const activeCookieJar = cookieJars.find((cookieJar) => cookieJar.id === kv.value);
return {
activeCookieJar: activeCookieJar ?? null,
setActiveCookieJarId: kv.set,
};
}

View File

@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { CookieJar } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
export function cookieJarsQueryKey({ workspaceId }: { workspaceId: string }) {
return ['cookie_jars', { workspaceId }];
}
export function useCookieJars() {
const workspaceId = useActiveWorkspaceId();
return (
useQuery({
enabled: workspaceId != null,
queryKey: cookieJarsQueryKey({ workspaceId: workspaceId ?? 'n/a' }),
queryFn: async () => {
if (workspaceId == null) return [];
return (await invoke('list_cookie_jars', { workspaceId })) as CookieJar[];
},
}).data ?? []
);
}

View File

@@ -0,0 +1,35 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { trackEvent } from '../lib/analytics';
import type { CookieJar, HttpRequest } from '../lib/models';
import { useActiveWorkspaceId } from './useActiveWorkspaceId';
import { usePrompt } from './usePrompt';
import { requestsQueryKey } from './useRequests';
export function useCreateCookieJar() {
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
const prompt = usePrompt();
return useMutation<HttpRequest>({
mutationFn: async () => {
if (workspaceId === null) {
throw new Error("Cannot create cookie jar when there's no active workspace");
}
const name = await prompt({
name: 'name',
title: 'New CookieJar',
label: 'Name',
defaultValue: 'My Jar',
});
return invoke('create_cookie_jar', { workspaceId, name });
},
onSettled: () => trackEvent('CookieJar', 'Create'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),
(requests) => [...(requests ?? []), request],
);
},
});
}

View File

@@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import { InlineCode } from '../components/core/InlineCode';
import { trackEvent } from '../lib/analytics';
import type { CookieJar, Workspace } from '../lib/models';
import { useConfirm } from './useConfirm';
import { cookieJarsQueryKey } from './useCookieJars';
export function useDeleteCookieJar(cookieJar: CookieJar | null) {
const queryClient = useQueryClient();
const confirm = useConfirm();
return useMutation<CookieJar | null, string>({
mutationFn: async () => {
const confirmed = await confirm({
title: 'Delete CookieJar',
variant: 'delete',
description: (
<>
Permanently delete <InlineCode>{cookieJar?.name}</InlineCode>?
</>
),
});
if (!confirmed) return null;
return invoke('delete_cookie_jar', { cookieJarId: cookieJar?.id });
},
onSettled: () => trackEvent('CookieJar', 'Delete'),
onSuccess: async (cookieJar) => {
if (cookieJar === null) return;
const { id: cookieJarId, workspaceId } = cookieJar;
queryClient.setQueryData<CookieJar[]>(cookieJarsQueryKey({ workspaceId }), (cookieJars) =>
cookieJars?.filter((e) => e.id !== cookieJarId),
);
},
});
}

View File

@@ -47,7 +47,7 @@ export function useImportData() {
render: ({ hide }) => { render: ({ hide }) => {
const { workspaces, environments, folders, requests } = imported; const { workspaces, environments, folders, requests } = imported;
return ( return (
<VStack space={3}> <VStack space={3} className="pb-4">
<ul className="list-disc pl-6"> <ul className="list-disc pl-6">
<li>{count('Workspace', workspaces.length)}</li> <li>{count('Workspace', workspaces.length)}</li>
<li>{count('Environment', environments.length)}</li> <li>{count('Environment', environments.length)}</li>

View File

@@ -1,4 +1,3 @@
import { dialog } from '@tauri-apps/api';
import type { DialogProps } from '../components/core/Dialog'; import type { DialogProps } from '../components/core/Dialog';
import { useDialog } from '../components/DialogContext'; import { useDialog } from '../components/DialogContext';
import type { PromptProps } from './Prompt'; import type { PromptProps } from './Prompt';
@@ -13,8 +12,8 @@ export function usePrompt() {
label, label,
defaultValue, defaultValue,
placeholder, placeholder,
}: Pick<DialogProps, 'title' | 'description'> & confirmLabel,
Pick<PromptProps, 'name' | 'label' | 'defaultValue' | 'placeholder'>) => }: Pick<DialogProps, 'title' | 'description'> & Omit<PromptProps, 'onResult' | 'onHide'>) =>
new Promise((onResult: PromptProps['onResult']) => { new Promise((onResult: PromptProps['onResult']) => {
dialog.show({ dialog.show({
title, title,
@@ -22,7 +21,7 @@ export function usePrompt() {
hideX: true, hideX: true,
size: 'sm', size: 'sm',
render: ({ hide }) => render: ({ hide }) =>
Prompt({ onHide: hide, onResult, name, label, defaultValue, placeholder }), Prompt({ onHide: hide, onResult, name, label, defaultValue, placeholder, confirmLabel }),
}); });
}); });
} }

View File

@@ -5,12 +5,14 @@ import slugify from 'slugify';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import type { HttpResponse } from '../lib/models'; import type { HttpResponse } from '../lib/models';
import { getRequest } from '../lib/store'; import { getRequest } from '../lib/store';
import { useActiveCookieJar } from './useActiveCookieJar';
import { useActiveEnvironmentId } from './useActiveEnvironmentId'; import { useActiveEnvironmentId } from './useActiveEnvironmentId';
import { useAlert } from './useAlert'; import { useAlert } from './useAlert';
export function useSendAnyRequest(options: { download?: boolean } = {}) { export function useSendAnyRequest(options: { download?: boolean } = {}) {
const environmentId = useActiveEnvironmentId(); const environmentId = useActiveEnvironmentId();
const alert = useAlert(); const alert = useAlert();
const { activeCookieJar } = useActiveCookieJar();
return useMutation<HttpResponse | null, string, string | null>({ return useMutation<HttpResponse | null, string, string | null>({
mutationFn: async (id) => { mutationFn: async (id) => {
const request = await getRequest(id); const request = await getRequest(id);
@@ -33,6 +35,7 @@ export function useSendAnyRequest(options: { download?: boolean } = {}) {
requestId: id, requestId: id,
environmentId, environmentId,
downloadDir: downloadDir, downloadDir: downloadDir,
cookieJarId: activeCookieJar?.id,
}); });
}, },
onSettled: () => trackEvent('HttpRequest', 'Send'), onSettled: () => trackEvent('HttpRequest', 'Send'),

View File

@@ -17,7 +17,6 @@ export function useSyncWindowTitle() {
newTitle += ` ${fallbackRequestName(activeRequest)}`; newTitle += ` ${fallbackRequestName(activeRequest)}`;
} }
console.log('Skipping setting window title to ', newTitle);
// TODO: This resets the stoplight position so we can't use it yet // TODO: This resets the stoplight position so we can't use it yet
// appWindow.setTitle(newTitle).catch(console.error); // appWindow.setTitle(newTitle).catch(console.error);
}, [activeEnvironment, activeRequest, activeWorkspace]); }, [activeEnvironment, activeRequest, activeWorkspace]);

View File

@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { invoke } from '@tauri-apps/api';
import type { CookieJar } from '../lib/models';
import { getCookieJar } from '../lib/store';
import { cookieJarsQueryKey } from './useCookieJars';
export function useUpdateCookieJar(id: string | null) {
const queryClient = useQueryClient();
return useMutation<void, unknown, Partial<CookieJar> | ((j: CookieJar) => CookieJar)>({
mutationFn: async (v) => {
const cookieJar = await getCookieJar(id);
if (cookieJar == null) {
throw new Error("Can't update a null workspace");
}
const newCookieJar = typeof v === 'function' ? v(cookieJar) : { ...cookieJar, ...v };
console.log('NEW COOKIE JAR', newCookieJar.cookies.length);
await invoke('update_cookie_jar', { cookieJar: newCookieJar });
},
onMutate: async (v) => {
const cookieJar = await getCookieJar(id);
if (cookieJar === null) return;
const newCookieJar = typeof v === 'function' ? v(cookieJar) : { ...cookieJar, ...v };
queryClient.setQueryData<CookieJar[]>(cookieJarsQueryKey(cookieJar), (cookieJars) =>
(cookieJars ?? []).map((j) => (j.id === newCookieJar.id ? newCookieJar : j)),
);
},
});
}

View File

@@ -21,7 +21,6 @@ export function useUpdateWorkspace(id: string | null) {
if (workspace === null) return; if (workspace === null) return;
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v }; const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
console.log('NEW WORKSPACE', newWorkspace);
queryClient.setQueryData<Workspace[]>(workspacesQueryKey(workspace), (workspaces) => queryClient.setQueryData<Workspace[]>(workspacesQueryKey(workspace), (workspaces) =>
(workspaces ?? []).map((w) => (w.id === newWorkspace.id ? newWorkspace : w)), (workspaces ?? []).map((w) => (w.id === newWorkspace.id ? newWorkspace : w)),
); );

View File

@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api';
export function trackEvent( export function trackEvent(
resource: resource:
| 'App' | 'App'
| 'CookieJar'
| 'Sidebar' | 'Sidebar'
| 'Workspace' | 'Workspace'
| 'Environment' | 'Environment'

View File

@@ -9,7 +9,14 @@ export const AUTH_TYPE_NONE = null;
export const AUTH_TYPE_BASIC = 'basic'; export const AUTH_TYPE_BASIC = 'basic';
export const AUTH_TYPE_BEARER = 'bearer'; export const AUTH_TYPE_BEARER = 'bearer';
export type Model = Settings | Workspace | HttpRequest | HttpResponse | KeyValue | Environment; export type Model =
| Settings
| Workspace
| HttpRequest
| HttpResponse
| KeyValue
| Environment
| CookieJar;
export interface BaseModel { export interface BaseModel {
readonly id: string; readonly id: string;
@@ -34,6 +41,33 @@ export interface Workspace extends BaseModel {
settingRequestTimeout: number; settingRequestTimeout: number;
} }
export interface CookieJar extends BaseModel {
readonly model: 'cookie_jar';
workspaceId: string;
name: string;
cookies: Cookie[];
}
export interface Cookie {
raw_cookie: string;
domain: { HostOnly: string } | { Suffix: string } | 'NotPresent' | 'Empty';
expires: { AtUtc: string } | 'SessionEnd';
path: [string, boolean];
}
export function cookieDomain(cookie: Cookie): string {
if (cookie.domain === 'NotPresent' || cookie.domain === 'Empty') {
return 'n/a';
}
if ('HostOnly' in cookie.domain) {
return cookie.domain.HostOnly;
}
if ('Suffix' in cookie.domain) {
return cookie.domain.Suffix;
}
return 'unknown';
}
export interface EnvironmentVariable { export interface EnvironmentVariable {
name: string; name: string;
value: string; value: string;

View File

@@ -1,5 +1,13 @@
import { invoke } from '@tauri-apps/api'; import { invoke } from '@tauri-apps/api';
import type { Environment, Folder, HttpRequest, Settings, Workspace } from './models'; import type {
Cookie,
CookieJar,
Environment,
Folder,
HttpRequest,
Settings,
Workspace,
} from './models';
export async function getSettings(): Promise<Settings> { export async function getSettings(): Promise<Settings> {
return invoke('get_settings', {}); return invoke('get_settings', {});
@@ -40,3 +48,12 @@ export async function getWorkspace(id: string | null): Promise<Workspace | null>
} }
return workspace; return workspace;
} }
export async function getCookieJar(id: string | null): Promise<CookieJar | null> {
if (id === null) return null;
const cookieJar: CookieJar = (await invoke('get_cookie_jar', { id })) ?? null;
if (cookieJar == null) {
return null;
}
return cookieJar;
}