Files
yaak-mountain-loop/apps/yaak-proxy/components/SettingsProxy.tsx
T
Gregory SchierandClaude Opus 5.5 4fc95d264c Clear Yaak Proxy traffic at launch and follow the real system proxy
Captured traffic is now cleared as the app launches by default, with a
"Keep between launches" choice that brings the size limit with it. An
install that had a size limit set keeps its traffic. The Proxy settings
tab is regrouped into Proxy, Decryption, Traffic and Terminal, and the
per-body limit row is gone.

The app now watches macOS proxy settings and follows them: when another
app or the user points the system proxy elsewhere, the override shows as
off and the settings saved for restoring are dropped, so quitting puts
back nothing the app didn't set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:26:38 -07:00

380 lines
12 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type {
HttpVersion,
SystemProxyStatus,
TrafficRetention,
} from "@yaakapp-internal/proxy-lib";
import {
Button,
Dialog,
DialogFooter,
Heading,
Icon,
RadioDropdown,
SettingRow,
SettingRowBoolean,
PlainInput,
SettingRowSelect,
SettingSelectControl,
SettingSnippet,
SettingsList,
SettingsSection,
VStack,
} from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useState } from "react";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperDialogOpenAtom } from "../lib/atoms";
import {
defaultShell,
environmentBlock,
environmentVars,
TERMINAL_SHELLS,
type TerminalShell,
} from "../lib/env";
import {
KEEP_TRAFFIC_OPTIONS,
sizeFromOptionValue,
sizeOptionsFor,
sizeOptionValue,
} from "../lib/settings";
import { helperRow } from "../lib/systemProxy";
import { proxyStateAtom, refreshProxyState } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { DecryptListEditor } from "./DecryptListEditor";
/**
* The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what
* happens to captured traffic, and the environment for one terminal. Everything set once and
* left alone, which is what left the state pill's menu.
*/
export function SettingsProxy() {
const proxyState = useAtomValue(proxyStateAtom);
const store = useStore();
const refresh = { onSettled: () => void refreshProxyState(store) };
const setHttpVersion = useRpcMutation("set_http_version", refresh);
const setAtLaunch = useRpcMutation("set_system_proxy_at_launch", refresh);
const systemProxy = useRpcQuery("system_proxy_status", {});
// Another process owning the engine wouldn't see changes until it restarts
const elsewhere = proxyState?.otherOwner != null;
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">Listening, decryption, traffic and terminals</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<PortRow disabled={elsewhere} />
<SettingRowSelect<HttpVersion>
name="httpVersion"
title="HTTP version"
description="For new connections"
value={proxyState?.httpVersion ?? "auto"}
disabled={elsewhere}
onChange={(httpVersion) => setHttpVersion.mutate({ httpVersion })}
options={[
{ label: "Auto", value: "auto" },
{ label: "HTTP/1.1 only", value: "http1Only" },
]}
/>
{systemProxy.data?.supported === true && (
<>
<SettingRowBoolean
title="Override system proxy"
description="At launch"
checked={proxyState?.systemProxyAtLaunch ?? true}
disabled={setAtLaunch.isPending}
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
/>
<HelperRow status={systemProxy.data} />
</>
)}
</SettingsSection>
<SettingsSection title="Decryption">
<DecryptListEditor className="py-4" />
</SettingsSection>
<SettingsSection title="Traffic">
<TrafficRow />
{proxyState?.traffic === "keep" && <KeepTrafficRow />}
<ResetRow />
</SettingsSection>
<SettingsSection title="Terminal">
<TerminalRow />
</SettingsSection>
</SettingsList>
</VStack>
);
}
/** A port from 1 to 65535, or null */
function parsePort(text: string): number | null {
if (!/^\d+$/.test(text.trim())) return null;
const port = Number(text.trim());
return port > 0 && port < 65536 ? port : null;
}
/**
* The port, saved when the field is left or Enter is pressed rather than on each keystroke:
* every save restarts a running proxy, so typing 8080 must not restart it on 8, 80 and 808.
*/
function PortRow({ disabled }: { disabled: boolean }) {
const proxyState = useAtomValue(proxyStateAtom);
const store = useStore();
const showToast = useShowToast();
const saved = proxyState?.configuredPort ?? 9090;
const [draft, setDraft] = useState<string | null>(null);
// Puts the saved port back in the field after a rejected or failed change
const [resets, setResets] = useState(0);
const reset = () => {
setDraft(null);
setResets((n) => n + 1);
};
const setPort = useRpcMutation("set_port", {
onError: (err) => showToast({ message: err.message, color: "danger" }),
onSettled: () => {
reset();
void refreshProxyState(store);
},
});
const commit = () => {
if (draft == null || setPort.isPending) return;
const port = parsePort(draft);
if (port == null || port === saved) {
reset();
return;
}
setPort.mutate({ port });
};
return (
<SettingRow title="Port" description="Restarts the proxy">
<PlainInput
hideLabel
size="sm"
name="port"
label="Port"
type="number"
containerClassName="w-48!"
defaultValue={`${saved}`}
forceUpdateKey={`${saved}.${resets}`}
disabled={disabled || setPort.isPending}
validate={(v) => parsePort(v) != null}
onChange={setDraft}
onBlur={commit}
onKeyDownCapture={(e) => {
if (e.key === "Enter") commit();
}}
/>
</SettingRow>
);
}
function HelperRow({ status }: { status: SystemProxyStatus }) {
const queryClient = useQueryClient();
const openDialog = useSetAtom(helperDialogOpenAtom);
const unregister = useRpcMutation("helper_unregister", {
onSuccess: (status) =>
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status),
});
const row = helperRow(status.helper, status.backend);
return (
<SettingRow
title="Helper"
description={
unregister.error != null ? (
<span className="text-danger">{unregister.error.message}</span>
) : (
`${capitalize(row.title.replace(/^Helper: /, ""))}. ${row.description}`
)
}
>
{row.action != null && (
<Button
size="xs"
variant="border"
color={row.action === "unregister" ? "default" : "primary"}
isLoading={unregister.isPending}
onClick={() => (row.action === "unregister" ? unregister.mutate({}) : openDialog(true))}
>
{row.action === "install"
? "Install"
: row.action === "approve"
? "Approve"
: "Unregister"}
</Button>
)}
</SettingRow>
);
}
/** Whether captured traffic is cleared as the app launches or kept between launches */
function TrafficRow() {
const proxyState = useAtomValue(proxyStateAtom);
const store = useStore();
const setTraffic = useRpcMutation("set_traffic", {
onSettled: () => void refreshProxyState(store),
});
return (
<SettingRowSelect<TrafficRetention>
name="traffic"
title="Captured traffic"
selectClassName="w-56!"
value={proxyState?.traffic ?? "clearOnLaunch"}
disabled={proxyState?.otherOwner != null}
onChange={(traffic) => setTraffic.mutate({ traffic })}
options={[
{ label: "Clear on launch", value: "clearOnLaunch" },
{ label: "Keep between launches", value: "keep" },
]}
/>
);
}
/** The most traffic kept between launches, beside what is stored now */
function KeepTrafficRow() {
const proxyState = useAtomValue(proxyStateAtom);
const store = useStore();
const size = useRpcQuery("get_storage_size", {}, { refetchInterval: 10_000 });
const setKeep = useRpcMutation("set_keep_traffic", {
onSettled: () => void refreshProxyState(store),
});
const keep = proxyState?.keepTrafficBytes ?? null;
return (
<SettingRow title="Size limit" description="Oldest flows dropped past it">
<div className="flex items-center gap-3">
{size.data != null && (
<span className="text-sm text-text-subtle tabular-nums whitespace-nowrap">
{formatSize(size.data.bytes)} used
</span>
)}
<SettingSelectControl
name="keepTraffic"
label="Size limit"
value={sizeOptionValue(keep)}
disabled={proxyState?.otherOwner != null}
onChange={(v) => setKeep.mutate({ bytes: sizeFromOptionValue(v) })}
options={sizeOptionsFor(KEEP_TRAFFIC_OPTIONS, keep).map(({ label, value }) => ({
label,
value,
}))}
/>
</div>
</SettingRow>
);
}
function ResetRow() {
const [confirming, setConfirming] = useState(false);
const store = useStore();
const queryClient = useQueryClient();
const showToast = useShowToast();
const reset = useRpcMutation("reset_captured_data", {
onSuccess: () => {
setConfirming(false);
void queryClient.invalidateQueries();
showToast({ message: "Captured data reset" });
},
onSettled: () => void refreshProxyState(store),
});
return (
<SettingRow title="Reset captured data" description="Deletes all flows and their keys">
<Button size="xs" variant="border" color="danger" onClick={() => setConfirming(true)}>
Reset…
</Button>
<Dialog
open={confirming}
onClose={() => setConfirming(false)}
size="sm"
title="Reset captured data?"
description="Every captured flow and the keys that encrypt them are deleted."
>
{reset.error != null && <p className="text-danger text-sm">{reset.error.message}</p>}
<DialogFooter
actions={[
{ label: "Cancel", variant: "border", onClick: () => setConfirming(false) },
{
label: "Reset",
color: "danger",
isLoading: reset.isPending,
onClick: () => reset.mutate({}),
},
]}
/>
</Dialog>
</SettingRow>
);
}
function TerminalRow() {
const proxyState = useAtomValue(proxyStateAtom);
const caStatus = useRpcQuery("ca_status", {});
const showToast = useShowToast();
const os = getOsType();
const [shell, setShell] = useState<TerminalShell>(() => defaultShell(os));
const openTerminal = useRpcMutation("open_terminal", {
onError: (err) => showToast({ message: err.message, color: "danger" }),
});
const port =
proxyState?.port ?? proxyState?.otherOwner?.proxyPort ?? proxyState?.configuredPort ?? 9090;
const caPath = caStatus.data?.certPath;
const shellLabel = TERMINAL_SHELLS.find((s) => s.value === shell)?.label ?? shell;
return (
<div className="py-4">
<div className="flex items-center gap-2">
<span className="text-text">Environment</span>
<RadioDropdown
value={shell}
onChange={(v) => setShell(v as TerminalShell)}
items={TERMINAL_SHELLS.map((s) => ({ label: s.label, value: s.value }))}
>
<Button
size="2xs"
variant="border"
title="Shell"
rightSlot={<Icon icon="chevron_down" size="xs" className="ml-1" />}
>
{shellLabel}
</Button>
</RadioDropdown>
<Button
size="2xs"
variant="border"
className="ml-auto"
title={
os === "macos"
? "A new Terminal window with these set"
: "Not available on this platform yet"
}
disabled={caPath == null || os !== "macos"}
isLoading={openTerminal.isPending}
onClick={() =>
caPath != null && openTerminal.mutate({ vars: environmentVars(port, caPath) })
}
>
Open Terminal
</Button>
</div>
<p className="mt-1 text-sm text-text-subtle">Proxies one terminal instead of the system</p>
{caPath != null && (
<SettingSnippet
className="mt-3"
language="text"
snippet={environmentBlock(shell, port, caPath)}
/>
)}
</div>
);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}