mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-18 01:15:12 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a |
Generated
+12
@@ -11277,6 +11277,7 @@ dependencies = [
|
||||
"yaak-grpc",
|
||||
"yaak-http",
|
||||
"yaak-license",
|
||||
"yaak-lifecycle",
|
||||
"yaak-mac-window",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
@@ -11342,6 +11343,7 @@ dependencies = [
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
@@ -11526,6 +11528,14 @@ dependencies = [
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-lifecycle"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-mac-window"
|
||||
version = "0.1.0"
|
||||
@@ -11745,6 +11755,8 @@ dependencies = [
|
||||
"sqlite-wasm-vfs",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ members = [
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-sse",
|
||||
@@ -77,6 +78,7 @@ yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
|
||||
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
|
||||
// Merge default hotkeys with custom hotkeys from settings
|
||||
// Custom hotkeys override defaults for the same action
|
||||
// An empty array means the hotkey is intentionally disabled
|
||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||
for (const action of hotkeyActions) {
|
||||
merged[action] = defaultHotkeys[action];
|
||||
}
|
||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||
if (action in merged && Array.isArray(keys)) {
|
||||
merged[action as HotkeyAction] = keys;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
|
||||
});
|
||||
|
||||
/** Helper function to get current hotkeys from the store */
|
||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
||||
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||
return jotaiStore.get(hotkeysAtom);
|
||||
}
|
||||
|
||||
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
|
||||
"Space",
|
||||
];
|
||||
|
||||
/** Zoom is the browser's own on these keys, so the app has no such action there. */
|
||||
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
|
||||
|
||||
/**
|
||||
* The actions this host actually has. An action left out of here has no keys in
|
||||
* `hotkeysAtom`, so it never matches and never claims the keystroke.
|
||||
*/
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
).sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
)
|
||||
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||
.sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
|
||||
@@ -45,6 +45,7 @@ yaak-api = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
|
||||
@@ -435,15 +435,12 @@ fn create(
|
||||
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
|
||||
let name = name.unwrap_or_default();
|
||||
let url = url.unwrap_or_default();
|
||||
let method = method.unwrap_or_else(|| "GET".to_string());
|
||||
|
||||
let request = HttpRequest {
|
||||
workspace_id,
|
||||
name,
|
||||
method: method.to_uppercase(),
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
|
||||
// Only override the method when one was given; `HttpRequest::default()` is the
|
||||
// single place the fallback ("GET") is defined.
|
||||
if let Some(method) = method {
|
||||
request.method = method.to_uppercase();
|
||||
}
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
|
||||
@@ -49,6 +49,14 @@ impl CliContext {
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Guest: the desktop may have this DB open, so only what's safe beside a live session
|
||||
let _ = yaak_lifecycle::on_launch(
|
||||
&yaak_lifecycle::Host::guest(),
|
||||
&query_manager.connect(),
|
||||
&blob_manager,
|
||||
);
|
||||
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||
|
||||
Self {
|
||||
|
||||
@@ -88,6 +88,7 @@ yaak-grpc = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-license = { workspace = true, optional = true }
|
||||
yaak-mac-window = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
|
||||
@@ -1258,6 +1258,14 @@ pub fn run() {
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
let lifecycle_host = yaak_lifecycle::Host::owner()
|
||||
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
|
||||
if let Err(e) =
|
||||
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
|
||||
{
|
||||
error!("on_launch hook failed: {e:?}");
|
||||
}
|
||||
|
||||
// The RPC command registry — every frontend command dispatches
|
||||
// through this via the single `rpc` Tauri command
|
||||
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
|
||||
@@ -1357,15 +1365,6 @@ pub fn run() {
|
||||
let info = history::get_or_upsert_launch_info(&h);
|
||||
debug!("Launched Yaak {:?}", info);
|
||||
});
|
||||
|
||||
// Cancel pending requests
|
||||
let h = app_handle.clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let db = h.db();
|
||||
let _ = db.cancel_pending_http_responses();
|
||||
let _ = db.cancel_pending_grpc_connections();
|
||||
let _ = db.cancel_pending_websocket_connections();
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
|
||||
@@ -15,7 +15,6 @@ use yaak_models::error::Result;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
||||
|
||||
@@ -152,30 +151,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
}
|
||||
};
|
||||
|
||||
let db = query_manager.connect();
|
||||
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
||||
{
|
||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
||||
}
|
||||
// Only stream writes that happen after this app launch.
|
||||
let cursor = ModelChangeCursor::from_launch_time();
|
||||
|
||||
let poll_query_manager = query_manager.clone();
|
||||
|
||||
// GC response bodies orphaned by cascade deletes, which historically
|
||||
// didn't clean the blob DB or responses directory
|
||||
let gc_query_manager = query_manager.clone();
|
||||
let gc_blob_manager = blob_manager.clone();
|
||||
let gc_responses_dir = app_path.join("responses");
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let db = gc_query_manager.connect();
|
||||
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
|
||||
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
app_handle.manage(query_manager);
|
||||
app_handle.manage(blob_manager);
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "yaak-lifecycle"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Lifecycle hooks shared by every host (desktop, browser, CLI). The hooks say
|
||||
//! what happens at each moment; the host decides when and on which thread.
|
||||
//!
|
||||
//! Builds for wasm32, so it can depend on `yaak-models` but not on the send
|
||||
//! engine or plugin runtime.
|
||||
|
||||
use log::info;
|
||||
use std::path::PathBuf;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Result;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// Has the database for the life of the app (desktop, browser worker)
|
||||
Owner,
|
||||
/// Short-lived, and an owner may be using the database right now (CLI).
|
||||
/// Must not touch anything in flight.
|
||||
Guest,
|
||||
}
|
||||
|
||||
/// Paths are `None` on hosts without a filesystem (the browser).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Host {
|
||||
pub role: Role,
|
||||
pub responses_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn owner() -> Self {
|
||||
Self { role: Role::Owner, responses_dir: None }
|
||||
}
|
||||
|
||||
pub fn guest() -> Self {
|
||||
Self { role: Role::Guest, responses_dir: None }
|
||||
}
|
||||
|
||||
pub fn with_responses_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||
self.responses_dir = Some(dir.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Run once after the database is open, before the host answers anything.
|
||||
pub fn on_launch(host: &Host, db: &ClientDb, blobs: &BlobManager) -> Result<()> {
|
||||
db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)?;
|
||||
|
||||
if host.role == Role::Owner {
|
||||
// Anything still in flight was left by the last session
|
||||
db.cancel_pending_http_responses()?;
|
||||
db.cancel_pending_grpc_connections()?;
|
||||
db.cancel_pending_websocket_connections()?;
|
||||
|
||||
// Cascaded deletes never cleaned up response bodies
|
||||
let deleted = match host.responses_dir.as_deref() {
|
||||
Some(dir) => db.delete_orphaned_response_bodies(blobs, dir)?,
|
||||
None => db.delete_orphaned_response_body_blobs(blobs)?,
|
||||
};
|
||||
if deleted > 0 {
|
||||
info!("Deleted {deleted} orphaned response bodies");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_models::blob_manager::BodyChunk;
|
||||
use yaak_models::init_in_memory;
|
||||
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseState, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn only_the_owner_closes_what_the_last_session_left_open() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let source = &UpdateSource::Background;
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Hooks".to_string(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.unwrap();
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.unwrap();
|
||||
let pending = db
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
state: HttpResponseState::Connected,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
&blob_manager,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
on_launch(&Host::guest(), &db, &blob_manager).unwrap();
|
||||
let response = db.get_http_response(&pending.id).unwrap();
|
||||
assert!(matches!(response.state, HttpResponseState::Connected));
|
||||
|
||||
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
|
||||
let response = db.get_http_response(&pending.id).unwrap();
|
||||
assert!(matches!(response.state, HttpResponseState::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_without_a_filesystem_still_sweeps_blobs() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
{
|
||||
let blob_ctx = blob_manager.connect();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
|
||||
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
|
||||
|
||||
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createStore } from "jotai";
|
||||
import { expect, test } from "vitest";
|
||||
import type { HttpResponseEvent } from "../bindings/gen_models";
|
||||
import { httpResponseEventsAtom, modelStoreDataAtom } from "./atoms";
|
||||
import { newStoreData } from "./util";
|
||||
|
||||
// The five setting events that every send writes, all within the same millisecond
|
||||
const SETTING_NAMES = [
|
||||
"validate_certificates",
|
||||
"redirects",
|
||||
"timeout",
|
||||
"send_cookies",
|
||||
"store_cookies",
|
||||
];
|
||||
|
||||
function settingEvent(id: string, name: string, createdAt: string): HttpResponseEvent {
|
||||
return {
|
||||
model: "http_response_event",
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
workspaceId: "wk_1",
|
||||
responseId: "rs_1",
|
||||
event: { type: "setting", name, value: "true" },
|
||||
};
|
||||
}
|
||||
|
||||
test("events with equal createdAt keep store (DB) insertion order", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
SETTING_NAMES.forEach((name, i) => {
|
||||
data.http_response_event[`hre_${i}`] = settingEvent(
|
||||
`hre_${i}`,
|
||||
name,
|
||||
"2026-08-17T00:00:00.123",
|
||||
);
|
||||
});
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
const names = store.get(httpResponseEventsAtom).map((e) => {
|
||||
return e.event.type === "setting" ? e.event.name : e.event.type;
|
||||
});
|
||||
expect(names).toEqual(SETTING_NAMES);
|
||||
});
|
||||
|
||||
test("events with distinct createdAt sort ascending", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
for (const [id, createdAt] of [
|
||||
["hre_b", "2026-08-17T00:00:00.456"],
|
||||
["hre_a", "2026-08-17T00:00:00.123"],
|
||||
["hre_c", "2026-08-17T00:00:00.789"],
|
||||
]) {
|
||||
data.http_response_event[id!] = settingEvent(id!, "timeout", createdAt!);
|
||||
}
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
expect(store.get(httpResponseEventsAtom).map((e) => e.id)).toEqual(["hre_a", "hre_b", "hre_c"]);
|
||||
});
|
||||
@@ -61,7 +61,9 @@ export function createOrderedModelAtom<M extends AnyModel["model"]>(
|
||||
const modelData = data[modelType] ?? {};
|
||||
return Object.values(modelData).sort(
|
||||
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
|
||||
const n = a[field] > b[field] ? 1 : -1;
|
||||
// NOTE: ties must return 0, or the comparator is inconsistent and V8 reorders
|
||||
// equal-keyed rows. Sort is stable, so 0 preserves store (DB) insertion order.
|
||||
const n = a[field] === b[field] ? 0 : a[field] > b[field] ? 1 : -1;
|
||||
return order === "desc" ? n * -1 : n;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -60,8 +60,22 @@ pub struct ProxySettingAuth {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
impl Default for ClientCertificate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
port: None,
|
||||
crt_file: None,
|
||||
key_file: None,
|
||||
pfx_file: None,
|
||||
passphrase: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct ClientCertificate {
|
||||
pub host: String,
|
||||
@@ -75,13 +89,18 @@ pub struct ClientCertificate {
|
||||
pub pfx_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub passphrase: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
impl Default for DnsOverride {
|
||||
fn default() -> Self {
|
||||
Self { hostname: String::new(), ipv4: Vec::new(), ipv6: Vec::new(), enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct DnsOverride {
|
||||
pub hostname: String,
|
||||
@@ -89,7 +108,6 @@ pub struct DnsOverride {
|
||||
pub ipv4: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub ipv6: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -147,7 +165,6 @@ pub struct InheritedBoolSetting {
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub value: bool,
|
||||
}
|
||||
|
||||
@@ -383,7 +400,31 @@ impl UpsertModelInfo for Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "workspace".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
name: String::new(),
|
||||
encryption_key_challenge: None,
|
||||
setting_validate_certificates: true,
|
||||
setting_follow_redirects: true,
|
||||
setting_request_timeout: 0,
|
||||
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
setting_dns_overrides: Vec::new(),
|
||||
setting_send_cookies: true,
|
||||
setting_store_cookies: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "workspaces")]
|
||||
@@ -403,18 +444,13 @@ pub struct Workspace {
|
||||
pub encryption_key_challenge: Option<String>,
|
||||
|
||||
// Settings
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_validate_certificates: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_follow_redirects: bool,
|
||||
pub setting_request_timeout: i32,
|
||||
#[serde(default = "default_request_message_size")]
|
||||
pub setting_request_message_size: i32,
|
||||
#[serde(default)]
|
||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_send_cookies: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_store_cookies: bool,
|
||||
}
|
||||
|
||||
@@ -920,11 +956,16 @@ impl UpsertModelInfo for Environment {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for EnvironmentVariable {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, name: String::new(), value: String::new(), id: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct EnvironmentVariable {
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
@@ -949,7 +990,35 @@ pub struct ParentHeaders {
|
||||
pub headers: Vec<HttpRequestHeader>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for Folder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "folder".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
name: String::new(),
|
||||
sort_priority: 0.0,
|
||||
setting_send_cookies: InheritedBoolSetting::default(),
|
||||
setting_store_cookies: InheritedBoolSetting::default(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_follow_redirects: InheritedBoolSetting::default(),
|
||||
setting_request_timeout: InheritedIntSetting::default(),
|
||||
setting_request_message_size: InheritedIntSetting {
|
||||
enabled: false,
|
||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "folders")]
|
||||
@@ -974,7 +1043,6 @@ pub struct Folder {
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
pub setting_follow_redirects: InheritedBoolSetting,
|
||||
pub setting_request_timeout: InheritedIntSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
@@ -1088,11 +1156,16 @@ impl UpsertModelInfo for Folder {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for HttpRequestHeader {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, name: String::new(), value: String::new(), id: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct HttpRequestHeader {
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
@@ -1101,11 +1174,16 @@ pub struct HttpRequestHeader {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for HttpUrlParameter {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, name: String::new(), value: String::new(), id: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct HttpUrlParameter {
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
/// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
@@ -1116,7 +1194,36 @@ pub struct HttpUrlParameter {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for HttpRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "http_request".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
body: BTreeMap::new(),
|
||||
body_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
method: "GET".to_string(),
|
||||
name: String::new(),
|
||||
sort_priority: 0.0,
|
||||
url: String::new(),
|
||||
url_parameters: Vec::new(),
|
||||
setting_send_cookies: InheritedBoolSetting::default(),
|
||||
setting_store_cookies: InheritedBoolSetting::default(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_follow_redirects: InheritedBoolSetting::default(),
|
||||
setting_request_timeout: InheritedIntSetting::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "http_requests")]
|
||||
@@ -1137,7 +1244,6 @@ pub struct HttpRequest {
|
||||
pub body_type: Option<String>,
|
||||
pub description: String,
|
||||
pub headers: Vec<HttpRequestHeader>,
|
||||
#[serde(default = "default_http_method")]
|
||||
pub method: String,
|
||||
pub name: String,
|
||||
pub sort_priority: f64,
|
||||
@@ -1393,7 +1499,36 @@ impl Default for WebsocketMessageType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for WebsocketRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "websocket_request".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
message: String::new(),
|
||||
name: String::new(),
|
||||
sort_priority: 0.0,
|
||||
url: String::new(),
|
||||
url_parameters: Vec::new(),
|
||||
setting_send_cookies: InheritedBoolSetting::default(),
|
||||
setting_store_cookies: InheritedBoolSetting::default(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_request_message_size: InheritedIntSetting {
|
||||
enabled: false,
|
||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "websocket_requests")]
|
||||
@@ -1420,7 +1555,6 @@ pub struct WebsocketRequest {
|
||||
pub setting_send_cookies: InheritedBoolSetting,
|
||||
pub setting_store_cookies: InheritedBoolSetting,
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
@@ -2053,7 +2187,35 @@ impl UpsertModelInfo for GraphQlIntrospection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for GrpcRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "grpc_request".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication_type: None,
|
||||
authentication: BTreeMap::new(),
|
||||
description: String::new(),
|
||||
message: String::new(),
|
||||
metadata: Vec::new(),
|
||||
method: None,
|
||||
name: String::new(),
|
||||
service: None,
|
||||
sort_priority: 0.0,
|
||||
url: String::new(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_request_message_size: InheritedIntSetting {
|
||||
enabled: false,
|
||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "grpc_requests")]
|
||||
@@ -2079,7 +2241,6 @@ pub struct GrpcRequest {
|
||||
/// Server URL (http for plaintext or https for secure)
|
||||
pub url: String,
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
@@ -2730,22 +2891,12 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_request_message_size() -> i32 {
|
||||
DEFAULT_REQUEST_MESSAGE_SIZE
|
||||
}
|
||||
|
||||
/// Only used as a `from_row` fallback for an unparseable settings column. The
|
||||
/// value a *new* model gets comes from that model's `Default` impl.
|
||||
fn default_request_message_size_setting() -> InheritedIntSetting {
|
||||
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
|
||||
}
|
||||
|
||||
fn default_http_method() -> String {
|
||||
"GET".to_string()
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! define_any_model {
|
||||
($($type:ident),* $(,)?) => {
|
||||
@@ -2889,3 +3040,65 @@ impl AnyModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every model below carries `#[serde(default)]` at the container level, so a
|
||||
/// missing key is filled from `Default::default()`, which makes each `Default`
|
||||
/// impl the single definition of that model's defaults.
|
||||
///
|
||||
/// Deserializing `{}` therefore equals `Default::default()` by construction
|
||||
/// today. What this catches is the two ways that can come apart again, both of
|
||||
/// which have already bitten us:
|
||||
///
|
||||
/// 1. A field-level `#[serde(default = "...")]` (or bare `#[serde(default)]`)
|
||||
/// added back on a field whose `Default` says something else. That is exactly
|
||||
/// the shape of the bug this replaced: `setting_send_cookies` deserialized as
|
||||
/// true but a derived `Default` produced false, so the bootstrapped workspace
|
||||
/// silently sent no cookies.
|
||||
/// 2. The container-level `#[serde(default)]` being dropped, which turns every
|
||||
/// missing key into a deserialization error instead.
|
||||
macro_rules! assert_default_matches_serde {
|
||||
($($t:ty),+ $(,)?) => {
|
||||
$(
|
||||
assert_eq!(
|
||||
serde_json::from_str::<$t>("{}").expect(concat!(
|
||||
stringify!($t),
|
||||
" must deserialize from an empty object"
|
||||
)),
|
||||
<$t>::default(),
|
||||
concat!(stringify!($t), ": Default::default() disagrees with its serde defaults"),
|
||||
);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_serde_defaults() {
|
||||
assert_default_matches_serde!(
|
||||
Workspace,
|
||||
HttpRequest,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
WebsocketRequest,
|
||||
HttpRequestHeader,
|
||||
HttpUrlParameter,
|
||||
EnvironmentVariable,
|
||||
DnsOverride,
|
||||
ClientCertificate,
|
||||
InheritedBoolSetting,
|
||||
InheritedIntSetting,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_carry_their_model_name() {
|
||||
assert_eq!(Workspace::default().model, "workspace");
|
||||
assert_eq!(HttpRequest::default().model, "http_request");
|
||||
assert_eq!(Folder::default().model, "folder");
|
||||
assert_eq!(GrpcRequest::default().model, "grpc_request");
|
||||
assert_eq!(WebsocketRequest::default().model, "websocket_request");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,31 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Delete blob-stored response bodies whose owning HTTP response row no
|
||||
/// longer exists. Blob ids are keyed by the response that owns them —
|
||||
/// "{response_id}" for a response body, "{response_id}.request" for the
|
||||
/// request that produced it — so ownership is the id's first segment.
|
||||
///
|
||||
/// The blob half of [`Self::delete_orphaned_response_bodies`], on its own
|
||||
/// for hosts with no filesystem to hold body files. See `crate::hooks`.
|
||||
///
|
||||
/// Returns the number of orphaned bodies deleted.
|
||||
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
|
||||
let blob_ctx = blobs.connect();
|
||||
for body_id in blob_ctx.list_body_ids()? {
|
||||
let response_id = body_id.split('.').next().unwrap_or_default();
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
}
|
||||
blob_ctx.delete_chunks(&body_id)?;
|
||||
deleted += 1;
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Delete response body data (blob chunks and body files) whose owning HTTP
|
||||
/// response row no longer exists. Cascaded deletes (request, folder,
|
||||
/// workspace) historically never cleaned the blob DB or the responses
|
||||
@@ -59,18 +84,7 @@ impl<'a> ClientDb<'a> {
|
||||
blobs: &BlobManager,
|
||||
responses_dir: &std::path::Path,
|
||||
) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
|
||||
// Blob chunks are keyed "{response_id}.request"
|
||||
let blob_ctx = blobs.connect();
|
||||
for body_id in blob_ctx.list_body_ids()? {
|
||||
let response_id = body_id.split('.').next().unwrap_or_default();
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
}
|
||||
blob_ctx.delete_chunks(&body_id)?;
|
||||
deleted += 1;
|
||||
}
|
||||
let mut deleted = self.delete_orphaned_response_body_blobs(blobs)?;
|
||||
|
||||
// Body files are stored as {responses_dir}/{response_id}
|
||||
if let Ok(entries) = fs::read_dir(responses_dir) {
|
||||
@@ -172,19 +186,20 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::blob_manager::BodyChunk;
|
||||
use crate::blob_manager::{BlobManager, BodyChunk};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn deletes_orphaned_response_bodies() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
/// A workspace, a request, and one response that still exists.
|
||||
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
|
||||
let source = &UpdateSource::Background;
|
||||
let workspace = db
|
||||
.upsert_workspace(&Workspace { name: "GC Test".to_string(), ..Default::default() }, source)
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "GC Test".to_string(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
@@ -192,19 +207,57 @@ mod tests {
|
||||
source,
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
blob_manager,
|
||||
)
|
||||
.expect("Failed to upsert response")
|
||||
}
|
||||
|
||||
let live = db
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
&blob_manager,
|
||||
)
|
||||
.expect("Failed to upsert response");
|
||||
/// What a browser host runs: no filesystem, so bodies exist only as blob
|
||||
/// chunks, under both id shapes the blob DB uses.
|
||||
#[test]
|
||||
fn deletes_orphaned_response_body_blobs() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let live = seed_live_response(&db, &blob_manager);
|
||||
let live_request_body_id = format!("{}.request", live.id);
|
||||
{
|
||||
// Scope the connection: the in-memory pool only has one, and the GC
|
||||
// needs to take it
|
||||
let blob_ctx = blob_manager.connect();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new(&live.id, 0, b"live".to_vec())).unwrap();
|
||||
blob_ctx
|
||||
.insert_chunk(&BodyChunk::new(&live_request_body_id, 0, b"live".to_vec()))
|
||||
.unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
|
||||
let deleted = db
|
||||
.delete_orphaned_response_body_blobs(&blob_manager)
|
||||
.expect("Failed to GC response body blobs");
|
||||
assert_eq!(deleted, 2);
|
||||
|
||||
let blob_ctx = blob_manager.connect();
|
||||
assert!(blob_ctx.body_exists(&live.id).unwrap());
|
||||
assert!(blob_ctx.body_exists(&live_request_body_id).unwrap());
|
||||
assert!(!blob_ctx.body_exists("rs_gone").unwrap());
|
||||
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletes_orphaned_response_bodies() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let live = seed_live_response(&db, &blob_manager);
|
||||
let live_body_id = format!("{}.request", live.id);
|
||||
{
|
||||
// Scope the connection: the in-memory pool only has one, and the GC
|
||||
|
||||
@@ -25,13 +25,7 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
if workspaces.is_empty() {
|
||||
workspaces.push(self.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Yaak".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)?)
|
||||
}
|
||||
@@ -194,16 +188,40 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn default_headers() -> Vec<HttpRequestHeader> {
|
||||
vec![
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "User-Agent".to_string(),
|
||||
value: "yaak".to_string(),
|
||||
id: None,
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "Accept".to_string(),
|
||||
value: "*/*".to_string(),
|
||||
id: None,
|
||||
..Default::default()
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::init_in_memory;
|
||||
|
||||
#[test]
|
||||
fn bootstraps_first_workspace_with_real_defaults() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
|
||||
let workspace = workspaces.first().expect("No workspace was bootstrapped");
|
||||
|
||||
// This workspace is built in Rust and never deserialized, so it only gets
|
||||
// these values if `Workspace::default()` carries them. Asserted through the
|
||||
// DB round trip, since the column values are what a fresh install lives with.
|
||||
assert!(workspace.setting_send_cookies, "setting_send_cookies");
|
||||
assert!(workspace.setting_store_cookies, "setting_store_cookies");
|
||||
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
|
||||
assert!(workspace.setting_validate_certificates, "setting_validate_certificates");
|
||||
assert_eq!(
|
||||
workspace.setting_request_message_size,
|
||||
crate::models::DEFAULT_REQUEST_MESSAGE_SIZE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ crate-type = ["cdylib", "rlib"]
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
@@ -35,3 +36,4 @@ sqlite-wasm-rs = "0.5"
|
||||
sqlite-wasm-vfs = "0.2"
|
||||
wasm-bindgen = "0.2.100"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["console"] }
|
||||
|
||||
@@ -677,23 +677,26 @@ export function __wbg_versions_215a3ab1c9d5745a(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
|
||||
console.warn(arg0, arg1);
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1104, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1103, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 177, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h400c17219073e521);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 198, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
|
||||
return ret;
|
||||
}
|
||||
@@ -746,8 +749,8 @@ function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h400c17219073e521(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -17,7 +17,7 @@ export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
|
||||
export const sqlite3_os_end: () => number;
|
||||
export const sqlite3_os_init: () => number;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h400c17219073e521: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
|
||||
|
||||
@@ -43,6 +43,10 @@ struct Host {
|
||||
events: mpsc::Receiver<ModelPayload>,
|
||||
}
|
||||
|
||||
fn lifecycle_host() -> yaak_lifecycle::Host {
|
||||
yaak_lifecycle::Host::owner()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOST: RefCell<Option<Host>> = const { RefCell::new(None) };
|
||||
}
|
||||
@@ -91,6 +95,10 @@ pub async fn boot() -> Result<()> {
|
||||
let (queries, blobs, events) =
|
||||
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
|
||||
|
||||
if let Err(e) = yaak_lifecycle::on_launch(&lifecycle_host(), &queries.connect(), &blobs) {
|
||||
web_sys::console::warn_2(&"on_launch hook failed".into(), &js_error(e));
|
||||
}
|
||||
|
||||
HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events }));
|
||||
Ok(())
|
||||
}
|
||||
@@ -312,6 +320,28 @@ fn dispatch(
|
||||
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
|
||||
}
|
||||
|
||||
"cmd_delete_all_http_responses" => {
|
||||
let req: RequestIdReq = from_js(payload)?;
|
||||
host.queries
|
||||
.connect()
|
||||
.delete_all_http_responses_for_request(&req.request_id, source)
|
||||
.map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
|
||||
"cmd_delete_send_history" => {
|
||||
let req: WorkspaceIdReq = from_js(payload)?;
|
||||
host.queries
|
||||
.with_tx(|tx| {
|
||||
tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?;
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})
|
||||
.map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
|
||||
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
|
||||
timeline: true,
|
||||
multiWindow: true,
|
||||
windowChrome: true,
|
||||
interfaceZoom: true,
|
||||
plugins: true,
|
||||
encryption: true,
|
||||
updater: true,
|
||||
|
||||
@@ -262,6 +262,11 @@ export interface PlatformCapabilities {
|
||||
* chrome should be reserved or drawn.
|
||||
*/
|
||||
windowChrome: boolean;
|
||||
/**
|
||||
* The app zooms its own interface, and so owns Cmd/Ctrl `+`, `-` and `0`.
|
||||
* False in a browser, where those keys are already the browser's.
|
||||
*/
|
||||
interfaceZoom: boolean;
|
||||
/** The plugin runtime. */
|
||||
plugins: boolean;
|
||||
/** Workspace encryption backed by a key the host keeps. */
|
||||
|
||||
@@ -129,7 +129,10 @@ Reported honestly, so callers gate on the question rather than on the host:
|
||||
|
||||
| True | False |
|
||||
| --- | --- |
|
||||
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
|
||||
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `interfaceZoom`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
|
||||
|
||||
`interfaceZoom: false` leaves Cmd/Ctrl `+`, `-` and `0` to the browser instead
|
||||
of swallowing them, and drops those three rows from the hotkeys screen.
|
||||
|
||||
`multiWindow: false` means the host cannot open a *second window* on demand —
|
||||
what `cmd_new_child_window` does for Settings and workspace switching. It is not
|
||||
|
||||
@@ -65,6 +65,8 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
|
||||
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
|
||||
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
|
||||
cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload),
|
||||
cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload),
|
||||
|
||||
/* -------------------------------- app ---------------------------------- */
|
||||
|
||||
@@ -298,10 +300,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
// Sending history and its bookkeeping belong to the send slice.
|
||||
cmd_delete_send_history: ["Sending isn't available in the browser yet", null],
|
||||
cmd_delete_all_http_responses: ["Sending isn't available in the browser yet", null],
|
||||
|
||||
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ function capabilitiesFor(): PlatformCapabilities {
|
||||
// The browser draws the frame around the page. There are no traffic lights
|
||||
// to leave room for and no window controls to draw.
|
||||
windowChrome: false,
|
||||
// The browser already zooms the page, on the same keys, and remembers it
|
||||
// per site. The app stays out of the way.
|
||||
interfaceZoom: false,
|
||||
plugins: false,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
|
||||
@@ -31,7 +31,13 @@ export async function fetchAccessToken(
|
||||
],
|
||||
};
|
||||
|
||||
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
|
||||
// RFC 6749 §4.1.3 doesn't define scope for the authorization code token
|
||||
// request, so strict servers (OpenIddict) reject it outright. Scope belongs on
|
||||
// the authorize request, which already sends it. Every other grant does define
|
||||
// it: §4.3.2 password, §4.4.2 client credentials, §6 refresh.
|
||||
if (scope && grantType !== "authorization_code") {
|
||||
httpRequest.body?.form.push({ name: "scope", value: scope });
|
||||
}
|
||||
if (audience) httpRequest.body?.form.push({ name: "audience", value: audience });
|
||||
|
||||
if ("clientAssertion" in args) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { HttpRequest } from "@yaakapp/api";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { fetchAccessToken } from "../src/fetchAccessToken";
|
||||
|
||||
/**
|
||||
* Captures the request handed to ctx.httpRequest.send so tests can assert on the
|
||||
* form body, and replies with a minimal successful token response.
|
||||
*/
|
||||
function createMockContext() {
|
||||
const sent: Partial<HttpRequest>[] = [];
|
||||
|
||||
const ctx = {
|
||||
httpRequest: {
|
||||
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
|
||||
sent.push(httpRequest);
|
||||
return {
|
||||
httpResponse: { status: 200, error: null },
|
||||
body: {
|
||||
async text() {
|
||||
return JSON.stringify({ access_token: "token-123" });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
return { ctx, sent };
|
||||
}
|
||||
|
||||
function formNames(httpRequest: Partial<HttpRequest>) {
|
||||
return (httpRequest.body?.form ?? []).map((p: { name: string }) => p.name);
|
||||
}
|
||||
|
||||
function formValue(httpRequest: Partial<HttpRequest>, name: string) {
|
||||
return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value;
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
clientId: "client-123",
|
||||
accessTokenUrl: "https://auth.example.com/token",
|
||||
scope: "openid profile",
|
||||
audience: null,
|
||||
clientSecret: "secret",
|
||||
credentialsInBody: true,
|
||||
params: [],
|
||||
};
|
||||
|
||||
describe("fetchAccessToken scope handling", () => {
|
||||
test("omits scope for the authorization code grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "authorization_code",
|
||||
params: [{ name: "code", value: "abc" }],
|
||||
});
|
||||
|
||||
expect(formNames(sent[0]!)).not.toContain("scope");
|
||||
// The rest of the request is untouched
|
||||
expect(formValue(sent[0]!, "grant_type")).toBe("authorization_code");
|
||||
expect(formValue(sent[0]!, "code")).toBe("abc");
|
||||
});
|
||||
|
||||
test("sends scope for the client credentials grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, { ...baseArgs, grantType: "client_credentials" });
|
||||
|
||||
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
|
||||
});
|
||||
|
||||
test("sends scope for the password grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, { ...baseArgs, grantType: "password" });
|
||||
|
||||
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
|
||||
});
|
||||
|
||||
test("still sends audience for the authorization code grant", async () => {
|
||||
const { ctx, sent } = createMockContext();
|
||||
|
||||
await fetchAccessToken(ctx, {
|
||||
...baseArgs,
|
||||
grantType: "authorization_code",
|
||||
audience: "https://api.example.com",
|
||||
});
|
||||
|
||||
expect(formValue(sent[0]!, "audience")).toBe("https://api.example.com");
|
||||
expect(formNames(sent[0]!)).not.toContain("scope");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user