From 249f16d49f3deecc87b0a2a48792e3f5afc4162f Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Wed, 22 Jul 2026 01:09:27 +0800 Subject: [PATCH] feat: add capture profile settings --- apps/desktop/e2e/workbench.spec.ts | 30 ++++++ apps/desktop/src/App.test.tsx | 85 +++++++++++++++ apps/desktop/src/App.tsx | 12 +++ apps/desktop/src/SettingsDialog.tsx | 157 +++++++++++++++++++++++++++- apps/desktop/src/styles.css | 30 +++++- apps/desktop/src/workspace.test.ts | 18 ++++ apps/desktop/src/workspace.ts | 27 +++++ docs/progress.html | 9 +- docs/threat-model.html | 4 +- 9 files changed, 362 insertions(+), 10 deletions(-) diff --git a/apps/desktop/e2e/workbench.spec.ts b/apps/desktop/e2e/workbench.spec.ts index 9f34514..7f1eab7 100644 --- a/apps/desktop/e2e/workbench.spec.ts +++ b/apps/desktop/e2e/workbench.spec.ts @@ -118,6 +118,36 @@ test.describe("desktop workbench quality baseline", () => { expect(layout.scrollHeight).toBeLessThanOrEqual(layout.clientHeight); }); + test("keeps Capture Profile controls usable at minimum size", async ({ page }) => { + await page.setViewportSize(MINIMUM_VIEWPORT); + await page.goto("/"); + await page.getByRole("button", { name: "Settings" }).click(); + const dialog = page.getByRole("dialog", { name: "Settings & diagnostics" }); + await dialog.getByRole("tab", { name: "Profiles" }).click(); + + await expect(dialog.getByRole("textbox", { name: "Profile name" })).toHaveValue("OpenAI default"); + await expect(dialog.getByRole("textbox", { name: "Gateway origin" })).toHaveValue("https://api.openai.com"); + await expect(dialog.getByRole("textbox", { name: "Additional capture hosts" })).toBeVisible(); + await expect(dialog.getByText("Pause capture", { exact: true })).toBeVisible(); + + const layout = await dialog.evaluate((element) => { + const content = element.querySelector(".profile-content"); + return { + dialogClientWidth: element.clientWidth, + dialogScrollWidth: element.scrollWidth, + contentClientWidth: content?.clientWidth ?? 0, + contentScrollWidth: content?.scrollWidth ?? 0, + }; + }); + expect(layout.dialogScrollWidth).toBe(layout.dialogClientWidth); + expect(layout.contentScrollWidth).toBe(layout.contentClientWidth); + + const saveProfile = dialog.getByRole("button", { name: "Save Profile" }); + await saveProfile.scrollIntoViewIfNeeded(); + await expect(saveProfile).toBeVisible(); + await expect(saveProfile).toBeDisabled(); + }); + test("keeps validated update recovery read-only and exportable", async ({ page }) => { await page.setViewportSize(MINIMUM_VIEWPORT); await page.goto("/?recoveryMode=1"); diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 0a3468a..8fc1c3a 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -9,6 +9,7 @@ import { App } from "./App"; import fixture from "./data/workspace.json"; import type { BetaMetricsPreview, + CaptureProfile, ExportPreview, ExportProfile, SupportBundlePreview, @@ -124,6 +125,9 @@ describe("request workbench", () => { .toHaveAttribute("aria-selected", "true"); connectionTab.focus(); await user.keyboard("{ArrowRight}"); + expect(within(dialog).getByRole("tab", { name: "Profiles" })) + .toHaveAttribute("aria-selected", "true"); + await user.keyboard("{ArrowRight}"); expect(within(dialog).getByRole("tab", { name: "Metrics" })) .toHaveAttribute("aria-selected", "true"); await user.keyboard("{ArrowRight}"); @@ -237,6 +241,7 @@ describe("request workbench", () => { await user.click(screen.getByRole("button", { name: "Settings" })); const settings = screen.getByRole("dialog", { name: "Settings & diagnostics" }); + await user.click(within(settings).getByRole("tab", { name: "Profiles" })); const settingsResults = await axe.run(settings, options); expect(settingsResults.violations.map((violation) => violation.id)).toEqual([]); await user.keyboard("{Escape}"); @@ -416,6 +421,86 @@ describe("request workbench", () => { .toHaveAttribute("aria-selected", "true"); }); + it("saves a normalized Capture Profile only from a paused Gateway", async () => { + const user = userEvent.setup(); + window.__TAURI_INTERNALS__ = {}; + let workspace = structuredClone(fixture) as unknown as WorkspaceBootstrap; + workspace.capture = { + ...workspace.capture, + active: true, + canControl: true, + mode: "gateway", + endpoint: "http://127.0.0.1:8787", + }; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "bootstrap_workspace") return structuredClone(workspace); + if (command === "set_capture_active" && args?.active === false) { + workspace = { + ...workspace, + capture: { ...workspace.capture, active: false }, + compatibility: { + ...workspace.compatibility, + code: "capture_paused", + status: "attention", + title: "Gateway capture paused", + action: "resume_capture", + }, + }; + return structuredClone(workspace); + } + if (command === "update_capture_profile") { + const profile = args?.profile as CaptureProfile; + workspace = { + ...workspace, + captureProfile: { + ...profile, + name: profile.name.trim(), + gatewayUpstream: new URL(profile.gatewayUpstream).origin, + additionalHosts: [...profile.additionalHosts].sort(), + }, + capture: { ...workspace.capture, profile: `${profile.name.trim()} · Local Gateway` }, + }; + return structuredClone(workspace); + } + throw new Error(`Unexpected command: ${command}`); + }); + + render(); + await user.click(await screen.findByRole("button", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings & diagnostics" }); + await user.click(within(dialog).getByRole("tab", { name: "Profiles" })); + + const name = within(dialog).getByRole("textbox", { name: "Profile name" }); + const gateway = within(dialog).getByRole("textbox", { name: "Gateway origin" }); + const hosts = within(dialog).getByRole("textbox", { name: "Additional capture hosts" }); + await user.clear(name); + await user.type(name, "Private lab"); + await user.clear(gateway); + await user.click(gateway); + await user.paste("https://gateway.example.test/"); + await user.click(hosts); + await user.paste("proxy.example.test\napi.private.test"); + + const saveProfile = within(dialog).getByRole("button", { name: "Save Profile" }); + expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument(); + expect(saveProfile).toBeDisabled(); + await user.click(within(dialog).getByRole("button", { name: "Pause" })); + await waitFor(() => expect(saveProfile).toBeEnabled()); + await user.click(saveProfile); + + expect(invoke).toHaveBeenCalledWith("set_capture_active", { active: false }); + expect(invoke).toHaveBeenCalledWith("update_capture_profile", { + profile: { + version: "0.1", + name: "Private lab", + gatewayUpstream: "https://gateway.example.test/", + additionalHosts: ["api.private.test", "proxy.example.test"], + }, + }); + expect(await within(dialog).findByText("Profile saved")).toBeInTheDocument(); + expect(gateway).toHaveValue("https://gateway.example.test"); + }); + it("returns an active proxy workspace to the safe Gateway from settings", async () => { const user = userEvent.setup(); window.__TAURI_INTERNALS__ = {}; diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 812fc9c..93e8fee 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -34,6 +34,7 @@ import { requestSearchText } from "./compare"; import type { AnatomySection, CaptureMode, + CaptureProfile, CapturedRequest, CertificateAuthority, EvidenceLocator, @@ -55,6 +56,7 @@ import { setCaptureMode as persistCaptureMode, subscribeToCaptureEvents, uninstallCertificateAuthorityTrust, + updateCaptureProfile as persistCaptureProfile, } from "./workspace"; import { formatRawJson, resolveEvidenceLocator, resolveEvidencePointer } from "./raw-evidence"; import type { ResolvedRawEvidence } from "./raw-evidence"; @@ -338,6 +340,15 @@ export function App() { .finally(() => setCertificateChanging(false)); }; + const changeCaptureProfile = async (profile: CaptureProfile) => { + const nextWorkspace = await persistCaptureProfile(profile); + setWorkspace(nextWorkspace); + setCaptureActive(nextWorkspace.capture.active); + setCaptureMode(nextWorkspace.capture.mode); + setCaptureError(""); + return nextWorkspace.captureProfile; + }; + if (loadError) { return setReloadToken((value) => value + 1)} />; } @@ -440,6 +451,7 @@ export function App() { certificateChanging={certificateChanging} onToggleCapture={toggleCapture} onModeChange={changeCaptureMode} + onCaptureProfileChange={changeCaptureProfile} onCertificateTrustChange={changeCertificateTrust} onClose={closeSettings} />} diff --git a/apps/desktop/src/SettingsDialog.tsx b/apps/desktop/src/SettingsDialog.tsx index cc519da..e2f173e 100644 --- a/apps/desktop/src/SettingsDialog.tsx +++ b/apps/desktop/src/SettingsDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { AlertTriangle, BarChart3, @@ -11,7 +11,9 @@ import { Play, RotateCcw, RefreshCw, + Save, ShieldCheck, + SlidersHorizontal, Stethoscope, UploadCloud, X, @@ -19,6 +21,7 @@ import { import type { BetaMetricsPreview, CaptureMode, + CaptureProfile, CertificateAuthority, SupportBundlePreview, UpdateStatus, @@ -36,9 +39,9 @@ import { } from "./workspace"; import { handleTabListKeyDown, useModalDialog } from "./accessibility"; -type SettingsTab = "connection" | "metrics" | "diagnostics" | "updates"; +type SettingsTab = "connection" | "profiles" | "metrics" | "diagnostics" | "updates"; -export function SettingsDialog({ workspace, active, runtimeError, certificateError, modeChanging, certificateChanging, onToggleCapture, onModeChange, onCertificateTrustChange, onClose }: { +export function SettingsDialog({ workspace, active, runtimeError, certificateError, modeChanging, certificateChanging, onToggleCapture, onModeChange, onCaptureProfileChange, onCertificateTrustChange, onClose }: { workspace: WorkspaceBootstrap; active: boolean; runtimeError: string; @@ -47,6 +50,7 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr certificateChanging: boolean; onToggleCapture: () => void; onModeChange: (mode: CaptureMode) => void; + onCaptureProfileChange: (profile: CaptureProfile) => Promise; onCertificateTrustChange: (trusted: boolean) => void; onClose: () => void; }) { @@ -67,6 +71,12 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr const [updateChecking, setUpdateChecking] = useState(false); const [updateInstalling, setUpdateInstalling] = useState(false); const [updateError, setUpdateError] = useState(""); + const [profileName, setProfileName] = useState(workspace.captureProfile.name); + const [profileGateway, setProfileGateway] = useState(workspace.captureProfile.gatewayUpstream); + const [profileHosts, setProfileHosts] = useState(workspace.captureProfile.additionalHosts.join("\n")); + const [profileSaving, setProfileSaving] = useState(false); + const [profileError, setProfileError] = useState(""); + const [profileSaved, setProfileSaved] = useState(false); const { dialogRef, initialFocusRef } = useModalDialog(onClose); const certificate = workspace.capture.certificateAuthority; const compatibility = workspace.compatibility; @@ -75,6 +85,22 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr && certificate.state === "ready" && certificate.trust === "not_trusted"; const canRemoveTrust = certificate.canManageTrust && certificate.trust === "trusted"; + const profileDraft = captureProfileDraft( + workspace.captureProfile.version, + profileName, + profileGateway, + profileHosts, + ); + const profileValidationError = validateCaptureProfileDraft(profileDraft); + const profileDirty = !captureProfilesEqual(profileDraft, workspace.captureProfile); + const profileReady = !recoveryMode + && workspace.capture.canControl + && workspace.capture.mode === "gateway" + && !active; + const canSaveProfile = profileReady + && profileDirty + && !profileValidationError + && !profileSaving; useEffect(() => { if (tab !== "metrics") return; @@ -211,6 +237,35 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr }); }; + const restoreDefaultProfile = () => { + setProfileName("OpenAI default"); + setProfileGateway("https://api.openai.com"); + setProfileHosts(""); + setProfileError(""); + setProfileSaved(false); + }; + + const saveProfile = (event: FormEvent) => { + event.preventDefault(); + if (!canSaveProfile) return; + setProfileSaving(true); + setProfileError(""); + setProfileSaved(false); + onCaptureProfileChange(profileDraft) + .then((profile) => { + setProfileName(profile.name); + setProfileGateway(profile.gatewayUpstream); + setProfileHosts(profile.additionalHosts.join("\n")); + setProfileSaved(true); + }) + .catch((reason: unknown) => { + setProfileError( + reason instanceof Error ? reason.message : "Capture Profile could not be saved.", + ); + }) + .finally(() => setProfileSaving(false)); + }; + return (
@@ -220,6 +275,7 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr
+ @@ -268,6 +324,42 @@ export function SettingsDialog({ workspace, active, runtimeError, certificateErr
} + {tab === "profiles" &&
+ {!profileReady &&
+
+ Runtime lock + {recoveryMode ? "Read-only recovery" : workspace.capture.mode === "proxy" ? "Return to Gateway" : active ? "Pause capture" : "Profile changes unavailable"} + The active runtime must be a paused local Gateway before its capture boundary changes. +
+ {!recoveryMode && workspace.capture.mode === "proxy" && } + {!recoveryMode && workspace.capture.mode === "gateway" && active && } +
} +
+
+ + +