mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Count Reconnect's connections within its own scope, refresh the pass-through set from Try Again's answer, report a failed trust, keep the pill's switch where the app can't trust the certificate, and keep a failed tunnel's error across Type and Size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
import type {
|
|
DecryptListEntry,
|
|
Flow,
|
|
FlowSortKey,
|
|
GetProxyStateResponse,
|
|
SavedQuery,
|
|
} from "@yaakapp-internal/proxy-lib";
|
|
import { sortFlows } from "../lib/columns";
|
|
import { appIconPng, fixtures } from "./fixtures";
|
|
import { BUILT_IN_EXCLUSIONS, httpsFlows } from "./httpsFixtures";
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
|
|
/**
|
|
* The decrypt list and the certificate as the harness holds them, from `?list=` (`none`,
|
|
* `host`, `everything`, `apps`) and `?ca=untrusted`. `?trust=cancel` cancels the password
|
|
* prompt; `?still=n` is how many open connections each change leaves encrypted.
|
|
*/
|
|
const https = {
|
|
list: initialList(params.get("list")),
|
|
trusted: params.get("ca") !== "untrusted",
|
|
passthrough: params.get("scene") === "mixed" ? ["sync.acme.dev:443"] : ([] as string[]),
|
|
};
|
|
|
|
function initialList(which: string | null): DecryptListEntry[] {
|
|
const own = (...entries: string[]) => entries.map((entry) => ({ entry, builtIn: false }));
|
|
switch (which) {
|
|
case "none":
|
|
return BUILT_IN_EXCLUSIONS;
|
|
case "host":
|
|
return [...own("host:www.google.com"), ...BUILT_IN_EXCLUSIONS];
|
|
case "everything":
|
|
return [...own("*"), ...BUILT_IN_EXCLUSIONS];
|
|
case "apps":
|
|
return [...own("app:Dia", "app:Google Chrome", "app:Acme Sync"), ...BUILT_IN_EXCLUSIONS];
|
|
case "excluded":
|
|
return [...own("*", "!host:*.slack.com"), ...BUILT_IN_EXCLUSIONS];
|
|
default:
|
|
return [
|
|
{ entry: "host:api.stripe.com", builtIn: false },
|
|
{ entry: "app:Google Chrome", builtIn: false },
|
|
{ entry: "!host:*.apple.com", builtIn: true },
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dev only: answers the engine's RPCs the harness pages need, from fixtures, so the card and
|
|
* settings render outside the app as they do inside it.
|
|
*/
|
|
export function installFixtureBackend() {
|
|
// `?firstrun`: setup not done yet, so the first-run card shows
|
|
if (params.has("firstrun")) localStorage.setItem("system_proxy_ever_on", "false");
|
|
else if (params.has("window")) localStorage.setItem("system_proxy_ever_on", "true");
|
|
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
|
|
.__TAURI_INTERNALS__;
|
|
// For checking the window as traffic arrives: `__harnessEmit("model_write", payload)`
|
|
(window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit;
|
|
// Unlistening, which the app does as its components unmount
|
|
(
|
|
window as unknown as { __TAURI_EVENT_PLUGIN_INTERNALS__: unknown }
|
|
).__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
|
|
internals.transformCallback = (callback: (event: unknown) => void) => {
|
|
const id = callbacks.size + 1;
|
|
callbacks.set(id, callback);
|
|
return id;
|
|
};
|
|
internals.invoke = async (
|
|
cmd: string,
|
|
args?: { cmd?: string; payload?: unknown; event?: string; handler?: number },
|
|
) => {
|
|
if (cmd === "rpc") return answer(args?.cmd ?? "", args?.payload);
|
|
if (cmd === "plugin:event|listen" && args?.event != null && args.handler != null) {
|
|
listeners.set(args.event, [...(listeners.get(args.event) ?? []), args.handler]);
|
|
return args.handler;
|
|
}
|
|
if (cmd.startsWith("plugin:event|")) return 0;
|
|
// The app menu's Go items, which the harness has none of
|
|
if (cmd === "set_go_menu") return null;
|
|
throw new Error(`Not in the harness: ${cmd}`);
|
|
};
|
|
(
|
|
window as unknown as { __TAURI_OS_PLUGIN_INTERNALS__: Record<string, string> }
|
|
).__TAURI_OS_PLUGIN_INTERNALS__ = {
|
|
os_type: "macos",
|
|
platform: "macos",
|
|
family: "unix",
|
|
version: "15.0",
|
|
eol: "\n",
|
|
};
|
|
}
|
|
|
|
export const HARNESS_STATE: GetProxyStateResponse = {
|
|
state: "running",
|
|
port: 9090,
|
|
configuredPort: 9090,
|
|
recording: true,
|
|
systemProxyAtLaunch: true,
|
|
httpVersion: "auto",
|
|
captureBodyLimit: 10 * 1024 * 1024,
|
|
traffic: "keep",
|
|
keepTrafficBytes: 2 * 1024 * 1024 * 1024,
|
|
startError: null,
|
|
encrypting: null,
|
|
locked: null,
|
|
otherOwner: null,
|
|
};
|
|
|
|
/** The engine's state as `?status=paused`, `stopped`, `failed` or `cli` asks; recording otherwise */
|
|
function harnessState(status: string | null): GetProxyStateResponse {
|
|
const stopped = { ...HARNESS_STATE, state: "stopped" as const, port: null };
|
|
switch (status) {
|
|
case "paused":
|
|
return { ...HARNESS_STATE, recording: false };
|
|
case "stopped":
|
|
return stopped;
|
|
case "failed":
|
|
return { ...stopped, startError: "Port 9090 is in use by another process." };
|
|
case "cli":
|
|
return {
|
|
...stopped,
|
|
otherOwner: {
|
|
owner: "cli",
|
|
pid: 4242,
|
|
startedAt: new Date().toISOString(),
|
|
proxyPort: 9090,
|
|
},
|
|
};
|
|
default:
|
|
return HARNESS_STATE;
|
|
}
|
|
}
|
|
|
|
/** Callbacks the window registered, by id, and the ids listening to each event */
|
|
const callbacks = new Map<number, (event: unknown) => void>();
|
|
const listeners = new Map<string, number[]>();
|
|
|
|
/** Sends an event to the window, as the engine does */
|
|
function emit(event: string, payload: unknown = {}) {
|
|
for (const id of listeners.get(event) ?? []) callbacks.get(id)?.({ event, id, payload });
|
|
}
|
|
|
|
/** 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 {
|
|
return JSON.parse(localStorage.getItem(key) ?? "[]") as T[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function store(key: string, value: unknown[]) {
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
}
|
|
|
|
/**
|
|
* A rough stand-in for the engine's search: `@host:`, `@method:` and `@status:` terms, and bare
|
|
* words in the method, URL and status
|
|
*/
|
|
function matches(flow: Flow, query: string): boolean {
|
|
const host = (() => {
|
|
try {
|
|
return new URL(flow.url).host;
|
|
} catch {
|
|
return flow.url;
|
|
}
|
|
})();
|
|
const text = `${flow.method} ${flow.url} ${flow.status ?? ""}`.toLowerCase();
|
|
// `OR` between groups, as the sidebar writes several rows
|
|
const groups = query.replace(/[()]/g, " ").split(/\s+OR\s+/);
|
|
if (groups.length > 1) return groups.some((g) => matches(flow, g));
|
|
return query
|
|
.toLowerCase()
|
|
.split(/\s+/)
|
|
.filter((t) => t !== "")
|
|
.every((term) => {
|
|
const [, field, value = ""] = /^@([a-z.]+):(.*)$/.exec(term) ?? [];
|
|
if (field === "host") return host.toLowerCase().includes(value.replaceAll("*", ""));
|
|
if (field === "method") return flow.method.toLowerCase() === value;
|
|
if (field === "status") return String(flow.status ?? "").startsWith(value.replace(/x+$/, ""));
|
|
if (field === "is" && value === "encrypted") return flow.tunnelled;
|
|
if (field === "is" && value === "rejected") return flow.tlsRejected != null;
|
|
return field == null ? text.includes(term) : true;
|
|
});
|
|
}
|
|
|
|
function allFlows(): Flow[] {
|
|
if (params.has("firstrun")) return [];
|
|
const scene = httpsFlows(params.get("scene"));
|
|
if (scene != null) return scene.map((f, i) => ({ ...f, number: i + 1 }));
|
|
const many = Number(new URLSearchParams(location.search).get("many") ?? 0);
|
|
return (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({
|
|
...f,
|
|
number: 1401 + i,
|
|
}));
|
|
}
|
|
|
|
function caStatus() {
|
|
return {
|
|
trusted: https.trusted,
|
|
installed: https.trusted,
|
|
certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem",
|
|
};
|
|
}
|
|
|
|
async function answer(cmd: string, payload: unknown): Promise<unknown> {
|
|
const req = (payload ?? {}) as Record<string, unknown>;
|
|
switch (cmd) {
|
|
case "get_flows": {
|
|
const ids = new Set(req.ids as string[]);
|
|
return allFlows().filter((f) => ids.has(f.id));
|
|
}
|
|
case "list_filter_history":
|
|
return stored<string>(HISTORY_KEY).map((filter) => ({ filter, appliedAt: "" }));
|
|
case "record_filter": {
|
|
const filter = String(req.filter).trim();
|
|
if (filter === "") return false;
|
|
store(HISTORY_KEY, [filter, ...stored<string>(HISTORY_KEY).filter((f) => f !== filter)]);
|
|
emit("filter_history_changed");
|
|
return true;
|
|
}
|
|
case "delete_filter_history":
|
|
store(
|
|
HISTORY_KEY,
|
|
stored<string>(HISTORY_KEY).filter((f) => f !== req.filter),
|
|
);
|
|
emit("filter_history_changed");
|
|
return true;
|
|
case "clear_filter_history":
|
|
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": {
|
|
const saved = stored<SavedQuery>(SAVED_KEY);
|
|
const query = {
|
|
id: `sq_${saved.length + 1}`,
|
|
name: String(req.name),
|
|
query: String(req.query),
|
|
sortOrder: saved.length,
|
|
};
|
|
store(SAVED_KEY, [...saved, query]);
|
|
emit("saved_queries_changed");
|
|
return query;
|
|
}
|
|
case "count_saved_queries":
|
|
return stored<SavedQuery>(SAVED_KEY).map((s) => ({
|
|
id: s.id,
|
|
count: allFlows().filter((f) => matches(f, s.query)).length,
|
|
}));
|
|
case "watch_query":
|
|
return true;
|
|
case "get_flow_body": {
|
|
// A copy from `?many` reads its original's body
|
|
const id = String(req.flowId).split("~")[0];
|
|
const fixture = fixtures.find((f) => f.flow.id === id);
|
|
if (fixture == null) throw new Error(`No fixture for ${String(req.flowId)}`);
|
|
return req.which === "request" ? fixture.requestBody : fixture.responseBody;
|
|
}
|
|
case "format_body": {
|
|
const text = typeof req.text === "string" ? req.text : "";
|
|
try {
|
|
return JSON.stringify(JSON.parse(text), null, 2);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
case "app_icon":
|
|
return appIconPng;
|
|
case "get_proxy_state":
|
|
return harnessState(new URLSearchParams(location.search).get("status"));
|
|
case "get_storage_size":
|
|
return { bytes: 412 * 1024 * 1024 };
|
|
case "ca_status":
|
|
return caStatus();
|
|
case "ca_trust": {
|
|
// A moment for the prompt, so the controls that asked show it
|
|
await new Promise((r) => setTimeout(r, Number(params.get("prompt") ?? 900)));
|
|
if (params.get("trust") === "cancel") return { ...caStatus(), cancelled: true };
|
|
https.trusted = true;
|
|
return { ...caStatus(), cancelled: false };
|
|
}
|
|
case "edit_decrypt_list": {
|
|
const { add = [], remove = [] } = req as { add?: string[]; remove?: string[] };
|
|
const own = https.list.filter((e) => !e.builtIn && !remove.includes(e.entry));
|
|
for (const entry of add) {
|
|
if (!own.some((e) => e.entry === entry)) own.push({ entry, builtIn: false });
|
|
}
|
|
const builtIns = BUILT_IN_EXCLUSIONS.filter(
|
|
(e) => https.list.some((l) => l.entry === e.entry) && !remove.includes(e.entry),
|
|
);
|
|
https.list = [...own, ...builtIns];
|
|
emit("decrypt_list_changed", { list: { entries: https.list } });
|
|
const decrypts = add.some((e) => !e.startsWith("!")) || remove.some((e) => e.startsWith("!"));
|
|
return {
|
|
entries: https.list,
|
|
stillEncrypted: decrypts ? Number(params.get("still") ?? 3) : 0,
|
|
};
|
|
}
|
|
case "list_passthrough":
|
|
return { addresses: https.passthrough };
|
|
case "forget_passthrough": {
|
|
const address = String(req.address);
|
|
const forgotten = https.passthrough.includes(address);
|
|
https.passthrough = https.passthrough.filter((a) => a !== address);
|
|
emit("passthrough_changed", { addresses: https.passthrough });
|
|
return { forgotten, addresses: https.passthrough };
|
|
}
|
|
case "reconnect_tunnels":
|
|
return { closed: 1 };
|
|
case "list_reconnectable_tunnels":
|
|
return Array.from({ length: Number(params.get("still") ?? 3) }, (_, i) => ({
|
|
id: `harness_${i}`,
|
|
host: "www.google.com",
|
|
port: 443,
|
|
appName: "Dia",
|
|
appBundleId: null,
|
|
flowId: null,
|
|
reason: "not_listed",
|
|
}));
|
|
case "system_proxy_status":
|
|
return {
|
|
enabled: !params.has("firstrun"),
|
|
port: 9090,
|
|
services: [],
|
|
backend: "helper",
|
|
helper: "enabled",
|
|
supported: true,
|
|
};
|
|
case "get_decrypt_list":
|
|
return { entries: https.list };
|
|
case "list_actions":
|
|
// As the engine lists them on macOS
|
|
return {
|
|
actions: [
|
|
[
|
|
{ scope: "global", action: "recording_toggle" },
|
|
{ label: "Pause or Resume Recording", defaultHotkey: "Meta+R" },
|
|
],
|
|
[
|
|
{ scope: "global", action: "flows_clear" },
|
|
{ label: "Clear Flows", defaultHotkey: "Meta+Shift+K" },
|
|
],
|
|
],
|
|
};
|
|
case "list_flows": {
|
|
const all = allFlows();
|
|
const query = typeof req.query === "string" ? req.query : "";
|
|
const hideEncrypted = req.hideEncrypted === true && !query.includes("@is:encrypted");
|
|
const matching = all.filter((f) => matches(f, query) && !(hideEncrypted && f.tunnelled));
|
|
// In a sort's order, as the database lists it, all in one page
|
|
const sort = (req.sort ?? []) as FlowSortKey[];
|
|
const flows = sort.length > 0 ? sortFlows(matching, sort) : matching.reverse();
|
|
const page = req.limit === 0 ? [] : flows;
|
|
return {
|
|
flows: page,
|
|
total: all.length,
|
|
matching: query === "" && !hideEncrypted ? null : matching.length,
|
|
cursor: null,
|
|
};
|
|
}
|
|
case "list_seen":
|
|
return { hosts: [], apps: [] };
|
|
// A few of the engine's fields, so the search box highlights and completes
|
|
case "query_schema":
|
|
return {
|
|
schema: {
|
|
fields: [
|
|
{
|
|
name: "host",
|
|
kind: { type: "text" },
|
|
info: "The request's host",
|
|
example: "@host:api.example.com",
|
|
},
|
|
{
|
|
name: "method",
|
|
kind: { type: "text" },
|
|
info: "GET, POST, …",
|
|
example: "@method:post",
|
|
},
|
|
{
|
|
name: "status",
|
|
kind: { type: "number", units: [] },
|
|
info: "The response's status",
|
|
example: "@status:>=400",
|
|
},
|
|
{
|
|
name: "header",
|
|
kind: { type: "text" },
|
|
map: true,
|
|
info: "A request or response header",
|
|
example: "@header.accept:*json*",
|
|
},
|
|
{
|
|
name: "res.header",
|
|
kind: { type: "text" },
|
|
map: true,
|
|
info: "A response header",
|
|
example: "@res.header.x-cache:hit",
|
|
},
|
|
{
|
|
name: "query",
|
|
kind: { type: "text" },
|
|
map: true,
|
|
info: "A query parameter",
|
|
example: "@query.page:2",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
// Header and query parameter names, for completing a map's keys
|
|
case "query_values": {
|
|
const path = (payload as { path?: string } | undefined)?.path ?? "";
|
|
if (path.endsWith("header")) return ["x-cache", "x-request-id", "content-type"];
|
|
return path === "query" ? ["expand", "limit"] : [];
|
|
}
|
|
case "validate_query":
|
|
return [];
|
|
default:
|
|
throw new Error(`Not in the harness: ${cmd}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `?many=3000`: the fixtures over and over, a second apart, for checking the list with a lot of
|
|
* traffic. Every fourth has no response yet, and a program runs in a few working folders.
|
|
*/
|
|
function manyFlows(count: number): Flow[] {
|
|
const start = Date.parse("2026-11-09T09:00:00Z");
|
|
const folders = ["/Users/you/src/yaak", "/Users/you/.codex/worktrees/1a79/yaak", null];
|
|
return Array.from({ length: count }, (_, i) => {
|
|
const base = fixtures[i % fixtures.length]?.flow as Flow;
|
|
const pending = i % 4 === 3;
|
|
return {
|
|
...base,
|
|
id: `${base.id}~${i}`,
|
|
startedAt: new Date(start + i * 1000).toISOString().replace("Z", ""),
|
|
url:
|
|
i % 7 === 0 ? `${base.url}/a/rather/long/path/segment/${i}?page=${i}&q=search` : base.url,
|
|
status: pending ? null : base.status,
|
|
contentType: pending ? null : base.contentType,
|
|
responseSize: pending ? 0 : base.responseSize,
|
|
elapsedMs: pending ? null : base.elapsedMs,
|
|
clientCwd: base.clientBundleId == null ? (folders[i % folders.length] ?? null) : null,
|
|
// A cache header with a few values and some without, for a column of it to sort
|
|
responseHeaders:
|
|
i % 5 === 4
|
|
? base.responseHeaders
|
|
: [
|
|
...base.responseHeaders,
|
|
{ name: "X-Cache", value: ["HIT", "miss", "Hit", "EXPIRED"][i % 4] ?? "" },
|
|
],
|
|
};
|
|
});
|
|
}
|