diff --git a/apps/yaak-proxy/harness/FlowPanesHarness.tsx b/apps/yaak-proxy/harness/FlowPanesHarness.tsx index e24dcada3..75889c098 100644 --- a/apps/yaak-proxy/harness/FlowPanesHarness.tsx +++ b/apps/yaak-proxy/harness/FlowPanesHarness.tsx @@ -1,10 +1,10 @@ -import { useSetAtom } from "jotai"; +import { useSetAtom, useStore } from "jotai"; import { useEffect, useLayoutEffect, useState } from "react"; import { AddColumnDialog } from "../components/AddColumnDialog"; import { FlowDetail } from "../components/FlowDetail"; import { FlowList } from "../components/FlowList"; -import { selectedFlowAtom } from "../lib/atoms"; import { browserTabMemory, learnedTabKey, flowShape } from "../lib/defaultTab"; +import { goToPlace } from "../lib/navigation"; import { flowWindowAtom } from "../lib/store"; import { type FlowFixture, fixtures } from "./fixtures"; @@ -42,7 +42,7 @@ export function FlowPanesHarness() { function FixtureCard({ fixture }: { fixture: FlowFixture }) { const params = new URLSearchParams(location.search); - const setSelected = useSetAtom(selectedFlowAtom); + const store = useStore(); const setWindow = useSetAtom(flowWindowAtom); const [ready, setReady] = useState(false); useLayoutEffect(() => { @@ -52,7 +52,8 @@ function FixtureCard({ fixture }: { fixture: FlowFixture }) { browserTabMemory()?.setItem(learnedTabKey("response", flowShape(fixture.flow)), tab); } setWindow({ flows, hasOlder: false, total: flows.length }); - setSelected(flows.find((f) => f.id === fixture.flow.id) ?? fixture.flow); + const shown = flows.find((f) => f.id === fixture.flow.id) ?? fixture.flow; + goToPlace(store, "update", { selection: [shown.id], flow: shown }); setReady(true); // oxlint-disable-next-line react-hooks/exhaustive-deps -- once per fixture }, [fixture]); diff --git a/apps/yaak-proxy/harness/LockedHarness.tsx b/apps/yaak-proxy/harness/LockedHarness.tsx index cf948763e..3612813c5 100644 --- a/apps/yaak-proxy/harness/LockedHarness.tsx +++ b/apps/yaak-proxy/harness/LockedHarness.tsx @@ -82,10 +82,11 @@ export function LockedHarness() { const [synced, setSynced] = useState(false); useEffect(() => { syncProxyState(store); - syncNavigation(store); - syncFlows(store); - syncSidebarSelection(store); - setSynced(true); + void syncNavigation(store).then(() => { + syncFlows(store); + syncSidebarSelection(store); + setSynced(true); + }); }, [store]); if (!synced) return null; return ( diff --git a/apps/yaak-proxy/harness/MessagesHarness.tsx b/apps/yaak-proxy/harness/MessagesHarness.tsx index 1d980d0c6..84f1148fe 100644 --- a/apps/yaak-proxy/harness/MessagesHarness.tsx +++ b/apps/yaak-proxy/harness/MessagesHarness.tsx @@ -1,11 +1,11 @@ import type { Flow } from "@yaakapp-internal/proxy-lib"; -import { useSetAtom } from "jotai"; +import { useSetAtom, useStore } from "jotai"; import { useLayoutEffect, useMemo, useState } from "react"; import { FlowDetail } from "../components/FlowDetail"; import { FlowList } from "../components/FlowList"; import { ProxyEditorHost } from "../components/ProxyEditorHost"; import { MessageSourceContext } from "../hooks/useFlowMessages"; -import { selectedFlowAtom } from "../lib/atoms"; +import { goToPlace } from "../lib/navigation"; import { flowWindowAtom } from "../lib/store"; import { fixtureSource, type StreamFixture, streamFixtures } from "./streamFixtures"; @@ -51,15 +51,16 @@ function listedFlows(shown: Flow): Flow[] { function StreamDetail({ fixture }: { fixture: StreamFixture }) { const source = useMemo(() => fixtureSource(fixture), [fixture]); - const setSelected = useSetAtom(selectedFlowAtom); + const store = useStore(); const setWindow = useSetAtom(flowWindowAtom); const [flow, setFlow] = useState(fixture.flow); const withList = new URLSearchParams(location.search).has("list"); useLayoutEffect(() => { const flows = listedFlows(flow); setWindow({ flows, hasOlder: false, total: flows.length }); - setSelected(flows.find((f) => f.id === flow.id) ?? flow); - }, [flow, setSelected, setWindow]); + const shown = flows.find((f) => f.id === flow.id) ?? flow; + goToPlace(store, "update", { selection: [shown.id], flow: shown }); + }, [flow, store, setWindow]); // The count on the flow follows its messages, as the engine writes it useLayoutEffect( () => source.subscribe(() => setFlow((f) => ({ ...f, messageCount: f.messageCount + 1 }))), diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index 48c5d3dbc..c87286c53 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -72,6 +72,7 @@ function emit(event: string, payload: unknown = {}) { /** Filters applied, newest first, and saved searches, kept across reloads as the database is */ const HISTORY_KEY = "harness_filter_history"; const SAVED_KEY = "harness_saved_queries"; +const LAST_FILTER_KEY = "harness_last_filter"; function stored(key: string): T[] { try { @@ -146,6 +147,17 @@ function answer(cmd: string, payload: unknown): unknown { store(HISTORY_KEY, []); emit("filter_history_changed"); return 0; + // Held by the engine for as long as the app runs; the tab's session here + case "get_window_places": + return sessionStorage.getItem("harness_window_places"); + case "set_window_places": + sessionStorage.setItem("harness_window_places", String(req.places)); + return true; + case "get_last_filter": + return localStorage.getItem(LAST_FILTER_KEY) ?? ""; + case "set_last_filter": + localStorage.setItem(LAST_FILTER_KEY, String(req.filter)); + return true; case "list_saved_queries": return stored(SAVED_KEY); case "create_saved_query": { diff --git a/apps/yaak-proxy/lib/navigation.test.ts b/apps/yaak-proxy/lib/navigation.test.ts index 39d295b07..591e30f66 100644 --- a/apps/yaak-proxy/lib/navigation.test.ts +++ b/apps/yaak-proxy/lib/navigation.test.ts @@ -14,6 +14,8 @@ import { EMPTY_PLACE, stackOf } from "./places"; const existing = new Set(); const recorded: string[] = []; +let lastFilter = ""; +let windowPlaces: string | null = null; vi.mock("./rpc", () => ({ rpc: vi.fn(async (cmd: string, payload: { ids?: string[]; filter?: string }) => { @@ -21,6 +23,10 @@ vi.mock("./rpc", () => ({ return (payload.ids ?? []).filter((id) => existing.has(id)).map((id) => flow(id)); } if (cmd === "record_filter") recorded.push(payload.filter ?? ""); + if (cmd === "get_last_filter") return lastFilter; + if (cmd === "get_window_places") return windowPlaces; + if (cmd === "set_window_places") windowPlaces = (payload as { places: string }).places; + if (cmd === "set_last_filter") lastFilter = payload.filter ?? ""; return true; }), listen: vi.fn(() => () => {}), @@ -29,7 +35,7 @@ vi.mock("./tauri", () => ({ command: vi.fn(async () => {}), subscribe: vi.fn(() // The package's index reaches for the window as it loads; the sidebar's selection is all used. vi.mock("@yaakapp-internal/ui", () => ({ selectedIdsFamily: () => atom([]) })); -const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom } = +const { applyFilter, goToPlace, searchDraftAtom, searchDraftVersionAtom, syncNavigation } = await import("./navigation"); function flow(id: string): Flow { @@ -47,6 +53,65 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); beforeEach(() => { existing.clear(); recorded.length = 0; + lastFilter = ""; + windowPlaces = null; +}); + +describe("syncNavigation", () => { + test("a launch restores the last filter applied, and keeps each one applied after", async () => { + lastFilter = "@host:*.github.com"; + const store = createStore(); + await syncNavigation(store); + await settle(); + expect(store.get(appliedFilterAtom)).toBe("@host:*.github.com"); + expect(store.get(searchDraftAtom)).toBe("@host:*.github.com"); + expect(store.get(canGoBackAtom)).toBe(false); + + applyFilter(store, "stripe"); + await settle(); + expect(lastFilter).toBe("stripe"); + }); + + test("a launch the person has already moved on from keeps where they are", async () => { + lastFilter = "old"; + const store = createStore(); + await syncNavigation(store); + applyFilter(store, "new"); + await settle(); + expect(store.get(appliedFilterAtom)).toBe("new"); + }); + + test("a reload takes back the places, the tabs and the selection, not the last filter", async () => { + existing.add("B"); + lastFilter = "elsewhere"; + const before = createStore(); + await syncNavigation(before); + applyFilter(before, "x"); + goToPlace(before, "add", { selection: ["B"], flow: flow("B") }); + goToPlace(before, "update", { tabs: { response: "headers" } }); + await settle(); + + const after = createStore(); + await syncNavigation(after); + await settle(); + expect(after.get(appliedFilterAtom)).toBe("x"); + expect(after.get(placeAtom).tabs).toEqual({ response: "headers" }); + expect(after.get(selectedFlowAtom)?.id).toBe("B"); + expect(goToPlace(after, "back")).toBe(true); + }); + + test("a late read of the selected flow leaves a newer one alone", async () => { + existing.add("A"); + const store = newStore(); + goToPlace(store, "add", { selection: ["A"], flow: flow("A") }); + goToPlace(store, "add", { selection: ["B"], flow: flow("B") }); + store.set(selectedFlowAtom, null); + goToPlace(store, "back"); + const newer = { ...flow("A"), status: 200 } as Flow; + goToPlace(store, "update", { selection: ["A"], flow: newer }); + await settle(); + expect(store.get(selectedFlowAtom)).toBe(newer); + }); }); describe("goToPlace", () => { diff --git a/apps/yaak-proxy/lib/navigation.ts b/apps/yaak-proxy/lib/navigation.ts index d6b2b15c8..bd7fdbb9d 100644 --- a/apps/yaak-proxy/lib/navigation.ts +++ b/apps/yaak-proxy/lib/navigation.ts @@ -163,49 +163,61 @@ function readSelected(store: Store, id: string) { store.set(placeStackAtom, (s) => stackWithout(s, new Set([id]))); return; } - // The window has moved on meanwhile. - if (currentPlace(store.get(placeStackAtom)).selection.has(id)) { - store.set(selectedFlowAtom, found); - } + // The window has moved on meanwhile, or shows the flow already, as it was since. + const current = currentPlace(store.get(placeStackAtom)).selection.has(id); + if (current && store.get(selectedFlowAtom) == null) store.set(selectedFlowAtom, found); }) .catch((err) => console.warn("Failed to read the selected flow", err)); } -/** Where the window's places are kept for a reload, for this session */ -const PLACES_KEY = "proxy_places"; -/** Where the filter last applied is kept for the next launch */ -const LAST_FILTER_KEY = "proxy_last_filter"; - -function read(storage: () => Storage, key: string): string | null { - try { - return storage().getItem(key); - } catch { - return null; - } -} - -function write(storage: () => Storage, key: string, value: string) { - try { - storage().setItem(key, value); - } catch { - // Not kept: the next reload starts from the last filter. - } -} - /** - * Restores where the window was, before anything reads the filter: its places after a reload, - * or the last filter applied at launch. Keeps them as they change. + * Restores where the window was, before the flows load for its filter: its places after a + * reload, which the engine holds in memory while the app runs, or at launch the last filter + * applied, which the encrypted database keeps. Filters can hold captured values, so neither is + * kept in the web view's storage. Keeps them as they change. */ -export function syncNavigation(store: Store) { - const kept = stackFromJson(read(() => sessionStorage, PLACES_KEY)); - const lastFilter = read(() => localStorage, LAST_FILTER_KEY) ?? ""; - const stack = kept ?? stackOf({ ...EMPTY_PLACE, filter: lastFilter }); +export async function syncNavigation(store: Store) { + const kept = stackFromJson(await rpc("get_window_places", {}).catch(() => null)); + const stack = kept ?? stackOf(EMPTY_PLACE); store.set(placeStackAtom, stack); showPlace(store, currentPlace(stack), { filter: true, reveal: true }); + if (kept == null) { + // Unread while traffic is locked, as the database is. + rpc("get_last_filter", {}) + .then((filter) => { + // Only over the window as it opened, not where it has been taken since. + const now = store.get(placeStackAtom); + if (filter === "" || now.places.length > 1 || currentPlace(now).filter !== "") return; + goToPlace(store, "update", { filter }); + }) + .catch(() => {}); + } + + // One write at a time, the latest places after it, so they arrive in order. + let pending: string | null = null; + let writing = false; + const write = () => { + if (writing || pending == null) return; + const places = pending; + pending = null; + writing = true; + rpc("set_window_places", { places }) + .catch(() => {}) + .finally(() => { + writing = false; + write(); + }); + }; store.sub(placeStackAtom, () => { - const now = store.get(placeStackAtom); - write(() => sessionStorage, PLACES_KEY, stackToJson(now)); - write(() => localStorage, LAST_FILTER_KEY, currentPlace(now).filter); + pending = stackToJson(store.get(placeStackAtom)); + write(); + }); + let lastFilter = store.get(appliedFilterAtom); + store.sub(appliedFilterAtom, () => { + const filter = store.get(appliedFilterAtom); + if (filter === lastFilter) return; + lastFilter = filter; + rpc("set_last_filter", { filter }).catch(() => {}); }); } diff --git a/apps/yaak-proxy/main.tsx b/apps/yaak-proxy/main.tsx index 1d4c7cd9b..f4a86dd70 100644 --- a/apps/yaak-proxy/main.tsx +++ b/apps/yaak-proxy/main.tsx @@ -15,7 +15,7 @@ const jotaiStore = createStore(); syncProxyState(jotaiStore); // Before the flows load, so they load for the filter it restores -syncNavigation(jotaiStore); +await syncNavigation(jotaiStore); syncFlows(jotaiStore); syncSidebarSelection(jotaiStore); diff --git a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts index d5226a8b4..41f542d20 100644 --- a/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts +++ b/crates-proxy/yaak-proxy-lib/bindings/gen_rpc.ts @@ -250,6 +250,8 @@ note?: string, }; */ export type GetFlowsRequest = { ids: Array, }; +export type GetLastFilterRequest = Record; + export type GetMessagePayloadRequest = { flowId: string, seq: number, }; export type GetMessagePayloadResponse = { @@ -326,6 +328,8 @@ export type GetStorageSizeResponse = { */ bytes: number, }; +export type GetWindowPlacesRequest = Record; + export type GlobalAction = "recording_toggle" | "flows_clear"; export type HelperRegisterRequest = Record; @@ -492,7 +496,7 @@ export type ResetCapturedDataRequest = Record; export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, }; -export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], get_flows: [GetFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; +export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array], match_flows: [MatchFlowsRequest, Array], get_flows: [GetFlowsRequest, Array], count_saved_queries: [CountSavedQueriesRequest, Array], }; export type SaveFlowBodyRequest = { flowId: string, which: FlowBody, /** @@ -606,6 +610,11 @@ export type SetKeepTrafficRequest = { */ bytes: number | null, }; +/** + * The filter the window applied, kept in the encrypted database for the next launch. + */ +export type SetLastFilterRequest = { filter: string, }; + export type SetPortRequest = { port: number, }; export type SetRecordingRequest = { enabled: boolean, }; @@ -614,6 +623,11 @@ export type SetSystemProxyAtLaunchRequest = { enabled: boolean, }; export type SetTrafficRequest = { traffic: TrafficRetention, }; +/** + * The window's places, opaque to the engine, kept in memory for a reload of the window. + */ +export type SetWindowPlacesRequest = { places: string, }; + /** * A column the list sorts by. */ diff --git a/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql b/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql index 62d18ea27..00b369bb9 100644 --- a/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql +++ b/crates-proxy/yaak-proxy-lib/migrations/00000014_filter_history.sql @@ -4,3 +4,10 @@ CREATE TABLE filter_history filter TEXT NOT NULL PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ); + +-- The filter the window last applied, however it was applied, which the next launch restores. +CREATE TABLE last_filter +( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), + filter TEXT NOT NULL +); diff --git a/crates-proxy/yaak-proxy-lib/src/db.rs b/crates-proxy/yaak-proxy-lib/src/db.rs index 088d7113d..545cb1e84 100644 --- a/crates-proxy/yaak-proxy-lib/src/db.rs +++ b/crates-proxy/yaak-proxy-lib/src/db.rs @@ -525,6 +525,7 @@ mod tests { "flow_sweep_pending", "flows", "grpc_schemas", + "last_filter", "saved_queries" ] ); diff --git a/crates-proxy/yaak-proxy-lib/src/filter_history.rs b/crates-proxy/yaak-proxy-lib/src/filter_history.rs index 4c4f473b9..08d7c8c79 100644 --- a/crates-proxy/yaak-proxy-lib/src/filter_history.rs +++ b/crates-proxy/yaak-proxy-lib/src/filter_history.rs @@ -84,6 +84,38 @@ pub fn clear(db: &ProxyQueryManager) -> Result { .map_err(|e| failed("clear", e)) } +/// The filter the window last applied, in any way, or empty for none. Empty for a database from +/// before it, read without migrating. +pub fn last(db: &ProxyQueryManager) -> Result { + db.with_conn(|c| { + let conn = c.conn().resolve(); + let Ok(mut stmt) = conn.prepare("SELECT filter FROM last_filter WHERE id = 1") else { + return Ok(String::new()); + }; + match stmt.query_row([], |row| row.get(0)) { + Ok(filter) => Ok(filter), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(String::new()), + Err(e) => Err(failed("read the last filter from", e)), + } + }) +} + +/// Keeps the filter the window applied, for the next launch to restore. +pub fn set_last(db: &ProxyQueryManager, filter: &str) -> Result<(), String> { + db.with_tx(|c| { + c.conn() + .resolve() + .execute( + "INSERT INTO last_filter (id, filter) VALUES (1, ?) \ + ON CONFLICT (id) DO UPDATE SET filter = excluded.filter", + [filter], + ) + .map(|_| ()) + .map_err(yaak_database::Error::from) + }) + .map_err(|e| failed("keep the last filter in", e)) +} + #[cfg(test)] mod tests { use super::*; @@ -136,6 +168,20 @@ mod tests { assert!(kept.contains(&"f6".to_string())); } + #[test] + fn keeps_the_last_filter_applied() { + let dir = tempfile::tempdir().unwrap(); + let db = open(&dir); + assert_eq!(last(&db).unwrap(), ""); + set_last(&db, "@host:*.github.com").unwrap(); + set_last(&db, "stripe").unwrap(); + assert_eq!(last(&db).unwrap(), "stripe"); + set_last(&db, "").unwrap(); + assert_eq!(last(&db).unwrap(), ""); + // Apart from the history, which only Enter writes. + assert_eq!(filters(&db), Vec::::new()); + } + #[test] fn removes_one_or_all() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates-proxy/yaak-proxy-lib/src/lib.rs b/crates-proxy/yaak-proxy-lib/src/lib.rs index 8d34ec8e1..8a6298ec9 100644 --- a/crates-proxy/yaak-proxy-lib/src/lib.rs +++ b/crates-proxy/yaak-proxy-lib/src/lib.rs @@ -110,6 +110,9 @@ pub struct ProxyCtx { /// Captured traffic is to be emptied as the databases are first unlocked, this launch /// being set to clear it. Taken once it is. clear_at_launch: Arc, + /// The window's places, as it last sent them, for a reload of it to take back. Held in + /// memory only, as filters can hold captured values, and gone when the app quits. + window_places: Arc>>, /// `traffic.lock`, locked shared for as long as this process has the databases open, so /// another launch can't delete them from under it: clearing them takes it alone. traffic_lock: Arc>>, @@ -167,6 +170,7 @@ impl ProxyCtx { resets: Arc::new(AtomicU64::new(0)), sweeper: Default::default(), clear_at_launch: Arc::new(AtomicBool::new(false)), + window_places: Arc::new(Mutex::new(None)), traffic_lock: Arc::new(Mutex::new(None)), system_proxy_intent: Arc::new(Mutex::new(())), watching_system_proxy: Arc::new(AtomicBool::new(false)), @@ -1778,6 +1782,28 @@ pub struct ClearFilterHistoryRequest {} #[ts(export, export_to = "gen_rpc.ts")] pub struct FilterHistoryChangedPayload {} +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct GetLastFilterRequest {} + +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct GetWindowPlacesRequest {} + +/// The window's places, opaque to the engine, kept in memory for a reload of the window. +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SetWindowPlacesRequest { + pub places: String, +} + +/// The filter the window applied, kept in the encrypted database for the next launch. +#[derive(Deserialize, TS)] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct SetLastFilterRequest { + pub filter: String, +} + #[derive(Deserialize, TS)] #[ts(export, export_to = "gen_rpc.ts")] pub struct ClearFlowsRequest {} @@ -2809,6 +2835,27 @@ fn list_filter_history( filter_history::list(&ctx.db()?).map_err(rpc_error) } +fn get_window_places( + ctx: &ProxyCtx, + _req: GetWindowPlacesRequest, +) -> Result, RpcError> { + Ok(ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner).clone()) +} + +fn set_window_places(ctx: &ProxyCtx, req: SetWindowPlacesRequest) -> Result { + *ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner) = Some(req.places); + Ok(true) +} + +fn get_last_filter(ctx: &ProxyCtx, _req: GetLastFilterRequest) -> Result { + filter_history::last(&ctx.db()?).map_err(rpc_error) +} + +fn set_last_filter(ctx: &ProxyCtx, req: SetLastFilterRequest) -> Result { + filter_history::set_last(&ctx.db()?, &req.filter).map_err(rpc_error)?; + Ok(true) +} + fn record_filter(ctx: &ProxyCtx, req: RecordFilterRequest) -> Result { let recorded = filter_history::record(&ctx.db()?, &req.filter).map_err(rpc_error)?; if recorded { @@ -3722,6 +3769,10 @@ define_rpc! { record_filter(RecordFilterRequest) -> bool, delete_filter_history(DeleteFilterHistoryRequest) -> bool, clear_filter_history(ClearFilterHistoryRequest) -> usize, + get_last_filter(GetLastFilterRequest) -> String, + set_last_filter(SetLastFilterRequest) -> bool, + get_window_places(GetWindowPlacesRequest) -> Option, + set_window_places(SetWindowPlacesRequest) -> bool, list_grpc_schemas(ListGrpcSchemasRequest) -> Vec, list_messages(ListMessagesRequest) -> ListMessagesResponse, remove_grpc_schema(RemoveGrpcSchemaRequest) -> bool, @@ -4562,6 +4613,24 @@ mod tests { assert!(get_flows(ctx.clone(), req).await.unwrap().is_empty()); } + /// The window's places are held for a reload of it, in memory, and none at launch. + #[test] + fn window_places_are_held_in_memory_for_a_reload() { + let dir = tempfile::tempdir().unwrap(); + let (events, _rx) = RpcEventEmitter::new(); + let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + assert_eq!(get_window_places(&ctx, GetWindowPlacesRequest {}).unwrap(), None); + let places = r#"{"places":[{"filter":"stripe","selection":[],"tabs":{}}],"index":0}"#; + set_window_places(&ctx, SetWindowPlacesRequest { places: places.into() }).unwrap(); + let held = get_window_places(&ctx.clone(), GetWindowPlacesRequest {}).unwrap(); + assert_eq!(held.as_deref(), Some(places)); + drop(ctx); + + let (events, _rx) = RpcEventEmitter::new(); + let next = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap(); + assert_eq!(get_window_places(&next, GetWindowPlacesRequest {}).unwrap(), None); + } + /// Filters applied are listed newest first, each once, and the app hears of each change. #[test] fn filter_history_commands_record_list_and_forget() { @@ -5191,6 +5260,7 @@ mod tests { flow_with_response(&ctx, "fl_b", "text/plain", b"two"); saved_queries::create(&ctx.db().unwrap(), "Errors", "@status:>=400").unwrap(); filter_history::record(&ctx.db().unwrap(), "@host:api.stripe.com").unwrap(); + filter_history::set_last(&ctx.db().unwrap(), "@status:>=400").unwrap(); assert_eq!(flows::count(&ctx.db().unwrap()).unwrap(), 2); } if let Some(settings) = settings { @@ -5217,6 +5287,7 @@ mod tests { history.iter().map(|h| h.filter.as_str()).collect::>(), ["@host:api.stripe.com"] ); + assert_eq!(filter_history::last(&db).unwrap(), "@status:>=400"); // New traffic is written as before. flow_with_response(&ctx, "fl_c", "text/plain", b"three"); assert_eq!(flows::count(&db).unwrap(), 1);