mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Keep filters out of the web view's storage, and let a newer selection win over a late read
Filters can hold captured values, so neither the reload's places nor the launch's last filter go into web view storage: the engine holds the places in memory while the app runs, and proxy.db's last_filter row keeps the last filter. A read of a selected flow that answers after the window shows a newer copy no longer replaces it. The pane and stream harnesses select through goToPlace, so switching a tab no longer empties their card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2d4c39c62
commit
cc83c7cd1e
12 files changed
+281
-50
No files matched your search
@@ -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]);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }))),
|
||||
|
||||
@@ -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<T>(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<SavedQuery>(SAVED_KEY);
|
||||
case "create_saved_query": {
|
||||
|
||||
@@ -14,6 +14,8 @@ import { EMPTY_PLACE, stackOf } from "./places";
|
||||
|
||||
const existing = new Set<string>();
|
||||
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<string[]>([]) }));
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+15
-1
@@ -250,6 +250,8 @@ note?: string, };
|
||||
*/
|
||||
export type GetFlowsRequest = { ids: Array<string>, };
|
||||
|
||||
export type GetLastFilterRequest = Record<string, never>;
|
||||
|
||||
export type GetMessagePayloadRequest = { flowId: string, seq: number, };
|
||||
|
||||
export type GetMessagePayloadResponse = {
|
||||
@@ -326,6 +328,8 @@ export type GetStorageSizeResponse = {
|
||||
*/
|
||||
bytes: number, };
|
||||
|
||||
export type GetWindowPlacesRequest = Record<string, never>;
|
||||
|
||||
export type GlobalAction = "recording_toggle" | "flows_clear";
|
||||
|
||||
export type HelperRegisterRequest = Record<string, never>;
|
||||
@@ -492,7 +496,7 @@ export type ResetCapturedDataRequest = Record<string, never>;
|
||||
|
||||
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<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], 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<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
|
||||
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<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], 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<GrpcSchema>], 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<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -525,6 +525,7 @@ mod tests {
|
||||
"flow_sweep_pending",
|
||||
"flows",
|
||||
"grpc_schemas",
|
||||
"last_filter",
|
||||
"saved_queries"
|
||||
]
|
||||
);
|
||||
|
||||
@@ -84,6 +84,38 @@ pub fn clear(db: &ProxyQueryManager) -> Result<usize, String> {
|
||||
.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<String, String> {
|
||||
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::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_one_or_all() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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<AtomicBool>,
|
||||
/// 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<Mutex<Option<String>>>,
|
||||
/// `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<Mutex<Option<std::fs::File>>>,
|
||||
@@ -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<Option<String>, RpcError> {
|
||||
Ok(ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner).clone())
|
||||
}
|
||||
|
||||
fn set_window_places(ctx: &ProxyCtx, req: SetWindowPlacesRequest) -> Result<bool, RpcError> {
|
||||
*ctx.window_places.lock().unwrap_or_else(PoisonError::into_inner) = Some(req.places);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_last_filter(ctx: &ProxyCtx, _req: GetLastFilterRequest) -> Result<String, RpcError> {
|
||||
filter_history::last(&ctx.db()?).map_err(rpc_error)
|
||||
}
|
||||
|
||||
fn set_last_filter(ctx: &ProxyCtx, req: SetLastFilterRequest) -> Result<bool, RpcError> {
|
||||
filter_history::set_last(&ctx.db()?, &req.filter).map_err(rpc_error)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn record_filter(ctx: &ProxyCtx, req: RecordFilterRequest) -> Result<bool, RpcError> {
|
||||
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<String>,
|
||||
set_window_places(SetWindowPlacesRequest) -> bool,
|
||||
list_grpc_schemas(ListGrpcSchemasRequest) -> Vec<GrpcSchema>,
|
||||
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::<Vec<_>>(),
|
||||
["@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);
|
||||
|
||||
Reference in new issue
Block a user