diff --git a/apps/yaak-client/components/Workspace.tsx b/apps/yaak-client/components/Workspace.tsx index 1e6d22cef..9728244b2 100644 --- a/apps/yaak-client/components/Workspace.tsx +++ b/apps/yaak-client/components/Workspace.tsx @@ -1,10 +1,10 @@ import { platform } from "@yaakapp-internal/platform"; import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models"; -import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui"; +import { Banner, HeaderSize, HeaderTint, HStack, SidebarLayout } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import * as m from "motion/react-m"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { getActiveCookieJar, useEnsureActiveCookieJar, @@ -61,12 +61,6 @@ export function Workspace() { const activeEnvironment = useAtomValue(activeEnvironmentAtom); const [floating, setFloating] = useState(false); - const environmentBgStyle = useMemo(() => { - if (activeEnvironment?.color == null) return undefined; - const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`; - return { background }; - }, [activeEnvironment?.color]); - if (workspaces.length === 0) { return ; } @@ -81,13 +75,7 @@ export function Workspace() { useNativeTitlebar={settings.useNativeTitlebar} interfaceScale={settings.interfaceScale} > -
-
-
-
+ ); diff --git a/apps/yaak-proxy/components/ProxyLayout.tsx b/apps/yaak-proxy/components/ProxyLayout.tsx index 0cd38b228..0d11364d5 100644 --- a/apps/yaak-proxy/components/ProxyLayout.tsx +++ b/apps/yaak-proxy/components/ProxyLayout.tsx @@ -1,5 +1,6 @@ import { HeaderSize, + HeaderTint, IconButton, SidebarLayout, SplitLayout, @@ -13,7 +14,8 @@ import { useLocalStorage } from "react-use"; import { flowLayoutAtom } from "../lib/atoms"; import { setupIncomplete } from "../lib/firstRun"; import { useMenuExportHar } from "../lib/flowActions"; -import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store"; +import { proxyStatusOf, statusTintColor } from "../lib/proxyStatus"; +import { lockedAtom, noFlowsAtom, proxyStateAtom, systemProxyEverOnAtom } from "../lib/store"; import { getOsType } from "../lib/tauri"; import { AddColumnDialog } from "./AddColumnDialog"; import { ClearFlowsButton } from "./ClearFlowsButton"; @@ -43,6 +45,7 @@ export function ProxyLayout() { ); const [floating, setFloating] = useState(false); const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false); + const tint = statusTintColor(proxyStatusOf(useAtomValue(proxyStateAtom))); return (
+ {/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and the environment, the search where it names the request, then its icons. Only the controls take the pointer: everything around them, the traffic lights' corner diff --git a/apps/yaak-proxy/components/ProxyStatus.tsx b/apps/yaak-proxy/components/ProxyStatus.tsx index 38e80b77c..31f823f0c 100644 --- a/apps/yaak-proxy/components/ProxyStatus.tsx +++ b/apps/yaak-proxy/components/ProxyStatus.tsx @@ -19,6 +19,7 @@ import { useRpcQuery } from "../hooks/useRpcQuery"; import { helperRegisteredAtAtom } from "../lib/atoms"; import { encryptingPercent } from "../lib/encrypting"; import { environmentExports } from "../lib/env"; +import { proxyStatusOf } from "../lib/proxyStatus"; import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store"; import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy"; import { onWindowFocus } from "../lib/tauri"; @@ -44,25 +45,26 @@ export function ProxyStatus() { const other = proxyState?.otherOwner ?? null; const port = proxyState?.port ?? other?.proxyPort ?? null; const untrusted = caStatus.data != null && !caStatus.data.trusted; - // Traffic stored before encryption is being encrypted; the engine starts once it is. - const encrypting = !running && other == null ? encryptingPercent(proxyState) : null; + // The same status the header's tint takes its colour from + const status = proxyStatusOf(proxyState); + const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null; const stateRow = - encrypting != null ? ( + status === "encrypting" ? ( } title={`Encrypting stored traffic, ${encrypting}`} description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done." /> - ) : other != null ? ( + ) : status === "cli" && other != null ? ( } title="Running from the CLI" description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`} /> - ) : running ? ( + ) : status === "recording" || status === "paused" ? ( } + icon={} title={recording ? `Recording on port ${port}` : `Paused on port ${port}`} description={ recording @@ -190,20 +192,14 @@ export function ProxyStatus() { return ( <> - - {encrypting != null ? ( + + {status === "encrypting" ? ( <> Encrypting {encrypting} - ) : other != null ? ( + ) : status === "cli" && other != null ? ( <> CLI @@ -211,9 +207,9 @@ export function ProxyStatus() { :{other.proxyPort} )} - ) : running ? ( + ) : status === "recording" || status === "paused" ? ( <> - + {recording ? "Recording" : "Paused"} {port != null && :{port}} diff --git a/apps/yaak-proxy/harness/backend.ts b/apps/yaak-proxy/harness/backend.ts index e10e37b1f..4af10973f 100644 --- a/apps/yaak-proxy/harness/backend.ts +++ b/apps/yaak-proxy/harness/backend.ts @@ -41,6 +41,31 @@ export const HARNESS_STATE: GetProxyStateResponse = { 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; + } +} + function answer(cmd: string, payload: unknown): unknown { const req = (payload ?? {}) as Record; switch (cmd) { @@ -62,7 +87,7 @@ function answer(cmd: string, payload: unknown): unknown { case "app_icon": return appIconPng; case "get_proxy_state": - return HARNESS_STATE; + return harnessState(new URLSearchParams(location.search).get("status")); case "get_storage_size": return { bytes: 412 * 1024 * 1024 }; case "ca_status": diff --git a/apps/yaak-proxy/lib/proxyStatus.test.ts b/apps/yaak-proxy/lib/proxyStatus.test.ts new file mode 100644 index 000000000..a5357cf0b --- /dev/null +++ b/apps/yaak-proxy/lib/proxyStatus.test.ts @@ -0,0 +1,70 @@ +import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib"; +import { describe, expect, test } from "vite-plus/test"; +import { proxyStatusOf, statusTintColor } from "./proxyStatus"; + +const base: GetProxyStateResponse = { + state: "running", + port: 9090, + configuredPort: 9090, + recording: true, + systemProxyAtLaunch: true, + httpVersion: "auto", + captureBodyLimit: 10 * 1024 * 1024, + traffic: "keep", + keepTrafficBytes: null, + startError: null, + encrypting: null, + locked: null, + otherOwner: null, +}; + +const tint = (patch: Partial | null) => + statusTintColor(proxyStatusOf(patch == null ? null : { ...base, ...patch })); + +const cli: GetProxyStateResponse["otherOwner"] = { + owner: "cli", + pid: 42, + startedAt: "2026-11-10T10:00:00Z", + proxyPort: 9091, +}; + +describe("proxyStatusOf and statusTintColor", () => { + test("recording takes the success colour", () => { + expect(proxyStatusOf(base)).toBe("recording"); + expect(tint({})).toBe("var(--success)"); + }); + + test("paused takes the warning colour", () => { + expect(proxyStatusOf({ ...base, recording: false })).toBe("paused"); + expect(tint({ recording: false })).toBe("var(--warning)"); + }); + + test("not running takes the danger colour", () => { + expect(proxyStatusOf({ ...base, state: "stopped", port: null })).toBe("stopped"); + expect(tint({ state: "stopped", port: null })).toBe("var(--danger)"); + }); + + test("failing to start takes the danger colour", () => { + expect(tint({ state: "stopped", port: null, startError: "Port 9090 is in use" })).toBe( + "var(--danger)", + ); + }); + + test("locked traffic takes the danger colour", () => { + const locked = { message: "Traffic is locked", keychain: true } as NonNullable< + GetProxyStateResponse["locked"] + >; + expect(tint({ state: "stopped", port: null, locked })).toBe("var(--danger)"); + }); + + test("an engine the CLI owns takes the info colour", () => { + expect(proxyStatusOf({ ...base, state: "stopped", port: null, otherOwner: cli })).toBe("cli"); + expect(tint({ state: "stopped", port: null, otherOwner: cli })).toBe("var(--info)"); + }); + + test("no tint while encrypting or before the state is read", () => { + expect(proxyStatusOf({ ...base, state: "stopped", encrypting: 0.4 })).toBe("encrypting"); + expect(tint({ state: "stopped", encrypting: 0.4 })).toBeNull(); + expect(tint(null)).toBeNull(); + }); +}); diff --git a/apps/yaak-proxy/lib/proxyStatus.ts b/apps/yaak-proxy/lib/proxyStatus.ts new file mode 100644 index 000000000..cbeb8f188 --- /dev/null +++ b/apps/yaak-proxy/lib/proxyStatus.ts @@ -0,0 +1,37 @@ +import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib"; + +/** + * What the engine is doing, as the header shows it: the pill's dot and the header's tint both + * read this, so they can't disagree. + */ +export type ProxyStatusKind = "recording" | "paused" | "cli" | "stopped" | "encrypting"; + +/** The status of a proxy state, or null while it hasn't been read yet */ +export function proxyStatusOf( + state: GetProxyStateResponse | null | undefined, +): ProxyStatusKind | null { + if (state == null) return null; + // Traffic stored before encryption is being encrypted; the engine starts once it is. + if (state.state !== "running" && state.otherOwner == null && state.encrypting != null) { + return "encrypting"; + } + if (state.otherOwner != null) return "cli"; + if (state.locked != null || state.state !== "running") return "stopped"; + return state.recording === false ? "paused" : "recording"; +} + +/** The header's tint for a status, from the theme's colours; null for none */ +export function statusTintColor(status: ProxyStatusKind | null): string | null { + switch (status) { + case "recording": + return "var(--success)"; + case "paused": + return "var(--warning)"; + case "stopped": + return "var(--danger)"; + case "cli": + return "var(--info)"; + default: + return null; + } +} diff --git a/packages/ui/src/components/HeaderTint.tsx b/packages/ui/src/components/HeaderTint.tsx new file mode 100644 index 000000000..a05d3aa2b --- /dev/null +++ b/packages/ui/src/components/HeaderTint.tsx @@ -0,0 +1,40 @@ +import classNames from "classnames"; +import type { CSSProperties } from "react"; + +interface HeaderTintProps { + /** Any CSS colour, or null for no tint */ + color: string | null; + /** Fade between colours when the colour changes */ + animate?: boolean; +} + +// Drawn in currentColor so a change of colour can fade: a gradient itself can't transition. +const GRADIENT: CSSProperties = { + background: "linear-gradient(to right, currentColor 15%, transparent 40%)", +}; + +/** + * A colour wash from a header's left edge, with a line of it along the bottom border, placed + * inside a `HeaderSize`. It takes no pointer, so clicks and window drags reach the header. + */ +export function HeaderTint({ color, animate }: HeaderTintProps) { + return ( +
+
+
+
+ ); +} + +/** An unreadable colour draws nothing, rather than falling back to the inherited text colour */ +function isColor(color: string | null): color is string { + if (color == null || color === "") return false; + return typeof CSS === "undefined" || CSS.supports("color", color); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 7c46383fc..379db40c1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -136,6 +136,7 @@ export type { FormattableEditor } from "./components/FormatAction"; export { FormatAction, formatEditor } from "./components/FormatAction"; export { FormattedError } from "./components/FormattedError"; export { HeaderSize } from "./components/HeaderSize"; +export { HeaderTint } from "./components/HeaderTint"; export type { HeadersTableProps } from "./components/HeadersTable"; export { HeadersTable } from "./components/HeadersTable"; export { Heading } from "./components/Heading";