Files
yaak-mountain-loop/apps/yaak-proxy/components/SettingsProxy.tsx
T
Gregory SchierandClaude Fable 5.2 35058cc3be Yaak Proxy: prose pass over the UI copy
Apply the approved wording table: labels stay short, explanations are one
full sentence about the user's traffic, and detail moves behind an info
icon on the first-run card, the body banners, the Settings Helper row and
the rejected card. One word per thing: traffic and flows, recorded,
truncated, domains, searches, the CLI, Retry.

Backend messages that reach the window change with them, with the tests
and harness fixtures that assert on the old text. The two working files
for the pass are removed from the branch.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 08:41:49 -07:00

387 lines
13 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,
Icon,
IconTooltip,
RadioDropdown,
SettingRow,
SettingRowBoolean,
PlainInput,
SettingRowSelect,
SettingSelectControl,
SettingSnippet,
SettingsList,
SettingsPage,
SettingsSection,
} from "@yaakapp-internal/ui";
import { useAtom, 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 { confirmClearFlowsAtom } from "../lib/clearFlows";
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 { DecryptionSettings } 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 (
<SettingsPage
title="Proxy"
description="Configure the port, decryption, recorded traffic, and terminal environment."
>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<PortRow disabled={elsewhere} />
<SettingRowSelect<HttpVersion>
name="httpVersion"
title="HTTP version"
description="The change applies to new connections."
value={proxyState?.httpVersion ?? "auto"}
disabled={elsewhere}
onChange={(httpVersion) => setHttpVersion.mutate({ httpVersion })}
options={[
{ label: "Automatic", value: "auto" },
{ label: "HTTP/1.1 only", value: "http1Only" },
]}
/>
{systemProxy.data?.supported === true && (
<>
<SettingRowBoolean
title="Override system proxy at launch"
description="Traffic from every app will be recorded."
checked={proxyState?.systemProxyAtLaunch ?? true}
disabled={setAtLaunch.isPending}
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
/>
<HelperRow status={systemProxy.data} />
</>
)}
</SettingsSection>
<SettingsSection title="Decryption">
<DecryptionSettings />
</SettingsSection>
<SettingsSection title="Traffic">
<TrafficRow />
{proxyState?.traffic === "keep" && <KeepTrafficRow />}
<ConfirmClearRow />
<ResetRow />
</SettingsSection>
<SettingsSection title="Terminal">
<TerminalRow />
</SettingsSection>
</SettingsList>
</SettingsPage>
);
}
/** 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="Changing the port 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>
) : (
<span className="inline-flex items-center gap-1.5">
{row.description}
<IconTooltip iconSize="sm" content={row.tooltip} />
</span>
)
}
>
{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"
: "Uninstall"}
</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="Recorded 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="The oldest flows will be cleared first.">
<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>
);
}
/** Asking before Clear, which its dialog's "Don't ask again" turns off */
function ConfirmClearRow() {
const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom);
return (
<SettingRowBoolean title="Confirm before clearing" checked={confirm} onChange={setConfirm} />
);
}
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: "Recorded data reset" });
},
onSettled: () => void refreshProxyState(store),
});
return (
<SettingRow
title="Reset recorded data"
description="All traffic and its encryption keys will be deleted."
>
<Button size="xs" variant="border" color="danger" onClick={() => setConfirming(true)}>
Reset
</Button>
<Dialog
open={confirming}
onClose={() => setConfirming(false)}
size="sm"
title="Reset Recorded Data"
description="Permanently delete all traffic and its encryption keys?"
>
{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 will open with these variables set."
: "Opening a terminal is only supported on macOS."
}
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">Traffic from one terminal will be recorded.</p>
{caPath != null && (
<SettingSnippet
className="mt-3"
language="text"
snippet={environmentBlock(shell, port, caPath)}
/>
)}
</div>
);
}