mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Tint the proxy's header by its status, sharing Yaak's environment tint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
29a173a07e
commit
bba92c5a23
8 files changed
+195
-34
No files matched your search
@@ -1,10 +1,10 @@
|
|||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
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 classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import * as m from "motion/react-m";
|
import * as m from "motion/react-m";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
getActiveCookieJar,
|
getActiveCookieJar,
|
||||||
useEnsureActiveCookieJar,
|
useEnsureActiveCookieJar,
|
||||||
@@ -61,12 +61,6 @@ export function Workspace() {
|
|||||||
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
|
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
|
||||||
const [floating, setFloating] = useState(false);
|
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) {
|
if (workspaces.length === 0) {
|
||||||
return <RedirectToLatestWorkspace />;
|
return <RedirectToLatestWorkspace />;
|
||||||
}
|
}
|
||||||
@@ -81,13 +75,7 @@ export function Workspace() {
|
|||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
useNativeTitlebar={settings.useNativeTitlebar}
|
||||||
interfaceScale={settings.interfaceScale}
|
interfaceScale={settings.interfaceScale}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<HeaderTint color={activeEnvironment?.color ?? null} />
|
||||||
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
|
|
||||||
<div
|
|
||||||
style={environmentBgStyle}
|
|
||||||
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
|
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
|
||||||
</HeaderSize>
|
</HeaderSize>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
HeaderSize,
|
HeaderSize,
|
||||||
|
HeaderTint,
|
||||||
IconButton,
|
IconButton,
|
||||||
SidebarLayout,
|
SidebarLayout,
|
||||||
SplitLayout,
|
SplitLayout,
|
||||||
@@ -13,7 +14,8 @@ import { useLocalStorage } from "react-use";
|
|||||||
import { flowLayoutAtom } from "../lib/atoms";
|
import { flowLayoutAtom } from "../lib/atoms";
|
||||||
import { setupIncomplete } from "../lib/firstRun";
|
import { setupIncomplete } from "../lib/firstRun";
|
||||||
import { useMenuExportHar } from "../lib/flowActions";
|
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 { getOsType } from "../lib/tauri";
|
||||||
import { AddColumnDialog } from "./AddColumnDialog";
|
import { AddColumnDialog } from "./AddColumnDialog";
|
||||||
import { ClearFlowsButton } from "./ClearFlowsButton";
|
import { ClearFlowsButton } from "./ClearFlowsButton";
|
||||||
@@ -43,6 +45,7 @@ export function ProxyLayout() {
|
|||||||
);
|
);
|
||||||
const [floating, setFloating] = useState(false);
|
const [floating, setFloating] = useState(false);
|
||||||
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
|
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
|
||||||
|
const tint = statusTintColor(proxyStatusOf(useAtomValue(proxyStateAtom)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -60,6 +63,7 @@ export function ProxyLayout() {
|
|||||||
interfaceScale={1}
|
interfaceScale={1}
|
||||||
className="x-theme-appHeader bg-surface"
|
className="x-theme-appHeader bg-surface"
|
||||||
>
|
>
|
||||||
|
<HeaderTint color={tint} animate />
|
||||||
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
|
{/* 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
|
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
|
controls take the pointer: everything around them, the traffic lights' corner
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useRpcQuery } from "../hooks/useRpcQuery";
|
|||||||
import { helperRegisteredAtAtom } from "../lib/atoms";
|
import { helperRegisteredAtAtom } from "../lib/atoms";
|
||||||
import { encryptingPercent } from "../lib/encrypting";
|
import { encryptingPercent } from "../lib/encrypting";
|
||||||
import { environmentExports } from "../lib/env";
|
import { environmentExports } from "../lib/env";
|
||||||
|
import { proxyStatusOf } from "../lib/proxyStatus";
|
||||||
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||||
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
|
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
|
||||||
import { onWindowFocus } from "../lib/tauri";
|
import { onWindowFocus } from "../lib/tauri";
|
||||||
@@ -44,25 +45,26 @@ export function ProxyStatus() {
|
|||||||
const other = proxyState?.otherOwner ?? null;
|
const other = proxyState?.otherOwner ?? null;
|
||||||
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
||||||
const untrusted = caStatus.data != null && !caStatus.data.trusted;
|
const untrusted = caStatus.data != null && !caStatus.data.trusted;
|
||||||
// Traffic stored before encryption is being encrypted; the engine starts once it is.
|
// The same status the header's tint takes its colour from
|
||||||
const encrypting = !running && other == null ? encryptingPercent(proxyState) : null;
|
const status = proxyStatusOf(proxyState);
|
||||||
|
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
|
||||||
|
|
||||||
const stateRow =
|
const stateRow =
|
||||||
encrypting != null ? (
|
status === "encrypting" ? (
|
||||||
<MenuRow
|
<MenuRow
|
||||||
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
|
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
|
||||||
title={`Encrypting stored traffic, ${encrypting}`}
|
title={`Encrypting stored traffic, ${encrypting}`}
|
||||||
description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
|
description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
|
||||||
/>
|
/>
|
||||||
) : other != null ? (
|
) : status === "cli" && other != null ? (
|
||||||
<MenuRow
|
<MenuRow
|
||||||
icon={<StateDot state="cli" />}
|
icon={<StateDot state="cli" />}
|
||||||
title="Running from the CLI"
|
title="Running from the CLI"
|
||||||
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
|
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
|
||||||
/>
|
/>
|
||||||
) : running ? (
|
) : status === "recording" || status === "paused" ? (
|
||||||
<MenuRow
|
<MenuRow
|
||||||
icon={<StateDot state={recording ? "recording" : "paused"} />}
|
icon={<StateDot state={status} />}
|
||||||
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
|
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
|
||||||
description={
|
description={
|
||||||
recording
|
recording
|
||||||
@@ -190,20 +192,14 @@ export function ProxyStatus() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dropdown ref={dropdownRef} items={items}>
|
<Dropdown ref={dropdownRef} items={items}>
|
||||||
<Pill
|
<Pill color={status === "stopped" ? "warning" : undefined}>
|
||||||
color={
|
{status === "encrypting" ? (
|
||||||
!running && other == null && encrypting == null && proxyState != null
|
|
||||||
? "warning"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{encrypting != null ? (
|
|
||||||
<>
|
<>
|
||||||
<LoadingIcon size="xs" />
|
<LoadingIcon size="xs" />
|
||||||
<span className="text-text">Encrypting</span>
|
<span className="text-text">Encrypting</span>
|
||||||
<span className="font-mono text-text-subtle">{encrypting}</span>
|
<span className="font-mono text-text-subtle">{encrypting}</span>
|
||||||
</>
|
</>
|
||||||
) : other != null ? (
|
) : status === "cli" && other != null ? (
|
||||||
<>
|
<>
|
||||||
<StateDot state="cli" />
|
<StateDot state="cli" />
|
||||||
<span className="text-text">CLI</span>
|
<span className="text-text">CLI</span>
|
||||||
@@ -211,9 +207,9 @@ export function ProxyStatus() {
|
|||||||
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
|
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : running ? (
|
) : status === "recording" || status === "paused" ? (
|
||||||
<>
|
<>
|
||||||
<StateDot state={recording ? "recording" : "paused"} />
|
<StateDot state={status} />
|
||||||
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
|
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
|
||||||
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
|
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -41,6 +41,31 @@ export const HARNESS_STATE: GetProxyStateResponse = {
|
|||||||
otherOwner: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function answer(cmd: string, payload: unknown): unknown {
|
function answer(cmd: string, payload: unknown): unknown {
|
||||||
const req = (payload ?? {}) as Record<string, unknown>;
|
const req = (payload ?? {}) as Record<string, unknown>;
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
@@ -62,7 +87,7 @@ function answer(cmd: string, payload: unknown): unknown {
|
|||||||
case "app_icon":
|
case "app_icon":
|
||||||
return appIconPng;
|
return appIconPng;
|
||||||
case "get_proxy_state":
|
case "get_proxy_state":
|
||||||
return HARNESS_STATE;
|
return harnessState(new URLSearchParams(location.search).get("status"));
|
||||||
case "get_storage_size":
|
case "get_storage_size":
|
||||||
return { bytes: 412 * 1024 * 1024 };
|
return { bytes: 412 * 1024 * 1024 };
|
||||||
case "ca_status":
|
case "ca_status":
|
||||||
|
|||||||
@@ -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<GetProxyStateResponse> | 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{ color: isColor(color) ? color : "transparent" }}
|
||||||
|
className={classNames(
|
||||||
|
"absolute inset-0 pointer-events-none",
|
||||||
|
animate && "transition-[color] duration-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div style={GRADIENT} className="absolute inset-0 opacity-[0.07]" />
|
||||||
|
<div style={GRADIENT} className="absolute left-0 right-0 -bottom-px h-px opacity-20" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
@@ -136,6 +136,7 @@ export type { FormattableEditor } from "./components/FormatAction";
|
|||||||
export { FormatAction, formatEditor } from "./components/FormatAction";
|
export { FormatAction, formatEditor } from "./components/FormatAction";
|
||||||
export { FormattedError } from "./components/FormattedError";
|
export { FormattedError } from "./components/FormattedError";
|
||||||
export { HeaderSize } from "./components/HeaderSize";
|
export { HeaderSize } from "./components/HeaderSize";
|
||||||
|
export { HeaderTint } from "./components/HeaderTint";
|
||||||
export type { HeadersTableProps } from "./components/HeadersTable";
|
export type { HeadersTableProps } from "./components/HeadersTable";
|
||||||
export { HeadersTable } from "./components/HeadersTable";
|
export { HeadersTable } from "./components/HeadersTable";
|
||||||
export { Heading } from "./components/Heading";
|
export { Heading } from "./components/Heading";
|
||||||
|
|||||||
Reference in new issue
Block a user