Compare commits

...
19 Commits
Author SHA1 Message Date
Gregory SchierandClaude Fable 5.2 6a34d0a46d Yaak Proxy: the rejected card claims decryption only when the list decrypts
Once an address is no longer passed through, the foot said the next
connection will be decrypted even when the list excludes it or the
certificate is untrusted. It now says so only when the list still
decrypts the connection, and keeps the trust line while untrusted.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 08:47:57 -07:00
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
Gregory Schier 1f0541f379 Proxy prose pass 2026-11-11 08:13:49 -07:00
Gregory Schier f0e767d77e Proxy prose pass 2026-11-11 08:13:49 -07:00
Gregory Schier 075fcaf2c4 Proxy prose pass 2026-11-11 08:13:49 -07:00
Gregory SchierandClaude Opus 5.5 6e39955f17 Yaak Proxy: release build, dry run and updater (#836)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:45:25 -07:00
Gregory Schier 9cc9f7a5b8 Fix font size 2026-11-10 22:32:37 -07:00
Gregory SchierandClaude Opus 5.5 c0f75bcfc2 Yaak Proxy: square app icons beside long names, and the final uncovered why line
App icons keep their square however a long app name squeezes them, in the
card's buttons, the strip's Choose menu and the sidebar, and a long entry
hint truncates instead of the button's label. A connection nothing on the
list covers reads "Encrypted Content: Enable decryption to see requests
to <host>", with the host as code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 e3ff29ee6d Yaak Proxy: mute encrypted and refused rows by colour only
No opacity on the app icon or the port; the host, number, size and time
take the subtle text colour, the faintest that keeps 4.5:1 on the row in
both default themes, and the full text colour while selected. The lock
takes the subtle colour too, and refused rows follow the same rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 f2555bdcd5 Yaak Proxy: code-styled names and an info button in the encrypted card's why line
Hosts, apps and entries named in the why and opened-before lines render
as InlineCode, an info button beside the why line explains decryption in
a tooltip, and the "New connections only" line is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 ae666adfb7 Yaak Proxy: name the host when decryption isn't configured for it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 f5ff9cb63e Yaak Proxy: fix review findings in the decryption UI
Count Reconnect's connections within its own scope, refresh the
pass-through set from Try Again's answer, report a failed trust, keep
the pill's switch where the app can't trust the certificate, and keep a
failed tunnel's error across Type and Size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 b0425069f4 Yaak Proxy: HTTPS decryption UI, and Info and Body in the card's tabs
The strip, quiet encrypted rows, warning rows for refused handshakes, the
encrypted card with named actions, trust at the first Decrypt, the toast
with Undo and Reconnect, the pill's Decrypt everything switch, Settings'
Decryption section, the Encrypted sidebar row, and two-step setup. Both
body tabs read Body with a type badge; Info leads the request half with
the timeline folded in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:30:40 -07:00
Gregory SchierandClaude Opus 5.5 5148b0f0ba Yaak Proxy: open Settings as a child of the main window
Settings now opens through the shared create_child_window, as Yaak's
does, so it stays in front of the main window, takes focus back when the
main window is clicked, and closes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:34:22 -07:00
Gregory SchierandClaude Opus 5.5 7057a902c7 Yaak Proxy: scale resized numeric column widths with the interface size too
Every numeric width is kept in pixels at the default interface size, so
the least width matches what renders at any size and a resize follows
the pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:13:16 -07:00
Gregory SchierandClaude Opus 5.5 82ac6e5c15 Yaak Proxy: keep numeric column values from truncating
Size, Request Size, Time and Latency are never narrower than their
widest value (999.9 KB, 999m 59s) at any interface size, and their
default widths grow with it. Stored widths under the new least width
are raised when read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:13:16 -07:00
Gregory SchierandClaude Opus 5.5 17b1623e83 Settings: one shared page layout with a fixed gap under the header
Every settings tab in Yaak and Yaak Proxy now renders its heading, description,
optional banner and content through SettingsPage, which puts the same 1.5rem gap
between each. The banner slot hides itself when nothing inside it has content,
so Yaak's General tab no longer gets an accidental gap from the empty
commercial-use banner, and the proxy's General tab no longer sits tight under
its description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:06:36 -07:00
Gregory SchierandClaude Sonnet 5.5 dc09688bc9 Remove moved-actions banner from response history dropdown
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 15:14:18 -07:00
Gregory SchierandClaude Opus 5.5 f02dce21e7 Yaak Proxy: record why HTTPS is encrypted, refused handshakes, and reconnect on request (#835)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:49:12 -07:00
159 changed files with 9592 additions and 1872 deletions

No files matched your search

+190 -1
View File
@@ -2,10 +2,24 @@ name: Release App Artifacts
on:
push:
tags: [v*]
# The Yaak Proxy dry run only: builds, signs and notarizes the proxy for the chosen ref and
# keeps the results as workflow artifacts. It never builds Yaak, releases, tags or publishes.
workflow_dispatch:
inputs:
proxy_dry_run:
description: "Dry-run the Yaak Proxy build (artifacts only; nothing is released)"
type: boolean
required: true
default: true
proxy_version:
description: "Version to stamp into the dry-run build"
type: string
required: true
default: "0.0.1-dryrun"
jobs:
build-artifacts:
if: github.repository == 'mountain-loop/yaak'
if: github.repository == 'mountain-loop/yaak' && github.event_name == 'push'
permissions:
contents: write
@@ -290,3 +304,178 @@ jobs:
path: ${{ runner.temp }}/yaak-release-artifacts/
retention-days: 7
if-no-files-found: warn
# Yaak Proxy, macOS only, on the same tag and version as Yaak. Its own job, sharing nothing
# with Yaak's, so a proxy failure never stops Yaak's artifacts; it fails the run, visibly.
#
# On a v* tag it runs only once the repository variable RELEASE_PROXY is "true": the website's
# download links pick Yaak's assets by suffix and architecture, and its release audit expects
# Yaak's exact asset set and this run's success, so the proxy must not join Yaak's release
# until the website expects it there. The dry run (workflow_dispatch) runs regardless, and
# only keeps workflow artifacts.
build-proxy:
if: >-
github.repository == 'mountain-loop/yaak' && (
(github.event_name == 'push' && vars.RELEASE_PROXY == 'true') ||
(github.event_name == 'workflow_dispatch' && inputs.proxy_dry_run)
)
permissions:
contents: write
name: Build Yaak Proxy
strategy:
fail-fast: false
matrix:
include:
- target: "aarch64-apple-darwin" # for Arm-based Macs (M1 and above).
yaak_arch: "arm64"
lipo_arch: "arm64"
asset_arch: "aarch64"
- target: "x86_64-apple-darwin" # for Intel-based Macs.
yaak_arch: "x64"
lipo_arch: "x86_64"
asset_arch: "x64"
runs-on: macos-latest
timeout-minutes: 240
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
# The tag, as Yaak's version is taken, or the dry run's own
PROXY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.proxy_version || github.ref_name }}
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# A cache of its own, so the proxy's builds never change what Yaak's jobs restore
- uses: Swatinem/rust-cache@v2
with:
shared-key: release-proxy
key: ${{ matrix.target }}
cache-on-failure: true
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: vp install
- name: Set version
run: npm run replace-version -- proxy
env:
YAAK_VERSION: ${{ env.PROXY_VERSION }}
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
rm "$RUNNER_TEMP/certificate.p12"
# Lets codesign use the key unattended; without it the first signature can wait on a
# prompt nobody answers
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null
security list-keychain -d user -s "$KEYCHAIN_PATH"
# Builds the helper for the target and signs it and protoc before bundling (the bundle
# config's before-build step), then signs, notarizes and staples the app and makes the
# updater archive and its signature.
- name: Build, sign and notarize
run: >-
npm run proxy:bundle --
--target ${{ matrix.target }}
--bundles dmg
--config crates-tauri/yaak-app-proxy/tauri.release.conf.json
env:
CI: true
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The helper only answers an app signed by this team
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Verify the bundle
run: >-
node scripts/verify-proxy-bundle.mjs
"target/${{ matrix.target }}/release/bundle/macos/Yaak Proxy.app"
--signed
--version "$PROXY_VERSION"
--arch ${{ matrix.lipo_arch }}
--team "$YAAK_PROXY_TEAM_ID"
env:
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Named Yaak.Proxy_*, so neither people nor tools take them for Yaak's Yaak_* assets.
# The app refuses an update not named so (ARTIFACT_PREFIX in yaak-app-proxy's updates.rs).
- name: Collect the release assets
run: |
set -euo pipefail
bundle="target/${{ matrix.target }}/release/bundle"
version="${PROXY_VERSION#v}"
out="$RUNNER_TEMP/yaak-proxy-release"
mkdir -p "$out"
dmgs=("$bundle"/dmg/*.dmg)
if [ "${#dmgs[@]}" -ne 1 ] || [ ! -f "${dmgs[0]}" ]; then
echo "Expected one .dmg in $bundle/dmg"; exit 1
fi
cp "${dmgs[0]}" "$out/Yaak.Proxy_${version}_${{ matrix.asset_arch }}.dmg"
cp "$bundle/macos/Yaak Proxy.app.tar.gz" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz"
cp "$bundle/macos/Yaak Proxy.app.tar.gz.sig" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz.sig"
(cd "$out" && shasum -a 256 Yaak.Proxy_* > checksums.txt)
ls -l "$out"
- name: Keep the bundles as workflow artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: proxy-${{ env.PROXY_VERSION }}-${{ matrix.asset_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/yaak-proxy-release/
retention-days: ${{ env.DRY_RUN == 'true' && 14 || 7 }}
if-no-files-found: error
# Yaak's jobs create the draft release; this waits for it rather than making its own,
# which could race theirs into two drafts.
- name: Upload to the release
if: env.DRY_RUN != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
for attempt in $(seq 1 120); do
if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then break; fi
if [ "$attempt" -eq 120 ]; then
echo "No release for $GITHUB_REF_NAME after two hours; Yaak's jobs make it"; exit 1
fi
sleep 60
done
for asset in "$RUNNER_TEMP"/yaak-proxy-release/Yaak.Proxy_*; do
for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$asset" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
done
Generated
+23
View File
@@ -11507,6 +11507,7 @@ dependencies = [
"yaak-tauri-utils",
"yaak-templates",
"yaak-tls",
"yaak-updater",
"yaak-window",
"yaak-ws",
]
@@ -11522,9 +11523,12 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-updater",
"tokio",
"yaak-mac-window",
"yaak-proxy-lib",
"yaak-rpc",
"yaak-updater",
"yaak-window",
]
@@ -11948,12 +11952,14 @@ dependencies = [
"r2d2_sqlite",
"reqwest",
"rusqlite",
"rustls",
"sea-query",
"serde",
"serde_json",
"system-configuration",
"tempfile",
"tokio",
"tokio-rustls",
"tonic",
"tonic-reflection",
"ts-rs",
@@ -12091,6 +12097,23 @@ dependencies = [
"yasna 0.6.0",
]
[[package]]
name = "yaak-updater"
version = "0.1.0"
dependencies = [
"log 0.4.34",
"md5",
"nanoid",
"serde",
"serde_json",
"tauri",
"tauri-plugin-dialog",
"tauri-plugin-updater",
"thiserror 2.0.21",
"tokio",
"ts-rs",
]
[[package]]
name = "yaak-wasm"
version = "0.1.0"
+2
View File
@@ -44,6 +44,7 @@ members = [
"crates-tauri/yaak-proxy-helper",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-updater",
"crates-tauri/yaak-window",
]
@@ -116,6 +117,7 @@ yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-updater = { path = "crates-tauri/yaak-updater" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
@@ -9,8 +9,6 @@ import {
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
@@ -27,12 +25,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId,
}: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a";
const responseHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
@@ -123,20 +115,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0,
},
{
type: "content",
hidden: dismissedMovedActions === true,
label: (
<DismissibleBanner
id={movedActionsBannerId}
color="info"
size="xs"
className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
),
},
{
type: "separator",
label: "Recent",
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
@@ -14,12 +14,7 @@ import {
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link";
import {
SettingCommand,
SettingSnippet,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
import { DeprecatedPluginBanner } from "./SettingsPlugins";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
@@ -56,13 +51,10 @@ export function SettingsAgents() {
const cli = cliSetup(client);
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak.
</p>
</div>
<SettingsPage
title="Agents"
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
>
<SettingsList>
<SettingsSection title={null}>
<div className="pb-4 border-b border-border-subtle">
@@ -134,6 +126,6 @@ export function SettingsAgents() {
/>
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import type { ClientCertificate } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm";
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && (
<SettingsPage
title="Client Certificates"
description="Add and manage TLS certificates on a per domain basis"
actions={
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
}
banner={
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
}
>
{certificates.length > 0 ? (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
/>
))}
</VStack>
)}
</VStack>
) : null}
</SettingsPage>
);
}
@@ -1,8 +1,8 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import {
Heading,
HStack,
Icon,
SettingsPage,
Table,
TableBody,
TableCell,
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
}
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
<SettingsPage
title="Keyboard Shortcuts"
description="Click the menu button to add, remove, or reset keyboard shortcuts."
>
<VStack space={3}>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import {
@@ -26,15 +26,11 @@ export function SettingsProxy() {
};
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsPage
title="Proxy"
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
@@ -160,7 +156,7 @@ export function SettingsProxy() {
</>
)}
</SettingsList>
</VStack>
</SettingsPage>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import deepEqual from "@gilbarbara/deep-equal";
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
import type { UpdateInfo } from "@yaakapp-internal/tauri-updater";
import type { Atom } from "jotai";
import { atom } from "jotai";
import { selectAtom } from "jotai/utils";
+35 -123
View File
@@ -6,16 +6,19 @@ import type {
ShowToastRequest,
} from "@yaakapp-internal/plugins";
import { updateAllPlugins } from "@yaakapp-internal/plugins";
import type {
PluginUpdateNotification,
UpdateInfo,
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { PluginUpdateNotification, YaakNotification } from "@yaakapp-internal/tauri-client";
import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater";
import {
ButtonInfiniteLoading,
HStack,
Icon,
showUpdateAvailableToast as showUpdateAvailableToastIn,
showUpdateInstalledToast as showUpdateInstalledToastIn,
showUpdateStateToast as showUpdateStateToastIn,
VStack,
} from "@yaakapp-internal/ui";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
// Listen for toasts
import { platform } from "@yaakapp-internal/platform";
@@ -136,6 +139,11 @@ export function initGlobalListeners() {
void showUpdateAvailableToast(payload);
});
// A check asked for while an update is already in hand says where it stands
platform.listen<UpdateState>("update_state", (state) => {
showUpdateStateToastIn(jotaiStore, { appName: "Yaak", state });
});
platform.listen<YaakNotification>("notification", (payload) => {
console.log("Got notification event", payload);
showNotificationToast(payload);
@@ -149,131 +157,35 @@ export function initGlobalListeners() {
}
function showUpdateInstalledToast(version: string) {
const UPDATE_TOAST_ID = "update-info";
showToast({
id: UPDATE_TOAST_ID,
color: "primary",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">Start using the new version now?</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-20"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => rpc("cmd_restart", {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
showUpdateInstalledToastIn(jotaiStore, {
appName: "Yaak",
version,
onRelaunch: () => rpc("cmd_restart", {}),
});
}
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded, install } = updateInfo;
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
const whatsNewButton = (
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
);
if (install !== "integrated") {
// Nothing to reply to here; the backend only told us so we can say how to update
const flatpak = install === "flatpak";
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{flatpak ? (
<>
Update with <InlineCode>flatpak update</InlineCode> or your software center.
</>
) : (
"Download the new version to upgrade."
)}
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
{!flatpak && (
<Button
size="xs"
color="info"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl("https://yaak.app/download");
}}
>
Download
</Button>
)}
{whatsNewButton}
</HStack>
),
});
return;
// Nothing to reply to otherwise; the backend only told us so we can say how to update
if (install === "integrated") {
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
}
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? "Do you want to install" : "Download and install"} the update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-40"
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
onClick={async () => {
await platform.emit(replyEventId, {
type: "action",
action: "install",
} satisfies UpdateResponse);
}}
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
{whatsNewButton}
</HStack>
),
showUpdateAvailableToastIn(jotaiStore, {
appName: "Yaak",
update: updateInfo,
whatsNewUrl: `https://yaak.app/changelog/${version}`,
downloadUrl: "https://yaak.app/download",
onOpenUrl: (url) => platform.openUrl(url),
onInstall: () =>
platform.emit(replyEventId, {
type: "action",
action: "install",
} satisfies UpdateResponse),
});
}
+7 -2
View File
@@ -42,8 +42,13 @@ export function AppIcon({ path, className = "size-4" }: { path: string; classNam
}, [path]);
return url == null ? (
<span aria-hidden className={classNames(className, "inline-block shrink-0")} />
<span aria-hidden className={classNames(className, "inline-block shrink-0 aspect-square")} />
) : (
<img alt="" src={url} className={classNames(className, "shrink-0")} />
// Square whatever squeezes it, beside text that truncates
<img
alt=""
src={url}
className={classNames(className, "shrink-0 aspect-square object-contain")}
/>
);
}
@@ -47,27 +47,14 @@ export function ClearFlowsButton() {
<IconButton
className="pointer-events-auto"
icon="trash"
title={
locked != null
? "Clear flows (traffic is locked)"
: total === 0
? "Clear flows (none yet)"
: hotkey == null
? "Clear flows"
: `Clear flows ${hotkey}`
}
title={action === "disabled" || hotkey == null ? "Clear flows" : `Clear flows ${hotkey}`}
tooltip
size="sm"
iconColor="secondary"
disabled={action === "disabled"}
onClick={start}
/>
<Dialog
open={confirming}
onClose={() => setConfirming(false)}
size="sm"
title="Clear traffic?"
>
<Dialog open={confirming} onClose={() => setConfirming(false)} size="sm" title="Clear Flows">
{confirming && (
<ConfirmClear
total={total}
@@ -103,8 +90,8 @@ function ConfirmClear({
<form id={formId} className="flex flex-col gap-3" onSubmit={handleSubmit}>
<p>
{total === 1
? "1 flow will be removed."
: `${total.toLocaleString()} flows will be removed.`}
? "Permanently clear 1 flow?"
: `Permanently clear ${total.toLocaleString()} flows?`}
</p>
<Checkbox checked={dontAskAgain} title="Don't ask again" onChange={setDontAskAgain} />
<DialogFooter
+197 -49
View File
@@ -1,56 +1,208 @@
import { Prec } from "@codemirror/state";
import { type EditorView, keymap } from "@codemirror/view";
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui";
import {
Button,
CONTROL_TOOLTIP_DELAY,
Icon,
IconButton,
Input,
SettingRow,
SettingRowBoolean,
Tooltip,
} from "@yaakapp-internal/ui";
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useMemo, useRef, useState } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt";
import {
decryptFilterOptions,
decryptsEverything,
entryLabel,
entrySubject,
EVERYTHING,
everythingEdit,
normalizeEntry,
} from "../lib/decrypt";
import { revealLabel, revealPath } from "../lib/opener";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
/**
* What HTTPS is decrypted: one row per entry, the user's in order and then the built-in
* exclusions, each removable, and an input that adds one, suggesting `host:`, `app:` and `!`,
* then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings' Proxy
* tab.
* Settings' Decryption section: the certificate, Decrypt everything as a switch over `*`, then
* the person's own hosts and apps in order, each removable, an input that adds one (suggesting
* `host:`, `app:` and `!`, then the hosts and apps seen), and the built-in exclusions folded
* into one row. Every change goes through Decrypt, so the first that decrypts something trusts
* the certificate first.
*/
export function DecryptListEditor({ className }: { className?: string }) {
export function DecryptionSettings() {
const list = useDecryptList();
const save = useEditDecryptList();
const { apply } = useDecrypt();
const [busy, setBusy] = useState(false);
// Read when the input opens its suggestions, so new traffic shows without rebuilding it.
const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" });
const seenRef = useRef<SeenTargets | undefined>(seen.data);
seenRef.current = seen.data;
const everything = decryptsEverything(list);
const own = list.filter((e) => !e.builtIn && e.entry !== EVERYTHING);
const builtIns = list.filter((e) => e.builtIn);
// Each change replaces the whole list, so one waits for the last to land rather than undo it.
const remove = (entry: string) => {
if (save.isPending) return;
save.mutate({ add: [], remove: [entry] });
// One change at a time, each applied to the list as it is when it lands.
const change = async (
edit: { add: string[]; remove: string[] },
message: string,
sub?: string,
) => {
if (busy) return;
setBusy(true);
try {
await apply({ edit, message, sub });
} finally {
setBusy(false);
}
};
const remove = (entry: string) =>
void change(
{ add: [], remove: [entry] },
entry.startsWith("!")
? `Stopped excluding ${entrySubject(entry.slice(1))}`
: `Stopped decrypting ${entrySubject(entry)}`,
);
return (
<div className={classNames(className, "grid gap-2")}>
<EntryInput
seenRef={seenRef}
list={list}
disabled={save.isPending}
onAdd={(entry) => save.mutate({ add: [entry], remove: [] })}
<>
<CertificateRow />
<SettingRowBoolean
title="Decrypt everything"
description="Excluded hosts and apps stay encrypted."
checked={everything}
disabled={busy}
onChange={(on) =>
void change(
everythingEdit(on),
on ? "Decrypting everything" : "Stopped decrypting everything",
on ? "Excluded hosts and apps stay encrypted." : undefined,
)
}
/>
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
{list.length === 0 && (
<div className="px-3 py-2 text-sm text-text-subtle">Nothing decrypted</div>
<div className="py-4 grid gap-2">
<div className="flex items-center text-text">
Hosts and apps
<span className="ml-auto text-sm text-text-subtlest">
Changes apply to new connections.
</span>
</div>
<EntryInput
seenRef={seenRef}
list={list}
disabled={busy}
onAdd={(entry) =>
void change(
{ add: [entry], remove: [] },
entry.startsWith("!")
? `Excluding ${entrySubject(entry.slice(1))}`
: `Decrypting ${entrySubject(entry)}`,
)
}
/>
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
{own.length === 0 && (
<div className="px-3 py-2 text-sm text-text-subtle">No hosts or apps</div>
)}
{own.map((e) => (
<EntryRow key={e.entry} entry={e} disabled={busy} onRemove={() => remove(e.entry)} />
))}
{builtIns.length > 0 && <BuiltIns entries={builtIns} disabled={busy} onRemove={remove} />}
</div>
</div>
</>
);
}
/** The certificate: trusted, with Reveal for devices and tools with their own stores, or Trust */
function CertificateRow() {
const caStatus = useRpcQuery("ca_status", {});
const trust = useTrustCertificate();
const trusting = useTrusting();
const showToast = useShowToast();
const status = caStatus.data;
if (status == null)
return (
<SettingRow title="Certificate" description="Yaak Proxy CA">
{null}
</SettingRow>
);
return (
<SettingRow title="Certificate" description="Yaak Proxy CA">
<div className="flex items-center gap-2">
{status.trusted ? (
<span className="flex items-center gap-1 text-sm text-success">
<Icon icon="badge_check" size="sm" />
Trusted
</span>
) : (
<span className="text-sm text-text-subtle">Not trusted</span>
)}
{list.map((e) => (
{!status.trusted && canTrustHere() ? (
<Button size="2xs" color="primary" isLoading={trusting} onClick={() => trust.mutate({})}>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
) : (
<Button
size="2xs"
variant="border"
onClick={() =>
void revealPath(status.certPath, (message) =>
showToast({ id: "reveal-ca", color: "danger", message }),
)
}
>
{revealLabel(getOsType())}
</Button>
)}
</div>
</SettingRow>
);
}
/** The built-in exclusions, folded into one row that opens to each, with its kind */
function BuiltIns({
entries,
disabled,
onRemove,
}: {
entries: DecryptListEntry[];
disabled: boolean;
onRemove: (entry: string) => void;
}) {
const [open, setOpen] = useState(false);
const kinds = new Set(entries.map((e) => e.kind));
const summary = [kinds.has("apple") ? "Apple" : null, kinds.has("pinned") ? "pinned apps" : null]
.filter((k) => k != null)
.join(", ");
return (
<>
<button
type="button"
className="w-full flex items-center gap-2 h-xs pl-3 pr-2 text-sm text-text-subtle hover:bg-surface-highlight/50"
onClick={() => setOpen(!open)}
>
<Icon icon={open ? "chevron_down" : "chevron_right"} size="sm" className="shrink-0" />
{entries.length} built-in exclusions
<span className="ml-auto text-xs text-text-subtlest">{summary}</span>
</button>
{open &&
entries.map((e) => (
<EntryRow
key={e.entry}
entry={e}
disabled={save.isPending}
onRemove={() => remove(e.entry)}
disabled={disabled}
onRemove={() => onRemove(e.entry)}
/>
))}
</div>
</div>
</>
);
}
@@ -81,12 +233,10 @@ function EntryRow({
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
{entryLabel(entry.entry)}
</span>
{entry.builtIn && (
<Tooltip wrap content="Ships with Yaak Proxy" delay={CONTROL_TOOLTIP_DELAY}>
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
built-in
</span>
</Tooltip>
{entry.builtIn && entry.kind != null && (
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
{entry.kind === "apple" ? "Apple" : "Pinned"}
</span>
)}
<IconButton
size="xs"
@@ -139,7 +289,7 @@ function EntryInput({
if (disabledRef.current) return true;
const entry = normalizeEntry(text);
if (entry == null) {
setError("Not a host or app");
setError("Invalid host or app");
return true;
}
setError(null);
@@ -152,20 +302,18 @@ function EntryInput({
}, [seenRef]);
return (
<Tooltip wrap content="Applies to new connections" delay={CONTROL_TOOLTIP_DELAY}>
<div className="grid gap-1">
<Input
label="Add entry"
hideLabel
size="sm"
stateKey={null}
language={null}
placeholder="Add host, app or *"
extensions={extensions}
onChange={() => setError(null)}
/>
{error != null && <div className="text-xs text-danger">{error}</div>}
</div>
</Tooltip>
<div className="grid gap-1">
<Input
label="Add entry"
hideLabel
size="sm"
stateKey={null}
language={null}
placeholder="Add host or app"
extensions={extensions}
onChange={() => setError(null)}
/>
{error != null && <div className="text-xs text-danger">{error}</div>}
</div>
);
}
@@ -0,0 +1,309 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { useQueryClient } from "@tanstack/react-query";
import type { Flow, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
import {
Button,
Dropdown,
formatMillis,
Icon,
IconButton,
type IconProps,
InlineCode,
Tooltip,
CONTROL_TOOLTIP_DELAY,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ReactNode, useEffect, useState } from "react";
import { canTrustHere, useDecrypt, usePassthrough, useTrustCertificate } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import {
type CardAction,
connectionOf,
encryptedCard,
isOpen,
isRejection,
type Line,
} from "../lib/encryptedCard";
import { useFlowActions } from "../lib/flowActions";
import { utcTimestamp } from "../lib/flowViews";
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
import { useCopyEnvironment } from "./ProxyStatus";
/**
* The card for HTTPS the proxy did not decrypt, or a client's refused handshake: the flow card's
* header lines, with ENCRYPTED or REJECTED where the status goes, then why in one line and the
* changes that would decrypt the next connection like it, each showing the entry it writes.
*/
export function EncryptedCard({ flow }: { flow: Flow }) {
const list = useDecryptList();
const caStatus = useRpcQuery("ca_status", {});
const passthrough = usePassthrough();
const rejection = isRejection(flow);
// A tunnel passed through after a rejection names the client that refused.
const rejectedById = flow.tlsRejected == null ? flow.tunnelRejectedBy : null;
const rejectedBy = useRpcQuery(
"get_flows",
{ ids: rejectedById == null ? [] : [rejectedById] },
{ enabled: rejectedById != null },
).data?.[0];
const model = encryptedCard({
flow,
list,
trusted: caStatus.data?.trusted ?? null,
canTrust: canTrustHere(),
passthrough,
rejectedBy,
});
const { apply, trusting } = useDecrypt();
const trust = useTrustCertificate();
const copyEnvironment = useCopyEnvironment();
const showToast = useShowToast();
const fail = (err: Error) =>
showToast({ id: "encrypted-card", color: "danger", message: err.message });
const queryClient = useQueryClient();
// Read from the answer too: an engine the CLI owns tells this app nothing of the change.
const forget = useRpcMutation("forget_passthrough", {
onError: fail,
onSuccess: ({ addresses }) =>
queryClient.setQueryData<PassthroughResponse>(["list_passthrough", {}], { addresses }),
});
const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail });
const connection = connectionOf(flow);
const [busy, setBusy] = useState<string | null>(null);
const run = async (action: CardAction) => {
switch (action.kind) {
case "decrypt":
case "stop":
if (action.edit == null) return;
setBusy(action.label);
await apply({
edit: action.edit,
message: action.toast?.message ?? action.label,
sub: action.toast?.sub,
});
setBusy(null);
return;
case "reconnect":
reconnect.mutate({ flowId: flow.id });
return;
case "trust":
trust.mutate({});
return;
case "try_again":
forget.mutate({ address: connection.address });
return;
case "copy_environment":
await copyEnvironment();
forget.mutate({ address: connection.address });
return;
}
};
return (
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0">
<div
className={classNames(
"flex items-center gap-2 px-3 min-h-[calc(var(--height-sm)+0.25rem)]",
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
)}
>
<Icon
icon={rejection ? "shield_off" : "lock"}
size="sm"
className={rejection ? "text-warning" : "text-text-subtlest"}
/>
<span className="text-text select-text cursor-text">
{connection.host.includes(":") ? `[${connection.host}]` : connection.host}
<span className="text-text-subtlest">:{connection.port}</span>
</span>
</div>
<StatusLine flow={flow} rejected={flow.tlsRejected != null} />
<div className="mx-3 border-t border-border-subtle overflow-auto">
<div className="mx-auto max-w-[27rem] py-10 grid gap-2">
<div className="text-sm text-text mb-1">
<LineText line={model.why} />
<Tooltip wrap content={model.tooltip ?? ABOUT} delay={CONTROL_TOOLTIP_DELAY}>
<IconButton
size="2xs"
icon="info"
iconColor="secondary"
title="About decryption"
className="ml-1 align-middle inline-flex!"
/>
</Tooltip>
</div>
{model.actions.map((action) => (
<ActionButton
key={action.label}
action={
busy === action.label && trusting
? { ...action, label: "Waiting for macOS" }
: action
}
flow={flow}
loading={
busy === action.label ||
(action.kind === "trust" && trust.isPending) ||
(action.kind === "try_again" && forget.isPending) ||
(action.kind === "reconnect" && reconnect.isPending)
}
disabled={busy != null && busy !== action.label}
onClick={() => void run(action)}
/>
))}
{model.foot.length > 0 && (
<div className="text-xs text-text-subtlest mt-0.5">{model.foot.join(" ")}</div>
)}
{flow.error != null && flow.tlsRejected == null && (
<div className="text-xs text-danger mt-1">{flow.error}</div>
)}
</div>
</div>
</div>
);
}
/** What the info button beside the why line says */
const ABOUT =
"HTTPS is encrypted between the app and the server. Yaak Proxy shows requests only for hosts and apps you decrypt, from their next connection on.";
/** A why or fact line, its hosts, apps and entries shown as code */
function LineText({ line }: { line: Line }) {
return (
<>
{line.map((part, i) =>
typeof part === "string" ? (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<span key={i}>{part}</span>
) : "bold" in part ? (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<span key={i} className="font-semibold">
{part.bold}
</span>
) : (
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
<InlineCode key={i}>{part.code}</InlineCode>
),
)}
</>
);
}
const ICONS: Record<Exclude<CardAction["icon"], "app">, IconProps["icon"]> = {
host: "globe",
everything: "layers",
lock: "lock",
lock_open: "lock_open",
refresh: "refresh",
shield: "shield",
terminal: "square_terminal",
};
function ActionButton({
action,
flow,
loading,
disabled,
onClick,
}: {
action: CardAction;
flow: Flow;
loading: boolean;
disabled: boolean;
onClick: () => void;
}) {
const icon: ReactNode =
action.icon === "app" ? (
flow.clientPath == null ? (
<Icon icon="box" size="sm" />
) : (
<AppIcon path={flow.clientPath} className="size-4 min-w-4" />
)
) : (
<Icon icon={ICONS[action.icon]} size="sm" />
);
return (
<Button
size="xs"
variant="border"
color={action.primary ? "primary" : "default"}
justify="start"
className={classNames("w-full", action.dashed && "border-dashed!")}
isLoading={loading}
disabled={disabled}
leftSlot={loading ? undefined : icon}
rightSlot={
action.hint == null ? undefined : (
<span
className={classNames(
"block ml-auto pl-3 font-mono text-xs text-text-subtlest truncate max-w-[11rem]",
action.removes && "line-through",
)}
>
{action.hint}
</span>
)
}
onClick={onClick}
>
{action.label}
</Button>
);
}
/** ENCRYPTED or REJECTED, the duration, or how long it has been open, and the bytes each way */
function StatusLine({ flow, rejected }: { flow: Flow; rejected: boolean }) {
const flowActions = useFlowActions();
const open = isOpen(flow);
const openFor = useOpenFor(flow, open);
return (
<div
className={classNames(
"grid grid-cols-[minmax(0,1fr)_auto] items-center pl-3",
"min-h-[calc(var(--height-sm)+0.25rem)] font-mono text-sm text-text-subtle whitespace-nowrap",
)}
>
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbars">
<span className={rejected ? "text-warning" : undefined}>
{rejected ? "REJECTED" : "ENCRYPTED"}
</span>
<span>&bull;</span>
{open ? (
<span>open {openFor == null ? "" : formatMillis(openFor)}</span>
) : (
<>
<span>{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}</span>
<span>&bull;</span>
<span className="flex items-center gap-1">
<Icon icon="arrow_up" size="xs" className="text-text-subtlest" />
{formatSize(flow.requestSize)}
<Icon icon="arrow_down" size="xs" className="text-text-subtlest ml-1" />
{formatSize(flow.responseSize)}
</span>
</>
)}
</div>
<div className="m-0.5 pr-2.5">
<Dropdown items={flowActions(flow).slice(2)}>
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
</Dropdown>
</div>
</div>
);
}
/** How long an open tunnel has been open, ticking each second */
function useOpenFor(flow: Flow, open: boolean): number | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [open]);
const started = Date.parse(utcTimestamp(flow.startedAt));
return Number.isFinite(started) ? Math.max(0, now - started) : null;
}
+33 -122
View File
@@ -1,18 +1,8 @@
import {
Button,
CONTROL_TOOLTIP_DELAY,
Icon,
InlineCode,
LoadingIcon,
Tooltip,
} from "@yaakapp-internal/ui";
import { Button, Icon, IconTooltip, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { everythingEdit } from "../lib/decrypt";
import { decryptStep } from "../lib/firstRun";
import { encryptingPercent } from "../lib/encrypting";
import { proxyStateAtom } from "../lib/store";
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
@@ -20,49 +10,35 @@ import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./Proxy
type StepState = "todo" | "next" | "wait" | "done" | "fail";
/**
* The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
* choose what to decrypt, then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
* environment beside it serves someone who wants one terminal proxied and nothing system-wide.
* The list's empty state until traffic arrives: turn on the system proxy, then see traffic. The
* certificate is trusted at the first Decrypt, not here, as nothing needs it before. Not
* dismissable and nothing remembered; the first flow replaces it. Copy environment beside it
* serves someone who wants one terminal proxied and nothing system-wide.
*/
export function FirstRun() {
const proxyState = useAtomValue(proxyStateAtom);
const setup = useProxySetup();
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const { systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
const copyEnvironment = useCopyEnvironment();
const decryptList = useDecryptList();
const saveDecryptList = useEditDecryptList();
const running = proxyState?.state === "running";
// Changed only by the process that owns the engine, so not from here while the CLI does.
const otherOwner = proxyState?.otherOwner != null;
const recording = proxyState?.recording !== false;
const trusted = caStatus.data?.trusted === true;
const systemSupported = systemProxy.data?.supported !== false;
const systemOn = systemProxy.data?.enabled === true;
const trustState: StepState = trusted
? "done"
: trust.isPending
? "wait"
: trust.error != null
? "fail"
: "next";
const systemState: StepState = setSystemProxy.isPending
? "wait"
: systemOn
? "done"
: systemSwitch.failed
? "fail"
: trusted
? "next"
: "todo";
const decrypt = decryptStep(decryptList, trusted);
const everything = decrypt.choice === "everything";
const setEverything = (on: boolean) => saveDecryptList.mutate(everythingEdit(on));
const ready = trusted && (systemOn || !systemSupported);
: "next";
const ready = systemOn || !systemSupported;
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
const trafficState: StepState =
encrypting != null ? "wait" : !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
@@ -72,46 +48,21 @@ export function FirstRun() {
<div className="w-full max-w-[40rem]">
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
<div className="text-text-subtle text-sm leading-relaxed mb-4">
{systemSupported ? "Four" : "Three"} steps, once. Then every app on this Mac sends its
traffic here.
{systemSupported
? "Traffic from every app on this Mac will be recorded."
: "Traffic appears here as apps send it."}
</div>
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
<Step
n={1}
state={trustState}
title={trusted ? "Certificate trusted" : "Trust the certificate"}
description={
trusted
? "Yaak Proxy CA, in your login keychain."
: trustState === "wait"
? "Waiting for your password in the macOS prompt."
: trustState === "fail"
? (trust.error?.message ?? "The certificate was not trusted.")
: "So HTTPS reads as plain text. macOS asks for your login password once."
}
action={
trustState === "next" || trustState === "fail" ? (
<Button
size="2xs"
variant={trustState === "fail" ? "border" : "solid"}
color={trustState === "fail" ? "default" : "primary"}
onClick={() => trust.mutate({})}
>
{trustState === "fail" ? "Try Again" : "Trust Certificate"}
</Button>
) : null
}
/>
{systemSupported && (
<Step
n={2}
n={1}
state={systemState}
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
title={systemOn ? "System proxy overridden" : "Override system proxy"}
description={
otherOwner && !systemOn
? proxyState?.otherOwner?.owner === "cli"
? "The CLI owns the proxy. Take over to turn this on from here."
: "Another Yaak Proxy window owns the engine. Quit it to use this one."
? "Take over from the CLI to change this."
: "Quit the other Yaak Proxy to use this one."
: systemSwitch.description
}
action={
@@ -122,74 +73,32 @@ export function FirstRun() {
color={systemState === "fail" ? "default" : "primary"}
onClick={() => setSystemProxy.mutate({ enabled: true })}
>
{systemState === "fail" ? "Try Again" : "Turn On"}
{systemState === "fail" ? "Retry" : "Enable"}
</Button>
) : null
}
/>
)}
<Step
n={systemSupported ? 3 : 2}
state={decrypt.state}
title="What to decrypt"
description={
everything ? (
<Tooltip
wrap
content="Except the built-in exclusions"
delay={CONTROL_TOOLTIP_DELAY}
>
<span>Everything</span>
</Tooltip>
) : (
<Tooltip wrap content="From a flow or the sidebar" delay={CONTROL_TOOLTIP_DELAY}>
<span>Pick as they appear</span>
</Tooltip>
)
}
action={
everything ? (
<Button
size="2xs"
variant="border"
isLoading={saveDecryptList.isPending}
onClick={() => setEverything(false)}
>
Pick As They Appear
</Button>
) : (
<Button
size="2xs"
variant="border"
color={decrypt.state === "next" ? "primary" : "default"}
isLoading={saveDecryptList.isPending}
onClick={() => setEverything(true)}
>
Everything
</Button>
)
}
/>
<Step
n={systemSupported ? 4 : 3}
n={systemSupported ? 2 : 1}
state={trafficState}
title={
encrypting != null
? `Encrypting stored traffic, ${encrypting}`
? `Encrypting recorded traffic, ${encrypting}`
: trafficState === "wait"
? "Waiting for traffic"
: "See traffic"
}
description={
encrypting != null
? "Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
? "Recording will start once encryption finishes."
: trafficState === "fail"
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
: !recording
? "Recording is paused, so traffic passes through without being stored."
: trafficState === "wait"
? "Open a website or send a request, and its flow appears here."
: "Flows appear here as apps send requests."
? (retry.error?.message ?? proxyState?.startError ?? "The proxy failed to start.")
: !recording
? "Recording is paused."
: trafficState === "wait"
? "Open a website or send a request to begin."
: "Flows appear here as apps send requests."
}
action={
trafficState === "fail" ? (
@@ -214,7 +123,7 @@ export function FirstRun() {
/>
</div>
<div className="flex items-center gap-3 mt-4 text-sm text-text-subtle">
<span>Only need one terminal or one app? Copy the environment instead.</span>
<span>To proxy a single terminal instead, copy the environment.</span>
<Button
size="2xs"
variant="border"
@@ -226,12 +135,14 @@ export function FirstRun() {
</Button>
</div>
<div className="mt-2 text-sm text-text-subtle">
Working with an agent? <InlineCode>yaak mcp</InlineCode> gives it the proxy's flows and
actions.
Agents can connect through <InlineCode>yaak mcp</InlineCode>.
</div>
<div className="mt-2 text-sm text-text-subtle">
Captured traffic is encrypted on disk with a key kept in your keychain, so macOS may ask
to let Yaak Proxy use it.
<div className="flex items-center gap-1.5 mt-2 text-sm text-text-subtle">
Recorded traffic is encrypted on disk.
<IconTooltip
iconSize="sm"
content="The key is kept in the system keychain, so the system may ask to let Yaak Proxy use it."
/>
</div>
</div>
</div>
+11 -4
View File
@@ -10,6 +10,7 @@ import {
CsvViewer,
HexdumpViewer,
Icon,
IconTooltip,
ImageViewer,
LARGE_BODY_BYTES,
languageFromContentType,
@@ -238,14 +239,20 @@ export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]">
{decodeError != null && (
<Banner color="danger" className="text-sm mb-2">
{decodeError}. Showing the body as it was on the wire.
{decodeError}. The body is shown as it was received.
</Banner>
)}
{truncated && !hideCutNote && (
<Banner color="notice" className="text-sm mb-2">
{wireTruncated || decodeError != null
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
: `Showing the first ${formatSize(bytes.length)} of the decoded body, the capture limit. The app received all of it.`}
<span className="flex items-center gap-1.5">
{wireTruncated || decodeError != null
? `The first ${formatSize(storedSize)} of ${formatSize(wireSize)} is shown.`
: `The first ${formatSize(bytes.length)} of the decoded body is shown.`}
<IconTooltip
iconSize="sm"
content="The body was truncated at the capture limit. The app received all of it."
/>
</span>
</Banner>
)}
<div className="min-h-0 row-start-3">{viewer}</div>
+33 -17
View File
@@ -15,6 +15,7 @@ import {
parseCookieHeaders,
ResponseCookies,
SplitLayout,
type TabItem,
Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
@@ -22,7 +23,7 @@ import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useMemo } from "react";
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
import { placeTabsAtom } from "../lib/atoms";
import { findHeader } from "../lib/body";
import { bodyTypeBadge, findHeader } from "../lib/body";
import {
browserTabMemory,
defaultTabFor,
@@ -32,13 +33,12 @@ import {
REQUEST_TAB_BODY,
REQUEST_TAB_COOKIES,
REQUEST_TAB_HEADERS,
REQUEST_TAB_INFO,
REQUEST_TAB_PARAMS,
RESPONSE_TAB_BODY,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_INFO,
RESPONSE_TAB_MESSAGES,
RESPONSE_TAB_TIMELINE,
} from "../lib/defaultTab";
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
import {
@@ -54,7 +54,6 @@ import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowInfo } from "./FlowInfo";
import { FlowRequestBody } from "./FlowRequestBody";
import { FlowMessages } from "./FlowMessages";
import { FlowTimeline } from "./FlowTimeline";
import { ProxyEditorHost } from "./ProxyEditorHost";
/**
@@ -150,7 +149,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
],
[cookies, sentCount],
);
const leadingTabs = useMemo<ExtraPaneTab[]>(
() => [{ tab: { value: REQUEST_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> }],
[flow],
);
const contentType = findHeader(flow.requestHeaders, "content-type");
const badge = bodyTypeBadge(contentType, flow.requestSize, flow.graphqlOperation != null);
const bodyTab = useMemo(() => bodyTabItem(REQUEST_TAB_BODY, badge), [badge]);
const available = [
REQUEST_TAB_INFO,
REQUEST_TAB_BODY,
REQUEST_TAB_PARAMS,
REQUEST_TAB_HEADERS,
@@ -169,8 +176,11 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
request={request}
fullHeight
forceUpdateKey={forceUpdateKey}
tabsStorageKey="proxy_request_tabs"
// Info joined as the first tab: an order dragged before it would put it last
tabsStorageKey="proxy_request_tabs.2"
bodyLoader={loader}
bodyTab={bodyTab}
leadingTabs={leadingTabs}
extraTabs={extraTabs}
defaultTab={defaultTab}
activeTab={activeTab}
@@ -208,6 +218,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
);
}
/** A half's body tab: Body, with the body's type beside it as the Headers count is, if any */
function bodyTabItem(value: string, badge: string | null): TabItem {
return {
value,
label: "Body",
rightSlot: badge == null ? null : <CountBadge count={badge} />,
};
}
/** The method and URL, at the status line's height so the two header lines align */
function UrlLine({ flow }: { flow: Flow }) {
return (
@@ -244,14 +263,11 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
const flowActions = useFlowActions();
const messagesLabel = messagesTabLabel(flow);
const badge = bodyTypeBadge(flow.contentType, flow.responseSize);
const bodyTab = useMemo(() => bodyTabItem(RESPONSE_TAB_BODY, badge), [badge]);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" },
content: <FlowTimeline flow={flow} />,
},
{ tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> },
...(messagesLabel == null
() =>
messagesLabel == null
? []
: [
{
@@ -269,16 +285,13 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
},
content: <FlowMessages flow={flow} />,
},
]),
],
],
[flow, messagesLabel],
);
const available = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_TIMELINE,
RESPONSE_TAB_INFO,
...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]),
];
const { activeTab, defaultTab, onTabChange } = useShapeTab("response", flow, available);
@@ -299,6 +312,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
activeTab={activeTab}
onTabChange={onTabChange}
extraTabs={extraTabs}
bodyTab={bodyTab}
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
statusLineCenter={
flow.responseTruncated ? (
@@ -309,7 +323,9 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
)}
>
<Icon icon="scissors" size="xs" />
{body.data == null ? "Body cut" : `First ${formatSize(body.data.storedSize)} kept`}
{body.data == null
? "Body truncated"
: `First ${formatSize(body.data.storedSize)} recorded`}
</span>
) : undefined
}
+4 -3
View File
@@ -2,9 +2,10 @@ import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { selectedFlowAtom } from "../lib/atoms";
import { isRejection } from "../lib/encryptedCard";
import { EncryptedCard } from "./EncryptedCard";
import { EmptyState } from "./FlowBodyViewer";
import { FlowCard } from "./FlowCard";
import { NotDecrypted } from "./NotDecrypted";
/**
* The detail pane: the selected flow's card, or what the pane holds before one is selected. The
@@ -35,8 +36,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
<Keys keys={["⌘", "⇧", "K"]} />
</div>
</EmptyState>
) : flow.tunnelled ? (
<NotDecrypted flow={flow} />
) : flow.tunnelled || isRejection(flow) ? (
<EncryptedCard key={flow.id} flow={flow} />
) : (
<FlowCard flow={flow} listBeside={sideBySide} />
)}
+62 -7
View File
@@ -1,16 +1,26 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { DetailsBanner, formatMillis, KeyValueRow, KeyValueRows } from "@yaakapp-internal/ui";
import {
CONTROL_TOOLTIP_DELAY,
DetailsBanner,
formatMillis,
Icon,
type IconProps,
KeyValueRow,
KeyValueRows,
Tooltip,
} from "@yaakapp-internal/ui";
import { format } from "date-fns";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { Fragment, type HTMLAttributes, type ReactElement, type ReactNode, useMemo } from "react";
import { utcTimestamp } from "../lib/flowViews";
import { flowPhases, type PhaseKind } from "../lib/phases";
import { folderOf } from "../lib/search";
import { AppIcon } from "./AppIcon";
/**
* Where a flow came from and how it travelled: in a proxy you didn't send the request, so the
* connection on each leg, the app and its process, timing, sizes and the flow's record are part
* of the answer.
* of the answer. Then the timeline: each phase of the exchange and how long it took.
*/
export function FlowInfo({ flow }: { flow: Flow }) {
const folder = folderOf(flow);
@@ -55,8 +65,8 @@ export function FlowInfo({ flow }: { flow: Flow }) {
"Started",
format(utcTimestamp(flow.startedAt), "MMM d, yyyy, h:mm:ss.SSS a"),
),
row("headers", "Headers", ms(flow.elapsedHeadersMs)),
row("total", "Total", ms(flow.elapsedMs)),
row("headers", "Latency", ms(flow.elapsedHeadersMs)),
row("total", "Time", ms(flow.elapsedMs)),
]}
</Section>
<Section title="Size">
@@ -65,17 +75,62 @@ export function FlowInfo({ flow }: { flow: Flow }) {
row("response_size", "Response", formatSize(flow.responseSize)),
]}
</Section>
<Section title="Record">
<Section title="Flow">
{[
row("number", "Number", `#${flow.number}`, { copy: true }),
row("id", "ID", flow.id, { copy: true }),
row("saved", "Saved As", flow.savedRequestId, { copy: true }),
row("saved", "Yaak Request ID", flow.savedRequestId, { copy: true }),
]}
</Section>
<Timeline flow={flow} />
</div>
);
}
const PHASE_ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
start: { icon: "send_horizontal", color: "secondary" },
step: { icon: "arrow_right", color: "secondary" },
end: { icon: "check", color: "success" },
error: { icon: "alert_triangle", color: "danger" },
cancelled: { icon: "circle_off", color: "secondary" },
};
/** The flow's phases, each with how long it took and when, from the start */
function Timeline({ flow }: { flow: Flow }) {
const phases = useMemo(() => flowPhases(flow), [flow]);
if (phases.length === 0) return null;
return (
<DetailsBanner defaultOpen summary={<h2>Timeline</h2>}>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] gap-x-3 gap-y-1.5 items-center text-sm py-1">
{phases.map((p) => {
const { icon, color } = PHASE_ICONS[p.kind];
return (
<Fragment key={p.key}>
<Icon icon={icon} color={color} size="sm" />
{p.detail == null ? (
<span className="truncate">{p.label}</span>
) : (
<Tooltip wrap content={p.detail} delay={CONTROL_TOOLTIP_DELAY}>
<span className="truncate">
{p.label}
<span className="text-text-subtlest">{` ${p.detail}`}</span>
</span>
</Tooltip>
)}
<span className="font-mono text-xs tabular-nums text-right">
{p.kind === "start" ? "" : `+${formatMillis(p.tookMs)}`}
</span>
<span className="font-mono text-xs tabular-nums text-right text-text-subtlest">
{formatMillis(p.atMs)}
</span>
</Fragment>
);
})}
</div>
</DetailsBanner>
);
}
/** A row as `KeyValueRows` takes it */
type Row = ReactElement<HTMLAttributes<HTMLTableColElement>>;
+176 -17
View File
@@ -55,6 +55,7 @@ import { compareFlows } from "../lib/flowWindow";
import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow";
import { applyFilter, goToPlace } from "../lib/navigation";
import { asOneTerm } from "../lib/query";
import { connectionOf } from "../lib/encryptedCard";
import { folderOf } from "../lib/search";
import {
columnPrefsAtom,
@@ -853,6 +854,9 @@ const FlowRow = memo(function FlowRow({
// A failure's reason, or Cancelled, takes the type and size cells together, side by side.
const typeAt = ids.indexOf("type");
const spanReason = noResponse && typeAt !== -1 && ids[typeAt + 1] === "size";
// An encrypted connection has no path, so its host takes the room when Path is beside it.
const hostAt = ids.indexOf("host");
const spanHost = flow.tunnelled && hostAt !== -1 && ids[hostAt + 1] === "path";
const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt;
return (
@@ -885,10 +889,16 @@ const FlowRow = memo(function FlowRow({
<FlowMark flow={flow} />
{columns.map((c) => {
if (spanReason && c.id === "size") return null;
if (spanHost && c.id === "path") return null;
return (
// Names the cell's column for its menu, without a box of its own in the row's grid
<div key={c.id} data-column={c.id} className="contents">
<FlowCell column={c.id} flow={flow} span={spanReason && c.id === "type"} />
<FlowCell
column={c.id}
flow={flow}
selected={selected}
span={(spanReason && c.id === "type") || (spanHost && c.id === "host")}
/>
</div>
);
})}
@@ -896,27 +906,46 @@ const FlowRow = memo(function FlowRow({
);
});
/** Why an encrypted row is, for its mark's tooltip */
function encryptedReason(flow: Flow): string {
switch (flow.tunnelReason) {
case "excluded":
return flow.tunnelExcludedBuiltIn || flow.tunnelExcludedBy == null
? "Encrypted: excluded by default"
: `Encrypted: excluded by ${flow.tunnelExcludedBy}`;
case "rejected":
return "Encrypted: an app rejected the certificate";
default:
return "Encrypted: decryption is not enabled";
}
}
/**
* One mark per row, by a fixed priority: failed, cancelled, not decrypted, streaming, cut, saved.
* Its tooltip lists every state the flow has. Agent reads join it once the engine records them.
* One mark per row, by a fixed priority: rejected, failed, cancelled, encrypted, streaming, cut,
* saved. Its tooltip lists every state the flow has. Agent reads join it once the engine records
* them.
*/
function FlowMark({ flow }: { flow: Flow }) {
const open = isOpenStream(flow);
const rejected = flow.tlsRejected != null;
const states: string[] = [];
if (flow.error != null) states.push(`Failed: ${flow.error}`);
if (rejected) states.push("Certificate rejected by the app");
else if (flow.error != null) states.push(`Failed: ${flow.error}`);
if (flow.cancelled) states.push("Cancelled by the app");
if (flow.tunnelled) states.push("Not decrypted");
if (flow.tunnelled) states.push(encryptedReason(flow));
if (open) states.push("Streaming");
if (flow.responseTruncated) states.push("Body cut at the capture limit");
if (flow.responseTruncated) states.push("Body truncated at the capture limit");
if (flow.savedRequestId != null) states.push("Saved to Yaak");
const mark = (
<span className="flex items-center justify-center">
{flow.error != null ? (
{rejected ? (
<Icon icon="shield_off" size="sm" color="warning" />
) : flow.error != null ? (
<Icon icon="circle_alert" size="sm" color="danger" />
) : flow.cancelled ? (
<Icon icon="circle_off" size="sm" className="text-text-subtlest" />
) : flow.tunnelled ? (
<Icon icon="lock" size="sm" className="text-text-subtlest" />
<Icon icon="lock" size="sm" className="text-text-subtle" />
) : open ? (
<span aria-hidden className="w-[7px] h-[7px] rounded-full bg-info ring-3 ring-info/20" />
) : flow.responseTruncated ? (
@@ -958,7 +987,141 @@ function isOpenStream(flow: Flow): boolean {
return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled;
}
function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) {
interface CellProps {
column: ColumnId;
flow: Flow;
/** Takes the next cell's room too */
span: boolean;
selected: boolean;
}
/**
* The text of an encrypted or refused row: told apart by colour alone, the faintest that stays
* readable on the row (subtle, 4.5:1 on the surface in both default themes), and the full text
* colour while selected, as subtle falls under that on the dark theme's selection. No opacity,
* so the app icon and the mark show as on any row.
*/
function quietText(selected: boolean): string {
return selected ? "text-text" : "text-text-subtle";
}
/** A row's number, in the quiet colour */
function quietNumber(flow: Flow, selected: boolean): ReactElement {
return <span className={classNames(cell, mono, quietText(selected))}>{flow.number || ""}</span>;
}
/**
* An encrypted connection's cell: quiet, and only what is known, so its host, how much it
* carried and for how long; nothing read from inside it, so no method, path, status, type,
* query or field. Null for the cells that show as on any row, the app icon among them.
*/
function encryptedCell({ column, flow, span, selected }: CellProps): ReactElement | null {
const quiet = classNames(cell, mono, quietText(selected));
switch (column) {
case "app":
case "started":
return null;
case "number":
return quietNumber(flow, selected);
case "host": {
const { label } = connectionOf(flow);
return (
<Truncated text={label}>
<span
className={classNames(cell, "rtl text-left", span && "col-span-2", quietText(selected))}
>
<bdi>{label}</bdi>
</span>
</Truncated>
);
}
case "type":
// A tunnel that failed to reach the server keeps the failure look, across Type and Size
if (flow.error == null) return <span className={cell} />;
return (
<Tooltip wrap content={flow.error} delay={CONTROL_TOOLTIP_DELAY}>
<span className={classNames(cell, "text-danger text-xs", span && "col-span-2")}>
{flow.error}
</span>
</Tooltip>
);
case "size":
return (
<span className={classNames(quiet, "text-right")}>
{isOpenTunnel(flow) ? "" : formatSize(flow.responseSize)}
</span>
);
case "requestSize":
return (
<span className={classNames(quiet, "text-right")}>
{isOpenTunnel(flow) ? "" : formatSize(flow.requestSize)}
</span>
);
case "time": {
const ms = isOpenTunnel(flow)
? Date.now() - Date.parse(utcTimestamp(flow.startedAt))
: flow.elapsedMs;
return (
<span className={classNames(quiet, "text-right")}>
{ms == null || !Number.isFinite(ms) ? "" : formatMillis(Math.max(0, ms))}
</span>
);
}
default:
return <span className={cell} />;
}
}
/**
* A refused handshake's cell: its host, number and time in the quiet colour, the refusal across
* Type and Size in warning, and nothing for the request it never made. Null for the cells that
* show as on any row, the app icon among them.
*/
function rejectedCell({ column, flow, span, selected }: CellProps): ReactElement | null {
switch (column) {
case "method":
case "path":
case "status":
case "query":
case "size":
return <span className={cell} />;
case "number":
return quietNumber(flow, selected);
case "host": {
const host = hostOf(flow.url);
return (
<Truncated text={host}>
<span className={classNames(cell, "rtl text-left", quietText(selected))}>
<bdi>{host}</bdi>
</span>
</Truncated>
);
}
case "time":
return (
<span className={classNames(cell, mono, "text-right", quietText(selected))}>
{flow.elapsedMs == null ? "" : formatMillis(flow.elapsedMs)}
</span>
);
case "type":
return (
<span className={classNames(cell, "text-warning text-xs", span && "col-span-2")}>
Rejected certificate
</span>
);
default:
return null;
}
}
function FlowCell(props: CellProps) {
const { column, flow, span } = props;
const special = flow.tunnelled
? encryptedCell(props)
: flow.tlsRejected != null
? rejectedCell(props)
: null;
if (special != null) return special;
const failed = flow.error != null;
const noResponse = (failed || flow.cancelled) && flow.status == null;
switch (column) {
@@ -1005,10 +1168,6 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
);
}
case "path": {
// A tunnel's only path is its host; what it carried is unread.
if (flow.tunnelled) {
return <span className={classNames(cell, "text-text-subtlest")}>Not decrypted</span>;
}
const path = pathOf(flow.url);
if (flow.streamKind == null) {
return (
@@ -1034,7 +1193,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
);
}
case "status":
return (flow.cancelled || flow.tunnelled) && flow.status == null ? (
return flow.cancelled && flow.status == null ? (
<span className={classNames(cell, mono, "text-text-subtlest")}>--</span>
) : (
<HttpStatusTagRaw status={flow.status ?? 0} short className={classNames(cell, mono)} />
@@ -1104,7 +1263,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
</span>
);
case "query": {
const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? "");
const query = urlParts(flow.url).query ?? "";
return (
<Truncated text={query}>
<span className={classNames(cell, "text-text-subtle")}>{query}</span>
@@ -1210,9 +1369,9 @@ function useCellActions(): (flow: Flow, column: ColumnId) => DropdownItem[] {
onSelect: async () => {
try {
await navigator.clipboard.writeText(value);
showToast({ message: "Copied value" });
showToast({ message: "Copied to clipboard" });
} catch (err) {
showToast({ message: `Failed to copy value: ${String(err)}`, color: "danger" });
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
}
},
});
+17 -13
View File
@@ -6,7 +6,13 @@ import { appliedFilterAtom } from "../lib/atoms";
import { applyFilter } from "../lib/navigation";
import { rpc } from "../lib/rpc";
import { activeQuery, widerQueries } from "../lib/query";
import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store";
import {
hideEncryptedAtom,
hideSystemAtom,
pausedAtAtom,
proxyStateAtom,
searchWindowAtom,
} from "../lib/store";
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
export function FlowListEmpty() {
@@ -25,18 +31,17 @@ export function FlowListEmpty() {
<div className="text-text text-sm">{recording ? "No flows yet" : "Recording is paused"}</div>
<div className="text-sm text-text-subtle max-w-[26rem]">
{!running
? "The proxy is not running. Retry from the menu by the state."
? "Retry from the status menu to start the proxy."
: !recording
? "Traffic passes through without being stored."
: `Recording on port ${port ?? "--"}. Flows appear as apps send requests.`}
? "Traffic passes through without being recorded."
: `Traffic sent to port ${port ?? "--"} will be recorded.`}
</div>
</EmptyStateText>
);
}
/**
* The list's last row while recording is paused, where the next flow would appear: since when,
* and that nothing is stored.
* The list's last row while recording is paused, where the next flow would appear: since when.
*/
export function PausedRow({ style }: { style?: React.CSSProperties }) {
const pausedAt = useAtomValue(pausedAtAtom);
@@ -50,8 +55,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
>
<Icon icon="circle_pause" size="sm" className="text-text-subtlest" />
<span className="truncate">
{pausedAt == null ? "Paused." : `Paused at ${timeOf(pausedAt)}.`} Traffic passes through
without being stored.
{pausedAt == null ? "Recording is paused." : `Recording was paused at ${timeOf(pausedAt)}.`}
</span>
<Hotkey action="recording_toggle" variant="text" className="ml-auto" />
</div>
@@ -64,6 +68,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
*/
function NoResults({ text }: { text: string }) {
const hideSystem = useAtomValue(hideSystemAtom);
const hideEncrypted = useAtomValue(hideEncryptedAtom);
const store = useStore();
const setText = (wider: string) => applyFilter(store, wider);
const [wider, setWider] = useState<{ text: string; count: number }[]>([]);
@@ -73,7 +78,7 @@ function NoResults({ text }: { text: string }) {
const candidates = widerQueries(text);
void Promise.all(
candidates.map(async (candidate) => {
const query = activeQuery(candidate, hideSystem);
const query = activeQuery(candidate, hideSystem, hideEncrypted);
const res = await rpc("list_flows", { ...query, limit: 0 });
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
}),
@@ -87,7 +92,7 @@ function NoResults({ text }: { text: string }) {
return () => {
current = false;
};
}, [text, hideSystem]);
}, [text, hideSystem, hideEncrypted]);
return (
<EmptyStateText
@@ -95,11 +100,10 @@ function NoResults({ text }: { text: string }) {
className="not-italic !h-auto py-3 px-4 text-sm text-text-subtle"
>
<div className="text-center leading-relaxed">
No flows match.
{wider.length > 0 && " Found matches for "}
{wider.length === 0 ? "No results" : "No results, but found matches for "}
{wider.map((w, i) => (
<span key={w.text}>
{i > 0 && (i === wider.length - 1 ? ", or " : ", ")}
{i > 0 && " or "}
<button type="button" className="hover:underline" onClick={() => setText(w.text)}>
<InlineCode>{w.text === "" ? "all traffic" : w.text}</InlineCode>
</button>{" "}
+12 -3
View File
@@ -46,6 +46,7 @@ import {
resetWidth,
resetWidths,
resizeColumn,
resizeDistance,
toggleColumn,
unavailableColumns,
} from "../lib/columns";
@@ -158,7 +159,7 @@ export const FlowListHeader = memo(function FlowListHeader({
: {}),
};
}),
{ label: "Add Column…", icon: "plus", onSelect: () => openAddColumn(true) },
{ label: "Add Column", icon: "plus", onSelect: () => openAddColumn(true) },
{ type: "separator" },
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
@@ -178,7 +179,7 @@ export const FlowListHeader = memo(function FlowListHeader({
{ label: "Sort Descending", onSelect: () => sortBy(true) },
{ label: "Add to Sort Ascending", onSelect: () => addTo(false) },
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ type: "separator" },
{
label: "Move Left",
@@ -264,7 +265,10 @@ export const FlowListHeader = memo(function FlowListHeader({
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
onResizeMove={(dx) => {
const by = resizeDistance(c, dx, rootFontSize());
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + by));
}}
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
/>
))}
@@ -279,6 +283,11 @@ export const FlowListHeader = memo(function FlowListHeader({
);
});
/** The interface font size now, which numeric columns' widths grow with */
function rootFontSize(): number {
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
}
interface HeaderCellProps {
column: Column;
sort: FlowSortKey[];
+3 -1
View File
@@ -120,7 +120,9 @@ export function FlowMessages({ flow }: { flow: Flow }) {
header={header}
error={error ?? (open ? null : flow.error)}
isLoading={loading && events.length === 0}
emptyMessage={open ? `Waiting for the first ${noun.toLowerCase()}` : `No ${noun}s`}
emptyMessage={
open ? `Waiting for the first ${noun.toLowerCase()}` : `No ${noun.toLowerCase()}s`
}
splitLayoutStorageKey="proxy_flow_messages"
columns={columns}
loadPayload={loadPayload}
@@ -4,15 +4,14 @@ import { describe, expect, test, vi } from "vite-plus/test";
import type { FlowBodyData } from "../lib/flowViews";
import { FlowRequestBody } from "./FlowRequestBody";
// The package's index reaches for the window as it loads; these three are all this renders.
// The package's index reaches for the window as it loads; these four are all this renders.
vi.mock("@yaakapp-internal/ui", () => ({
Banner: ({ children, color }: { children: ReactNode; color?: string }) => (
<div data-banner={color}>{children}</div>
),
EmptyStateText: ({ children }: { children: ReactNode }) => <div>{children}</div>,
LoadingIcon: () => <span>LOADING</span>,
CONTROL_TOOLTIP_DELAY: 0,
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
IconTooltip: ({ content }: { content: string }) => <span data-tooltip={content} />,
}));
const data = (truncated = false): FlowBodyData => ({
@@ -45,10 +44,13 @@ describe("FlowRequestBody", () => {
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
});
test("a cut body says how much was kept", () => {
test("a truncated body says how much is shown, and why behind its info icon", () => {
const html = render({ data: data(true), isLoading: false, error: null });
expect(html).toContain("EDITOR");
expect(html).toContain('data-banner="notice"');
expect(html).toContain("kept");
expect(html.replaceAll("<!-- -->", "")).toContain("The first 7 B of 2 KB is shown.");
expect(html).toContain(
'data-tooltip="The body was truncated at the capture limit. The app sent all of it."',
);
});
});
+8 -12
View File
@@ -1,11 +1,5 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import {
Banner,
CONTROL_TOOLTIP_DELAY,
EmptyStateText,
LoadingIcon,
Tooltip,
} from "@yaakapp-internal/ui";
import { Banner, EmptyStateText, IconTooltip, LoadingIcon } from "@yaakapp-internal/ui";
import type { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes";
import type { ReactNode } from "react";
import type { FlowBodyData } from "../lib/flowViews";
@@ -42,11 +36,13 @@ export function FlowRequestBody({ body, viewer, requestSize }: Props) {
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<Banner color="notice" className="text-sm mb-2">
<Tooltip wrap content="The app sent all of it" delay={CONTROL_TOOLTIP_DELAY}>
<span>
First {formatSize(body.data.storedSize)} of {formatSize(requestSize)} kept
</span>
</Tooltip>
<span className="flex items-center gap-1.5">
The first {formatSize(body.data.storedSize)} of {formatSize(requestSize)} is shown.
<IconTooltip
iconSize="sm"
content="The body was truncated at the capture limit. The app sent all of it."
/>
</span>
</Banner>
<div className="min-h-0">{viewer}</div>
</div>
@@ -1,71 +0,0 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
CONTROL_TOOLTIP_DELAY,
EventViewer,
EventViewerRow,
formatMillis,
Icon,
type IconProps,
Tooltip,
} from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases";
const ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
start: { icon: "send_horizontal", color: "secondary" },
step: { icon: "arrow_right", color: "secondary" },
end: { icon: "check", color: "success" },
error: { icon: "alert_triangle", color: "danger" },
cancelled: { icon: "circle_off", color: "secondary" },
};
/** Yaak's event list with the flow's phases as rows, each saying how long it took */
export function FlowTimeline({ flow }: { flow: Flow }) {
const phases = useMemo(() => flowPhases(flow), [flow]);
return (
<EventViewer<FlowPhase>
events={phases}
getEventKey={(p) => p.key}
splitLayoutStorageKey="proxy_flow_timeline"
enableKeyboardNav={false}
emptyMessage="No timings recorded"
renderRow={({ event, isActive, onClick }) => {
const { icon, color } = ICONS[event.kind];
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon icon={icon} color={color} size="sm" />}
content={
event.detail == null ? (
<span>{event.label}</span>
) : (
<Tooltip
wrap
content={event.detail}
delay={CONTROL_TOOLTIP_DELAY}
>
<span>
{event.label}
<span className="text-text-subtlest">{` ${event.detail}`}</span>
</span>
</Tooltip>
)
}
columns="auto minmax(0,1fr) 6rem 6rem"
cells={{
trailing: [
<span key="took" className="tabular-nums">
{event.kind === "start" ? "" : `+${formatMillis(event.tookMs)}`}
</span>,
<span key="at" className="tabular-nums opacity-50">
{formatMillis(event.atMs)}
</span>,
],
}}
/>
);
}}
/>
);
}
+2 -2
View File
@@ -48,8 +48,8 @@ export function HelperDialog() {
open={open}
onClose={close}
size="dynamic"
title="Turn on Yaak Proxy under Allow in the Background"
description="In System Settings > General > Login Items. Newer macOS calls the list Background App Activity."
title="Approve Helper"
description="Turn on Yaak Proxy in System Settings > General > Login Items."
className="w-[34rem]"
>
<div className="grid gap-3 pb-4">
+174
View File
@@ -0,0 +1,174 @@
import {
Button,
CONTROL_TOOLTIP_DELAY,
Dropdown,
type DropdownItem,
Icon,
IconButton,
Tooltip,
} from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useLocalStorage } from "react-use";
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { everythingEdit, listEdit, setDecrypted } from "../lib/decrypt";
import { chooseOptions, connectionCount, stripState } from "../lib/httpsStrip";
import { sidebarFlowsAtom } from "../lib/store";
import { AppIcon } from "./AppIcon";
import { SETTINGS_TAB_PROXY, useOpenSettings } from "./Settings";
const STRIP =
"flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0";
/**
* Above the list while nothing is decrypted: says once that HTTPS is encrypted, and offers
* Decrypt Everything, or Choose for apps and sites one at a time. Goes once anything is on
* the list, or when dismissed; comes back, with Trust, while the list decrypts something the
* certificate isn't trusted for.
*/
export function HttpsStrip() {
const list = useDecryptList();
const flows = useAtomValue(sidebarFlowsAtom);
const caStatus = useRpcQuery("ca_status", {});
const [dismissed, setDismissed] = useLocalStorage("https_strip_dismissed", false);
// Kept while Choose is open, so checking a row there leaves the menu where it is.
const [choosing, setChoosing] = useState(false);
const { apply } = useDecrypt();
const trusting = useTrusting();
const trust = useTrustCertificate();
const openSettings = useOpenSettings();
const encrypted = useMemo(() => flows.filter((f) => f.tunnelled).length, [flows]);
const state = stripState({
list,
encrypted,
dismissed: dismissed ?? false,
trusted: caStatus.data?.trusted ?? null,
canTrust: canTrustHere(),
});
const options = useMemo(() => chooseOptions(flows, list), [flows, list]);
if (state === "hidden" && !choosing) return null;
if (state === "broken") {
return (
<div className={STRIP}>
<Icon icon="shield_off" size="sm" color="warning" className="shrink-0" />
<span className="text-text shrink-0">Certificate not trusted</span>
<span className="truncate">The certificate must be trusted to decrypt traffic.</span>
<Button
size="2xs"
color="primary"
className="ml-auto shrink-0"
isLoading={trusting}
onClick={() => trust.mutate({})}
>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
</div>
);
}
const items: DropdownItem[] = [
{ type: "separator", label: "Apps", hidden: options.apps.length === 0 },
...options.apps.map((app): DropdownItem => ({
label:
app.path == null ? (
app.name
) : (
<span className="flex items-center gap-2 min-w-0">
<AppIcon path={app.path} className="size-4 min-w-4" />
<span className="truncate">{app.name}</span>
</span>
),
keepOpenOnSelect: true,
leftSlot: <Check on={app.checked} />,
rightSlot: <Count n={app.count} />,
onSelect: async () => {
const subject = { kind: "app", name: app.name, bundleId: app.bundleId } as const;
await apply({
edit: listEdit(list, setDecrypted(list, [subject], !app.checked)),
message: `${app.checked ? "Stopped decrypting" : "Decrypting"} ${app.name}`,
});
},
})),
{ type: "separator", label: "Domains", hidden: options.sites.length === 0 },
...options.sites.map((site): DropdownItem => ({
label: <span className="font-mono text-xs">*.{site.site}</span>,
keepOpenOnSelect: true,
leftSlot: <Check on={site.checked} />,
rightSlot: <Count n={site.count} />,
onSelect: async () => {
const subject = { kind: "site", site: site.site } as const;
await apply({
edit: listEdit(list, setDecrypted(list, [subject], !site.checked)),
message: `${site.checked ? "Stopped decrypting" : "Decrypting"} *.${site.site}`,
});
},
})),
{ type: "separator" },
{
label: "Decryption Settings",
leftSlot: <Icon icon="settings" />,
onSelect: () => openSettings(SETTINGS_TAB_PROXY),
},
];
return (
<div className={STRIP}>
<Icon icon="lock" size="sm" className="shrink-0 text-text-subtle" />
<span className="text-text shrink-0">HTTPS is encrypted</span>
<span className="font-mono text-xs text-text-subtlest truncate">
{connectionCount(encrypted)}
</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<Tooltip
wrap
content="New connections will be decrypted, except for excluded hosts and apps."
delay={CONTROL_TOOLTIP_DELAY}
>
<Button
size="2xs"
variant={trusting ? "border" : "solid"}
color={trusting ? "default" : "primary"}
isLoading={trusting}
onClick={() =>
void apply({
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Excluded hosts and apps stay encrypted.",
})
}
>
{trusting ? "Waiting for macOS" : "Decrypt Everything"}
</Button>
</Tooltip>
<Dropdown items={items} onOpen={() => setChoosing(true)} onClose={() => setChoosing(false)}>
<Button size="2xs" variant="border">
Choose
</Button>
</Dropdown>
<IconButton
size="xs"
icon="x"
iconColor="secondary"
title="Dismiss"
tooltip
onClick={() => setDismissed(true)}
/>
</div>
</div>
);
}
function Check({ on }: { on: boolean }) {
return on ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
);
}
function Count({ n }: { n: number }) {
return <span className="pl-4 font-mono text-xs text-text-subtlest">{n.toLocaleString()}</span>;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function LockedState({ lock }: { lock: ProxyLock }) {
<div className="text-lg font-semibold whitespace-nowrap">
{encrypting != null ? (
<>
Encrypting stored traffic,{" "}
Encrypting recorded traffic,{" "}
{/* Sized for "100%" and right-aligned, so the centred line never changes width as
the number ticks: a changing width re-rasterizes every glyph at a new
sub-pixel offset, and the line shakes */}
@@ -1,97 +0,0 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { Button, CONTROL_TOOLTIP_DELAY, formatMillis, Icon, Tooltip } from "@yaakapp-internal/ui";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt";
import { sidebarPath } from "../lib/search";
import { EmptyState } from "./FlowBodyViewer";
/**
* The detail of HTTPS passed through undecrypted: its sizes and time, and buttons that add its
* host or its app to the decrypt list, from the next connection on.
*/
export function NotDecrypted({ flow }: { flow: Flow }) {
const list = useDecryptList();
const save = useEditDecryptList();
const host = sidebarPath(flow.url).split("/")[0] ?? flow.url;
const hostSubject: DecryptSubject = { kind: "host", host };
const appSubject: DecryptSubject | null =
flow.clientName == null
? null
: { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId };
const app = flow.clientName;
const actions = detailActions(
list,
host,
app == null ? null : { name: app, bundleId: flow.clientBundleId },
);
const open = flow.elapsedMs == null && flow.error == null;
const decrypt = (subject: DecryptSubject) =>
save.mutate(listEdit(list, setDecrypted(list, [subject], true)));
const facts = [
`${formatSize(Number(flow.requestSize))} sent`,
`${formatSize(Number(flow.responseSize))} received`,
open ? "open" : flow.elapsedMs == null ? null : formatMillis(flow.elapsedMs),
].filter((f) => f != null);
return (
<EmptyState>
<div className="grid justify-items-center gap-2">
<div className="flex items-center gap-1.5 text-text-subtle">
<Icon icon="lock" size="sm" />
Not decrypted
</div>
<div className="font-mono text-xs">{facts.join(" · ")}</div>
{flow.error != null && <div className="text-danger">{flow.error}</div>}
{actions.listed ? (
<Tooltip
wrap
content="Still tunnelled? The app may pin its certificate"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>Decrypting new connections</div>
</Tooltip>
) : actions.blocked != null ? (
<Tooltip
wrap
content="Remove it from the decrypt list to decrypt"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>
Excluded by <span className="font-mono text-xs">{actions.blocked}</span>
</div>
</Tooltip>
) : (
<div className="flex gap-2 mt-1">
{actions.host && (
<Button
size="xs"
variant="border"
title={`Decrypt new connections to ${host}`}
tooltip
disabled={save.isPending}
onClick={() => decrypt(hostSubject)}
>
Decrypt Host
</Button>
)}
{actions.app && appSubject != null && (
<Button
size="xs"
variant="border"
title={`Decrypt new connections from ${app}`}
tooltip
disabled={save.isPending}
onClick={() => decrypt(appSubject)}
>
Decrypt App
</Button>
)}
</div>
)}
</div>
</EmptyState>
);
}
+3 -2
View File
@@ -31,6 +31,7 @@ import { SavedSearchDialog } from "./SavedSearchDialog";
import { SearchBar } from "./SearchBar";
import { SettingsDialogFallback, useOpenSettings } from "./Settings";
import { Sidebar } from "./Sidebar";
import { HttpsStrip } from "./HttpsStrip";
import { TakeOverBanner } from "./TakeOverBanner";
export function ProxyLayout() {
@@ -166,6 +167,7 @@ export function ProxyLayout() {
>
<div className="h-full flex flex-col min-h-0">
<TakeOverBanner />
<HttpsStrip />
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
<FlowsOrFirstRun flowLayout={flowLayout} />
</div>
@@ -190,10 +192,9 @@ export function ProxyLayout() {
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
const locked = useAtomValue(lockedAtom);
const noFlows = useAtomValue(noFlowsAtom) || locked != null;
const { caStatus, systemProxy } = useProxySetup();
const { systemProxy } = useProxySetup();
const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom);
const incomplete = setupIncomplete({
trusted: caStatus.data?.trusted ?? null,
systemProxySupported: systemProxy.data?.supported ?? null,
systemProxyEverOn,
});
+99 -65
View File
@@ -1,5 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import {
Button,
Checkbox,
@@ -13,10 +13,13 @@ import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { useEffect, useRef, useState } from "react";
import { canTrustHere, useDecrypt, useTrustCertificate } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcEvent } from "../hooks/useRpcEvent";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperRegisteredAtAtom } from "../lib/atoms";
import { decryptsAnything, decryptsEverything, everythingEdit, listSummary } from "../lib/decrypt";
import { encryptingPercent } from "../lib/encrypting";
import { environmentExports } from "../lib/env";
import { proxyStatusOf } from "../lib/proxyStatus";
@@ -27,9 +30,10 @@ import { useShowToast } from "../lib/toast";
/**
* The header's state pill and its menu: the listening address, which copies itself, the state,
* Pause or Resume Recording, Override system proxy, and the certificate. The engine runs for as
* long as the app does, so the pill shows whether flows are being stored. A certificate that is not trusted steps out of the
* menu beside the pill until it is. Everything set once and left alone lives in Settings.
* Pause or Resume Recording, Override system proxy, and Decrypt everything. The engine runs for
* as long as the app does, so the pill shows whether flows are being stored. A certificate that
* is not trusted while something is listed to decrypt steps out of the menu beside the pill
* until it is. Everything set once and left alone lives in Settings.
*/
export function ProxyStatus() {
const dropdownRef = useRef<DropdownRef>(null);
@@ -38,14 +42,21 @@ export function ProxyStatus() {
const setup = useProxySetup();
const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const { caStatus, systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
const running = proxyState?.state === "running";
const recording = proxyState?.recording !== false;
const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null;
const untrusted = caStatus.data != null && !caStatus.data.trusted;
const decryptList = useDecryptList();
// Something is listed to decrypt but the certificate isn't trusted, so it can't be.
// Only where the app can trust it itself: elsewhere the status always reads untrusted.
const broken =
canTrustHere() &&
caStatus.data != null &&
!caStatus.data.trusted &&
decryptsAnything(decryptList);
// The same status the header's tint takes its colour from
const status = proxyStatusOf(proxyState);
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
@@ -54,24 +65,20 @@ export function ProxyStatus() {
status === "encrypting" ? (
<MenuRow
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
title={`Encrypting stored traffic, ${encrypting}`}
description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
title={`Encrypting recorded traffic, ${encrypting}`}
description="Recording will start once encryption finishes."
/>
) : status === "cli" && other != null ? (
<MenuRow
icon={<StateDot state="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={`The proxy was started by the CLI at ${timeOf(other.startedAt)}.`}
/>
) : status === "recording" || status === "paused" ? (
<MenuRow
icon={<StateDot state={status} />}
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
description={
recording
? "Every flow is stored as it passes."
: "Traffic passes through untouched, and nothing is stored."
}
description={recording ? null : "Traffic passes through without being recorded."}
/>
) : (
<MenuRow
@@ -79,7 +86,7 @@ export function ProxyStatus() {
title="Not running"
description={
<span className="text-danger">
{retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."}
{retry.error?.message ?? proxyState?.startError ?? "The proxy failed to start."}
</span>
}
action={
@@ -108,7 +115,7 @@ export function ProxyStatus() {
const address = port == null ? null : `127.0.0.1:${port}`;
const items: DropdownItem[] = [
{
label: <span className="font-mono">{address}</span>,
label: <span className="font-mono text-editor">{address}</span>,
hidden: address == null,
keepOpenOnSelect: true,
rightSlot: copied ? (
@@ -145,9 +152,7 @@ export function ProxyStatus() {
{systemSwitch.description}
</span>
{helperNotice && (
<span className="block text-notice">
macOS asks for your password each time until the helper is approved in Settings.
</span>
<span className="block text-notice">macOS will ask for a password each time.</span>
)}
</>
}
@@ -166,44 +171,7 @@ export function ProxyStatus() {
/>
),
},
{
type: "content",
label: caStatus.data?.trusted ? (
<MenuRow
icon={<Icon icon="shield_check" color="success" size="sm" />}
title="Certificate trusted"
description="Yaak Proxy CA, in your login keychain."
/>
) : (
<MenuRow
icon={<Icon icon="shield_off" color="warning" size="sm" />}
title="Certificate not trusted"
description={
trust.error != null ? (
<span className="text-danger">{trust.error.message}</span>
) : trust.isPending ? (
"Waiting for your password in the macOS prompt."
) : (
"So HTTPS reads as plain text. macOS asks for your login password once."
)
}
action={
<Button
size="2xs"
variant="border"
color="primary"
isLoading={trust.isPending}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trust.error != null ? "Try again" : "Trust certificate"}
</Button>
}
/>
),
},
{ type: "content", label: <DecryptEverythingRow broken={broken} /> },
];
return (
@@ -235,7 +203,7 @@ export function ProxyStatus() {
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
{untrusted && (
{broken && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
@@ -245,6 +213,73 @@ export function ProxyStatus() {
);
}
/**
* Decrypt everything, `*` on the list, as a switch: what else is listed when off, and Trust
* while something is listed and the certificate isn't trusted.
*/
function DecryptEverythingRow({ broken }: { broken: boolean }) {
const list = useDecryptList();
const { apply, trusting } = useDecrypt();
const trust = useTrustCertificate();
const on = decryptsEverything(list);
if (broken) {
return (
<MenuRow
icon={<Icon icon="shield_off" size="sm" color="warning" />}
title="Decrypt everything"
description={<span className="text-warning">Certificate not trusted</span>}
action={
<Button
size="2xs"
color="primary"
isLoading={trusting}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
}
/>
);
}
return (
<MenuRow
icon={<Icon icon="lock_open" size="sm" color="secondary" />}
title="Decrypt everything"
description={
trusting
? "Waiting for macOS"
: on
? "Excluded hosts and apps stay encrypted."
: (listSummary(list) ?? "No hosts or apps")
}
action={
<KeepMenuOpen>
<Checkbox
title="Decrypt everything"
hideLabel
checked={on}
disabled={trusting}
onChange={(checked) =>
void apply(
checked
? {
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Excluded hosts and apps stay encrypted.",
}
: { edit: everythingEdit(false), message: "Stopped decrypting everything" },
)
}
/>
</KeepMenuOpen>
}
/>
);
}
/**
* The certificate and the system proxy, read once and shared by the header and the first-run
* card, with the actions that change them.
@@ -265,10 +300,6 @@ export function useProxySetup() {
// The user comes back from System Settings, maybe having just allowed the helper.
const { refetch: refetchSystemProxy } = systemProxy;
useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]);
const trust = useRpcMutation("ca_trust", {
mutationKey: ["ca_trust"],
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
});
const setSystemProxy = useRpcMutation("system_proxy_set", {
mutationKey: ["system_proxy_set"],
onSuccess: (status) => {
@@ -284,7 +315,7 @@ export function useProxySetup() {
useEffect(() => {
if (on && !everOn) setEverOn(true);
}, [on, everOn, setEverOn]);
return { caStatus, trust, systemProxy, setSystemProxy };
return { caStatus, systemProxy, setSystemProxy };
}
/** The system proxy switch's state, from where it points and the change in flight */
@@ -313,7 +344,7 @@ export function useCopyEnvironment() {
try {
await navigator.clipboard.writeText(environmentExports(port, caPath));
showToast({
message: "Copied the environment. Paste it into a terminal to proxy what runs there.",
message: "Copied the environment. Paste it into a terminal.",
});
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
@@ -392,6 +423,7 @@ function MenuRow({
}: {
icon: ReactNode;
title: string;
/** Under the title; null for a title that says it all */
description: ReactNode;
action?: ReactNode;
}) {
@@ -400,7 +432,9 @@ function MenuRow({
<span className="h-5 flex items-center justify-center">{icon}</span>
<div>
<div className="text-sm text-text">{title}</div>
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
{description != null && (
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
)}
</div>
<span className="self-center">{action}</span>
</div>
+20 -24
View File
@@ -40,8 +40,8 @@ export function SaveDialog() {
open
onClose={close}
size="dynamic"
title="Save as Yaak request"
description="A new request in Yaak with this flow's method, URL, headers and body."
title="Save as Yaak Request"
description="A new request will be created in Yaak."
className="w-[36rem]"
>
{workspaces.error != null ? (
@@ -103,7 +103,7 @@ function SaveForm({
message: (
<>
Saved <b className="font-semibold">{saved.name || unnamed}</b> to {workspace.name}
{folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second.
{folderName == null ? "" : `, in ${folderName}`}
</>
),
});
@@ -113,11 +113,7 @@ function SaveForm({
};
if (workspaces.length === 0) {
return (
<Banner color="notice">
Yaak has no workspaces yet. Create one in Yaak, then save here.
</Banner>
);
return <Banner color="notice">Create a workspace in Yaak first.</Banner>;
}
if (p == null) {
@@ -209,13 +205,10 @@ function SaveForm({
</div>
{save.error != null && <Banner color="danger">{save.error.message}</Banner>}
<DialogFooter
leftSlot={
<span className="text-xs text-text-subtlest">Yaak picks it up within a second</span>
}
actions={[
{ label: "Cancel", onClick: onClose },
{
label: "Save request",
label: "Save Request",
form: SAVE_FORM_ID,
color: "primary",
isLoading: save.isPending,
@@ -243,15 +236,19 @@ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
return (
<Dialog open onClose={onClose} size="dynamic" className="w-[32rem]">
<DialogFooter
leftSlot={<span className="text-xs text-text-subtlest">Or keep the flow as a file</span>}
leftSlot={
<span className="text-xs text-text-subtlest">
The flow can be exported as a file instead.
</span>
}
actions={[
{
label: "Export HAR",
label: "Export as HAR",
onClick: async () => {
try {
if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) {
onClose();
showToast({ message: "Exported the flow as HAR" });
showToast({ message: "Exported 1 flow as HAR" });
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
@@ -262,11 +259,10 @@ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
]}
/>
<div className="pt-6 pb-2">
<div className="text-lg font-semibold mb-1.5">Saving needs Yaak</div>
<div className="text-lg font-semibold mb-1.5">Yaak Required</div>
<div className="text-sm text-text-subtle leading-relaxed">
This flow becomes a request in one of Yaak's workspaces, and Yaak has not been opened on
this Mac. Yaak is free for personal use and opens the request with the same viewers you
see here.
This flow will be saved as a request in one of Yaak's workspaces, but Yaak has not been
opened on this computer.
</div>
</div>
</Dialog>
@@ -308,18 +304,18 @@ function folderOptions(folders: FolderSummary[]): { id: string; name: string }[]
function reasonLabel(reason: DropReason): string {
switch (reason) {
case "set_by_sending":
return "Yaak sets it when it sends";
return "Yaak sets it when it sends the request.";
case "hop_by_hop":
return "Only meant for the proxy";
return "It was only meant for the proxy.";
case "body_decoded":
return "The body is saved decoded";
return "The body is saved decoded.";
}
}
function droppedSummary(reasons: DropReason[]): string {
const unique = [...new Set(reasons)];
if (unique.length === 1 && unique[0] === "set_by_sending") {
return "Yaak sets these itself when it sends";
return "Yaak sets these itself when it sends the request.";
}
return "Yaak sets these itself, or they only meant something to the proxy";
return "Yaak sets these, or they were meant for the proxy.";
}
@@ -47,7 +47,7 @@ function NameDialog({ dialog, onClose }: { dialog: SavedSearchDialog; onClose: (
open
onClose={onClose}
size="dynamic"
title={dialog.mode === "save" ? "Save search" : "Rename search"}
title={dialog.mode === "save" ? "Save Search" : "Rename Search"}
className="w-[26rem]"
>
<form
+3 -3
View File
@@ -205,7 +205,7 @@ export const SearchBar = memo(function SearchBar() {
<Dropdown
items={[
{
label: "Save search",
label: "Save Search",
leftSlot: <Icon icon="bookmark" />,
onSelect: () =>
setSavedDialog({
@@ -275,7 +275,7 @@ function FilterHistory({
const items: DropdownItem[] =
history.length === 0
? [{ label: "No recent filters", disabled: true }]
? [{ label: "No recent searches", disabled: true }]
: [
...history.map((filter): DropdownItem => ({
label: (
@@ -311,7 +311,7 @@ function FilterHistory({
<IconButton
size="xs"
icon="search"
title="Recent filters"
title="Recent searches"
tooltip
iconColor="secondary"
disabled={disabled}
+14 -6
View File
@@ -16,13 +16,14 @@ import {
type SettingsTitlebar,
Toasts,
} from "@yaakapp-internal/ui";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { settingsValuesAtom } from "../lib/interfaceSettings";
import { openLink, revealPath } from "../lib/opener";
import { proxySettingsView } from "../lib/settings";
import { closeWindow, command, currentWindowLabel, getOsType, subscribe } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { checkForUpdatesNow, updateCheckingAtom, updaterEnabled } from "../lib/updates";
import {
openSettingsVia,
requestedSettingsTab,
@@ -125,9 +126,16 @@ export function SettingsContent({
const [values, change] = useAtom(settingsValuesAtom);
const app = useAppInfo();
const systemAppearance = useSystemAppearance();
const store = useStore();
const checking = useAtomValue(updateCheckingAtom);
const { data: hasUpdater = false } = useQuery({
queryKey: ["updater_enabled"],
queryFn: updaterEnabled,
retry: false,
});
const view = useMemo(
() => proxySettingsView(values, app, systemAppearance),
[values, app, systemAppearance],
() => proxySettingsView(values, app, systemAppearance, hasUpdater ? { checking } : null),
[values, app, systemAppearance, hasUpdater, checking],
);
const showToast = useShowToast();
const callbacks = useMemo<SettingsCallbacks>(() => {
@@ -136,14 +144,14 @@ export function SettingsContent({
onChange: (patch) => change(patch),
// The proxy draws its own title bar always; the row isn't shown
onApplyNativeTitlebar: (useNativeTitlebar) => change({ useNativeTitlebar }),
onCheckForUpdates: () => undefined,
onCheckForUpdates: () => void checkForUpdatesNow(store),
onActivateLicense: async () => undefined,
onDeactivateLicense: () => undefined,
onOpenUrl: (url) => void openLink(url, fail),
onRevealPath: (path) => void revealPath(path, fail),
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
};
}, [change, showToast]);
}, [change, showToast, store]);
const props = { view, callbacks };
return (
@@ -227,7 +235,7 @@ function useAppInfo() {
? "Reveal in Finder"
: os === "windows"
? "Show in Explorer"
: "Show in Folder",
: "Show in File Manager",
}),
[data, os],
);
+32 -31
View File
@@ -5,8 +5,8 @@ import {
Button,
Dialog,
DialogFooter,
Heading,
Icon,
IconTooltip,
RadioDropdown,
SettingRow,
SettingRowBoolean,
@@ -15,8 +15,8 @@ import {
SettingSelectControl,
SettingSnippet,
SettingsList,
SettingsPage,
SettingsSection,
VStack,
} from "@yaakapp-internal/ui";
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { useState } from "react";
@@ -41,7 +41,7 @@ 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";
import { DecryptionSettings } from "./DecryptListEditor";
/**
* The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what
@@ -59,31 +59,30 @@ export function SettingsProxy() {
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>
<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="For new connections"
description="The change applies to new connections."
value={proxyState?.httpVersion ?? "auto"}
disabled={elsewhere}
onChange={(httpVersion) => setHttpVersion.mutate({ httpVersion })}
options={[
{ label: "Auto", value: "auto" },
{ label: "Automatic", value: "auto" },
{ label: "HTTP/1.1 only", value: "http1Only" },
]}
/>
{systemProxy.data?.supported === true && (
<>
<SettingRowBoolean
title="Override system proxy"
description="At launch"
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 })}
@@ -94,7 +93,7 @@ export function SettingsProxy() {
</SettingsSection>
<SettingsSection title="Decryption">
<DecryptListEditor className="py-4" />
<DecryptionSettings />
</SettingsSection>
<SettingsSection title="Traffic">
@@ -108,7 +107,7 @@ export function SettingsProxy() {
<TerminalRow />
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -152,7 +151,7 @@ function PortRow({ disabled }: { disabled: boolean }) {
setPort.mutate({ port });
};
return (
<SettingRow title="Port" description="Restarts the proxy">
<SettingRow title="Port" description="Changing the port restarts the proxy.">
<PlainInput
hideLabel
size="sm"
@@ -189,7 +188,10 @@ function HelperRow({ status }: { status: SystemProxyStatus }) {
unregister.error != null ? (
<span className="text-danger">{unregister.error.message}</span>
) : (
`${capitalize(row.title.replace(/^Helper: /, ""))}. ${row.description}`
<span className="inline-flex items-center gap-1.5">
{row.description}
<IconTooltip iconSize="sm" content={row.tooltip} />
</span>
)
}
>
@@ -205,7 +207,7 @@ function HelperRow({ status }: { status: SystemProxyStatus }) {
? "Install"
: row.action === "approve"
? "Approve"
: "Unregister"}
: "Uninstall"}
</Button>
)}
</SettingRow>
@@ -222,7 +224,7 @@ function TrafficRow() {
return (
<SettingRowSelect<TrafficRetention>
name="traffic"
title="Captured traffic"
title="Recorded traffic"
selectClassName="w-56!"
value={proxyState?.traffic ?? "clearOnLaunch"}
disabled={proxyState?.otherOwner != null}
@@ -245,7 +247,7 @@ function KeepTrafficRow() {
});
const keep = proxyState?.keepTrafficBytes ?? null;
return (
<SettingRow title="Size limit" description="Oldest flows dropped past it">
<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">
@@ -285,21 +287,24 @@ function ResetRow() {
onSuccess: () => {
setConfirming(false);
void queryClient.invalidateQueries();
showToast({ message: "Captured data reset" });
showToast({ message: "Recorded data reset" });
},
onSettled: () => void refreshProxyState(store),
});
return (
<SettingRow title="Reset captured data" description="Deletes all flows and their keys">
<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…
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."
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
@@ -356,8 +361,8 @@ function TerminalRow() {
className="ml-auto"
title={
os === "macos"
? "A new Terminal window with these set"
: "Not available on this platform yet"
? "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}
@@ -368,7 +373,7 @@ function TerminalRow() {
Open Terminal
</Button>
</div>
<p className="mt-1 text-sm text-text-subtle">Proxies one terminal instead of the system</p>
<p className="mt-1 text-sm text-text-subtle">Traffic from one terminal will be recorded.</p>
{caPath != null && (
<SettingSnippet
className="mt-3"
@@ -379,7 +384,3 @@ function TerminalRow() {
</div>
);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
+64 -26
View File
@@ -10,21 +10,30 @@ import {
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useDecrypt } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { appliedFilterAtom, SIDEBAR_TREE_ID } from "../lib/atoms";
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
import {
blockerOf,
decryptMenuAction,
decryptsEverything,
everythingEdit,
listEdit,
setDecrypted,
} from "../lib/decrypt";
import { revealLabel, revealPath } from "../lib/opener";
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
import {
copyRowsText,
ENCRYPTED_ID,
filterWithRows,
type SidebarItem,
SYSTEM_ID,
valueRows,
} from "../lib/sidebarTree";
import { applyFilter, noteSidebarInput, noteSidebarMenu } from "../lib/navigation";
import { hideSystemAtom, sidebarTreeAtom } from "../lib/store";
import { hideEncryptedAtom, hideSystemAtom, sidebarTreeAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
@@ -73,10 +82,12 @@ function ItemInner({ item }: { item: SidebarItem }) {
return (
<div className="flex items-center gap-2 w-full min-w-0">
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "encrypted" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "saved" && <Icon icon="bookmark" size="sm" className="text-text-subtle" />}
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
{item.appPath != null && <AppIcon path={item.appPath} className="size-[1.125rem]" />}
{item.appPath != null && (
<AppIcon path={item.appPath} className="size-[1.125rem] min-w-[1.125rem]" />
)}
{item.kind === "folder" && item.path != null ? (
// Named by its last part; the whole path on hover
<Tooltip wrap content={item.path} delay={CONTROL_TOOLTIP_DELAY}>
@@ -88,7 +99,11 @@ function ItemInner({ item }: { item: SidebarItem }) {
{item.sublabel != null && (
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
)}
{item.decrypt != null && <DecryptedMark item={item} />}
{item.encryptedOnly && (
<Tooltip wrap content="Encrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
)}
<Count item={item} />
</div>
);
@@ -117,14 +132,12 @@ function CloseAtOnce({ onClose }: { onClose: () => void }) {
return null;
}
/** A small open lock on a host, site or app the decrypt list decrypts */
function DecryptedMark({ item }: { item: SidebarItem }) {
const list = useDecryptList();
if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null;
return (
<Tooltip wrap content="Decrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock_open" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
/** A checkable menu item's mark: a check when on, the same room when off */
function checkMark(on: boolean) {
return on ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
);
}
@@ -137,9 +150,9 @@ export function Sidebar() {
[],
);
const [hideSystem, setHideSystem] = useAtom(hideSystemAtom);
const [hideEncrypted, setHideEncrypted] = useAtom(hideEncryptedAtom);
const decryptList = useDecryptList();
const setDecryptList = useEditDecryptList();
const { mutate: saveDecryptList } = setDecryptList;
const { apply: applyDecrypt } = useDecrypt();
const showToast = useShowToast();
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", {
@@ -162,7 +175,7 @@ export function Sidebar() {
onSelect: () => void revealPath(path, fail),
},
{
label: "Copy path",
label: "Copy Path",
leftSlot: <Icon icon="copy" />,
hidden: item.kind !== "folder",
onSelect: () => {
@@ -190,9 +203,9 @@ export function Sidebar() {
onSelect: async () => {
try {
await navigator.clipboard.writeText(copyRowsText(items));
showToast({ message: "Copied value" });
showToast({ message: "Copied to clipboard" });
} catch (err) {
showToast({ message: `Failed to copy value: ${String(err)}`, color: "danger" });
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
}
},
},
@@ -210,15 +223,35 @@ export function Sidebar() {
return [
{
label: "Show in All Traffic",
leftSlot: hideSystem ? (
<span aria-hidden className="inline-block w-4 h-4" />
) : (
<Icon icon="check" size="sm" />
),
leftSlot: checkMark(!hideSystem),
onSelect: () => setHideSystem(!hideSystem),
},
];
}
if (items.length === 1 && items[0]?.id === ENCRYPTED_ID) {
return withSeparators([
[
{
label: "Decrypt Everything",
leftSlot: <Icon icon="lock_open" />,
hidden: decryptsEverything(decryptList),
onSelect: () =>
void applyDecrypt({
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Excluded hosts and apps stay encrypted.",
}),
},
],
[
{
label: "Show in All Traffic",
leftSlot: checkMark(!hideEncrypted),
onSelect: () => setHideEncrypted(!hideEncrypted),
},
],
]);
}
const paths = items.length === 1 ? pathItems(items[0]) : [];
const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }]));
if (saved.length > 0) {
@@ -265,7 +298,10 @@ export function Sidebar() {
// Only what this changes, applied to the list as it is then, so a second change
// made before this one lands keeps it.
const next = setDecrypted(decryptList, subjects, action.on);
saveDecryptList(listEdit(decryptList, next));
void applyDecrypt({
edit: listEdit(decryptList, next),
message: `${action.on ? "Decrypting" : "Stopped decrypting"} ${action.subject}`,
});
},
},
];
@@ -273,7 +309,7 @@ export function Sidebar() {
},
[
decryptList,
saveDecryptList,
applyDecrypt,
showToast,
setSavedDialog,
deleteSaved,
@@ -281,6 +317,8 @@ export function Sidebar() {
filterItems,
hideSystem,
setHideSystem,
hideEncrypted,
setHideEncrypted,
],
);
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
@@ -29,11 +29,11 @@ export function TakeOverBanner() {
<span className="truncate">
{!cli ? (
// Stopping another app halts its engine without releasing it, so it can't be taken over.
"Another Yaak Proxy window owns the engine. Quit it to use this one."
"Quit the other copy to use this one."
) : takeOver.error != null ? (
<span className="text-danger">{takeOver.error.message}</span>
) : (
`pid ${other.pid}, since ${since}. Flows still list here, read from the capture database.`
`pid ${other.pid}, since ${since}`
)}
</span>
{cli && (
@@ -44,7 +44,7 @@ export function TakeOverBanner() {
isLoading={takeOver.isPending}
onClick={() => takeOver.mutate({})}
>
Take over
Take Over
</Button>
)}
</div>
+19 -3
View File
@@ -11,8 +11,9 @@ import { type FlowFixture, fixtures } from "./fixtures";
/**
* Dev only: captured flows in the detail card, over fixtures. Open
* `/harness.html?flow=0&theme=dark`; `&list` puts the flow list beside the card (its halves
* stacked), `&below` puts it above (side by side), and `&tab=info` opens the response half on a
* tab, as though it were chosen by hand for flows of that shape.
* stacked), `&below` puts it above (side by side), `&tab=info` opens the response half on a
* tab, as though it were chosen by hand for flows of that shape, and `&numbers` gives the flows
* the widest sizes and times.
*/
export function FlowPanesHarness() {
const params = new URLSearchParams(location.search);
@@ -40,13 +41,28 @@ export function FlowPanesHarness() {
);
}
const NUMBERS = [
{ responseSize: 999_949, requestSize: 1000, elapsedMs: 3_599_000, elapsedHeadersMs: 999 },
{ responseSize: 124_700, requestSize: 999_949, elapsedMs: 999, elapsedHeadersMs: 3_599_000 },
{ responseSize: 20_200, requestSize: 812, elapsedMs: 9_949, elapsedHeadersMs: 59_499 },
{ responseSize: 999_949_000, requestSize: 41_800, elapsedMs: 59_999_000, elapsedHeadersMs: 600 },
{ responseSize: 1000, requestSize: 7_600_000, elapsedMs: 234, elapsedHeadersMs: 5_900 },
{ responseSize: 9_000, requestSize: 999_949_000_000, elapsedMs: 400_000, elapsedHeadersMs: 12 },
];
function FixtureCard({ fixture }: { fixture: FlowFixture }) {
const params = new URLSearchParams(location.search);
const store = useStore();
const setWindow = useSetAtom(flowWindowAtom);
const [ready, setReady] = useState(false);
useLayoutEffect(() => {
const flows = fixtures.map((f, i) => ({ ...f.flow, number: 1401 + i }));
// `&numbers`: sizes and times of every unit, the widest of each among them
const numbers = params.has("numbers");
const flows = fixtures.map((f, i) => ({
...f.flow,
number: 1401 + i,
...(numbers && f.flow.elapsedMs != null && NUMBERS[i % NUMBERS.length]),
}));
const tab = params.get("tab");
if (tab != null) {
browserTabMemory()?.setItem(learnedTabKey("response", flowShape(fixture.flow)), tab);
+2 -2
View File
@@ -9,8 +9,8 @@ import { syncNavigation, syncSidebarSelection } from "../lib/navigation";
import { syncFlows, syncProxyState } from "../lib/store";
const LOCKED =
"Traffic is locked: Yaak Proxy stores traffic encrypted and couldn't read its key from the " +
"keychain: Encryption error: User canceled the operation. Allow it access, then retry.";
"Traffic is locked: Failed to read the encryption key from the keychain: Encryption error: " +
"User canceled the operation";
const STATE: GetProxyStateResponse = {
state: "stopped",
+107 -14
View File
@@ -1,4 +1,5 @@
import type {
DecryptListEntry,
Flow,
FlowSortKey,
GetProxyStateResponse,
@@ -6,16 +7,59 @@ import type {
} from "@yaakapp-internal/proxy-lib";
import { sortFlows } from "../lib/columns";
import { appIconPng, fixtures } from "./fixtures";
import { BUILT_IN_EXCLUSIONS, httpsFlows } from "./httpsFixtures";
const params = new URLSearchParams(location.search);
/**
* The decrypt list and the certificate as the harness holds them, from `?list=` (`none`,
* `host`, `everything`, `apps`) and `?ca=untrusted`. `?trust=cancel` cancels the password
* prompt; `?still=n` is how many open connections each change leaves encrypted.
*/
const https = {
list: initialList(params.get("list")),
trusted: params.get("ca") !== "untrusted",
passthrough: params.get("scene") === "mixed" ? ["sync.acme.dev:443"] : ([] as string[]),
};
function initialList(which: string | null): DecryptListEntry[] {
const own = (...entries: string[]) => entries.map((entry) => ({ entry, builtIn: false }));
switch (which) {
case "none":
return BUILT_IN_EXCLUSIONS;
case "host":
return [...own("host:www.google.com"), ...BUILT_IN_EXCLUSIONS];
case "everything":
return [...own("*"), ...BUILT_IN_EXCLUSIONS];
case "apps":
return [...own("app:Dia", "app:Google Chrome", "app:Acme Sync"), ...BUILT_IN_EXCLUSIONS];
case "excluded":
return [...own("*", "!host:*.slack.com"), ...BUILT_IN_EXCLUSIONS];
default:
return [
{ entry: "host:api.stripe.com", builtIn: false },
{ entry: "app:Google Chrome", builtIn: false },
{ entry: "!host:*.apple.com", builtIn: true },
];
}
}
/**
* Dev only: answers the engine's RPCs the harness pages need, from fixtures, so the card and
* settings render outside the app as they do inside it.
*/
export function installFixtureBackend() {
// `?firstrun`: setup not done yet, so the first-run card shows
if (params.has("firstrun")) localStorage.setItem("system_proxy_ever_on", "false");
else if (params.has("window")) localStorage.setItem("system_proxy_ever_on", "true");
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
.__TAURI_INTERNALS__;
// For checking the window as traffic arrives: `__harnessEmit("model_write", payload)`
(window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit;
// Unlistening, which the app does as its components unmount
(
window as unknown as { __TAURI_EVENT_PLUGIN_INTERNALS__: unknown }
).__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
internals.transformCallback = (callback: (event: unknown) => void) => {
const id = callbacks.size + 1;
callbacks.set(id, callback);
@@ -138,11 +182,16 @@ function matches(flow: Flow, query: string): boolean {
if (field === "host") return host.toLowerCase().includes(value.replaceAll("*", ""));
if (field === "method") return flow.method.toLowerCase() === value;
if (field === "status") return String(flow.status ?? "").startsWith(value.replace(/x+$/, ""));
if (field === "is" && value === "encrypted") return flow.tunnelled;
if (field === "is" && value === "rejected") return flow.tlsRejected != null;
return field == null ? text.includes(term) : true;
});
}
function allFlows(): Flow[] {
if (params.has("firstrun")) return [];
const scene = httpsFlows(params.get("scene"));
if (scene != null) return scene.map((f, i) => ({ ...f, number: i + 1 }));
const many = Number(new URLSearchParams(location.search).get("many") ?? 0);
return (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({
...f,
@@ -150,7 +199,15 @@ function allFlows(): Flow[] {
}));
}
function answer(cmd: string, payload: unknown): unknown {
function caStatus() {
return {
trusted: https.trusted,
installed: https.trusted,
certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem",
};
}
async function answer(cmd: string, payload: unknown): Promise<unknown> {
const req = (payload ?? {}) as Record<string, unknown>;
switch (cmd) {
case "get_flows": {
@@ -231,14 +288,55 @@ function answer(cmd: string, payload: unknown): unknown {
case "get_storage_size":
return { bytes: 412 * 1024 * 1024 };
case "ca_status":
return caStatus();
case "ca_trust": {
// A moment for the prompt, so the controls that asked show it
await new Promise((r) => setTimeout(r, Number(params.get("prompt") ?? 900)));
if (params.get("trust") === "cancel") return { ...caStatus(), cancelled: true };
https.trusted = true;
return { ...caStatus(), cancelled: false };
}
case "edit_decrypt_list": {
const { add = [], remove = [] } = req as { add?: string[]; remove?: string[] };
const own = https.list.filter((e) => !e.builtIn && !remove.includes(e.entry));
for (const entry of add) {
if (!own.some((e) => e.entry === entry)) own.push({ entry, builtIn: false });
}
const builtIns = BUILT_IN_EXCLUSIONS.filter(
(e) => https.list.some((l) => l.entry === e.entry) && !remove.includes(e.entry),
);
https.list = [...own, ...builtIns];
emit("decrypt_list_changed", { list: { entries: https.list } });
const decrypts = add.some((e) => !e.startsWith("!")) || remove.some((e) => e.startsWith("!"));
return {
trusted: true,
installed: true,
certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem",
entries: https.list,
stillEncrypted: decrypts ? Number(params.get("still") ?? 3) : 0,
};
}
case "list_passthrough":
return { addresses: https.passthrough };
case "forget_passthrough": {
const address = String(req.address);
const forgotten = https.passthrough.includes(address);
https.passthrough = https.passthrough.filter((a) => a !== address);
emit("passthrough_changed", { addresses: https.passthrough });
return { forgotten, addresses: https.passthrough };
}
case "reconnect_tunnels":
return { closed: 1 };
case "list_reconnectable_tunnels":
return Array.from({ length: Number(params.get("still") ?? 3) }, (_, i) => ({
id: `harness_${i}`,
host: "www.google.com",
port: 443,
appName: "Dia",
appBundleId: null,
flowId: null,
reason: "not_listed",
}));
case "system_proxy_status":
return {
enabled: true,
enabled: !params.has("firstrun"),
port: 9090,
services: [],
backend: "helper",
@@ -246,13 +344,7 @@ function answer(cmd: string, payload: unknown): unknown {
supported: true,
};
case "get_decrypt_list":
return {
entries: [
{ entry: "host:api.stripe.com", builtIn: false },
{ entry: "app:Google Chrome", builtIn: false },
{ entry: "!host:*.apple.com", builtIn: true },
],
};
return { entries: https.list };
case "list_actions":
// As the engine lists them on macOS
return {
@@ -270,7 +362,8 @@ function answer(cmd: string, payload: unknown): unknown {
case "list_flows": {
const all = allFlows();
const query = typeof req.query === "string" ? req.query : "";
const matching = all.filter((f) => matches(f, query));
const hideEncrypted = req.hideEncrypted === true && !query.includes("@is:encrypted");
const matching = all.filter((f) => matches(f, query) && !(hideEncrypted && f.tunnelled));
// In a sort's order, as the database lists it, all in one page
const sort = (req.sort ?? []) as FlowSortKey[];
const flows = sort.length > 0 ? sortFlows(matching, sort) : matching.reverse();
@@ -278,7 +371,7 @@ function answer(cmd: string, payload: unknown): unknown {
return {
flows: page,
total: all.length,
matching: query === "" ? null : matching.length,
matching: query === "" && !hideEncrypted ? null : matching.length,
cursor: null,
};
}
+7
View File
@@ -52,6 +52,12 @@ const base: Flow = {
grpcMessage: null,
graphqlOperation: null,
tunnelled: false,
tunnelReason: null,
tunnelExcludedBy: null,
tunnelExcludedBuiltIn: false,
tunnelRejectedBy: null,
reconnectRequested: false,
tlsRejected: null,
agentRead: false,
clientAddr: "127.0.0.1:54321",
elapsedConnectMs: 18,
@@ -209,6 +215,7 @@ export const fixtures: FlowFixture[] = [
requestSize: 3_412,
responseSize: 48_210,
tunnelled: true,
tunnelReason: "not_listed",
},
requestBody: body(""),
responseBody: body(""),
+179
View File
@@ -0,0 +1,179 @@
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
import { fixtures } from "./fixtures";
/**
* Dev only: traffic for HTTPS decryption's states, by `?scene=`: `encrypted`, the first minute
* with nothing decrypted, and `mixed`, some decrypted, a refused handshake and the tunnel passed
* through after it.
*/
export type HttpsScene = "encrypted" | "mixed";
export const BUILT_IN_EXCLUSIONS: DecryptListEntry[] = [
{ entry: "!host:*.apple.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.icloud.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.mzstatic.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.1password.com", builtIn: true, kind: "pinned" },
{ entry: "!host:*.dropbox.com", builtIn: true, kind: "pinned" },
];
const template = fixtures[0]?.flow as Flow;
const start = Date.parse("2026-11-09T09:00:00Z");
const apps = {
dia: {
clientName: "Dia",
clientBundleId: "company.thebrowser.dia",
clientPath: "/Applications/Dia.app",
},
chrome: {
clientName: "Google Chrome",
clientBundleId: "com.google.Chrome",
clientPath: "/Applications/Google Chrome.app",
},
slack: {
clientName: "Slack",
clientBundleId: "com.tinyspeck.slackmacgap",
clientPath: "/Applications/Slack.app",
},
onePassword: {
clientName: "1Password",
clientBundleId: "com.1password.1password",
clientPath: "/Applications/1Password.app",
},
headless: {
clientName: "chrome-headless-shell",
clientBundleId: null,
clientPath:
"/Users/you/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell",
},
acme: {
clientName: "Acme Sync",
clientBundleId: "dev.acme.sync",
clientPath: "/Applications/Acme Sync.app",
},
cloudd: {
clientName: "cloudd",
clientBundleId: null,
clientPath: "/System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd",
},
} as const;
type App = (typeof apps)[keyof typeof apps];
function tunnel(i: number, host: string, app: App, over: Partial<Flow> = {}): Flow {
return {
...template,
...app,
id: `fl_https_${i}`,
method: "CONNECT",
url: `${host}:443`,
startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""),
status: null,
contentType: null,
requestHeaders: [],
responseHeaders: [],
elapsedHeadersMs: null,
elapsedConnectMs: null,
elapsedTlsMs: null,
elapsedRequestMs: null,
elapsedMs: 120 + ((i * 97) % 900),
clientHttpVersion: "HTTP/1.1",
upstreamHttpVersion: null,
upstreamTlsVersion: null,
upstreamTlsCipher: null,
requestSize: 600 + ((i * 331) % 3000),
responseSize: 1_200 + ((i * 7919) % 120_000),
tunnelled: true,
tunnelReason: "not_listed",
...over,
// An open tunnel has been open a little while, counting up from now
...(over.elapsedMs === null
? { startedAt: new Date(Date.now() - 22_000 - i * 1000).toISOString().replace("Z", "") }
: {}),
};
}
function decrypted(i: number, source: number, url: string, app: App): Flow {
const flow = fixtures[source]?.flow ?? template;
return {
...flow,
...app,
// The fixture's id first, so the backend finds its bodies
id: `${flow.id}~https_${i}`,
url,
startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""),
};
}
const builtInExcluded = {
tunnelReason: "excluded" as const,
tunnelExcludedBy: "!host:*.1password.com",
tunnelExcludedBuiltIn: true,
};
/** The first minute: every HTTPS connection encrypted, two still open */
function encryptedScene(): Flow[] {
return [
tunnel(1, "clientstream.launchdarkly.com", apps.dia, { elapsedMs: null }),
tunnel(2, "browser-intake-datadoghq.com", apps.dia),
tunnel(3, "api.typefully.com", apps.dia),
tunnel(4, "js.stripe.com", apps.dia),
tunnel(5, "edgeapi.slack.com", apps.slack),
tunnel(6, "wss-primary.slack.com", apps.slack, { elapsedMs: null }),
tunnel(7, "gateway.icloud.com", apps.cloudd, {
tunnelReason: "excluded",
tunnelExcludedBy: "!host:*.icloud.com",
tunnelExcludedBuiltIn: true,
}),
tunnel(8, "my.1password.com", apps.onePassword, builtInExcluded),
tunnel(9, "www.google.com", apps.dia),
tunnel(10, "fonts.gstatic.com", apps.dia),
tunnel(11, "clients2.google.com", apps.chrome),
tunnel(12, "home.taila714c.ts.net:8317", apps.dia, { url: "home.taila714c.ts.net:8317" }),
tunnel(13, "optimizationguide-pa.googleapis.com", apps.headless),
];
}
/**
* Some decrypted, a refused handshake from an app that pins, and the tunnel passed through
* after it
*/
function mixedScene(): Flow[] {
return [
tunnel(1, "my.1password.com", apps.onePassword, builtInExcluded),
decrypted(2, 0, "https://api.stripe.com/v1/payment_intents", apps.dia),
decrypted(3, 5, "https://api.github.com/graphql", apps.chrome),
tunnel(4, "edgeapi.slack.com", apps.slack),
decrypted(5, 2, "https://cdn.example.com/avatars/42.png?size=2", apps.dia),
tunnel(6, "gateway.icloud.com", apps.cloudd, {
tunnelReason: "excluded",
tunnelExcludedBy: "!host:*.icloud.com",
tunnelExcludedBuiltIn: true,
}),
{
...tunnel(7, "sync.acme.dev", apps.acme),
method: "",
url: "https://sync.acme.dev",
tunnelled: false,
tunnelReason: null,
tlsRejected: "bad_certificate",
error: "Certificate rejected by the app",
elapsedMs: 6,
requestSize: 517,
responseSize: 0,
},
tunnel(8, "sync.acme.dev", apps.acme, {
tunnelReason: "rejected",
tunnelRejectedBy: "fl_https_7",
elapsedMs: null,
}),
decrypted(9, 0, "https://api.typefully.com/v1/drafts", apps.dia),
tunnel(10, "www.google.com", apps.dia, { elapsedMs: null }),
];
}
export function httpsFlows(scene: string | null): Flow[] | null {
if (scene === "encrypted") return encryptedScene();
if (scene === "mixed") return mixedScene();
return null;
}
@@ -51,6 +51,12 @@ const flowBase: Flow = {
grpcMessage: null,
graphqlOperation: null,
tunnelled: false,
tunnelReason: null,
tunnelExcludedBy: null,
tunnelExcludedBuiltIn: false,
tunnelRejectedBy: null,
reconnectRequested: false,
tlsRejected: null,
agentRead: false,
clientAddr: null,
elapsedConnectMs: null,
+202
View File
@@ -0,0 +1,202 @@
import { useIsMutating, useQueryClient } from "@tanstack/react-query";
import type { CaStatus, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
import { Button } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { inverseEdit } from "../lib/decrypt";
import {
applyChoice,
choiceToast,
type DecryptChoice,
type ListEdit,
reconnectEntries,
} from "../lib/decryptChoice";
import { rpc } from "../lib/rpc";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { announceEngineChange } from "../lib/windowSync";
import { useRpcEvent } from "./useRpcEvent";
import { useRpcMutation } from "./useRpcMutation";
import { useRpcQuery } from "./useRpcQuery";
const CA_KEY = ["ca_status", {}];
const LIST_KEY = ["get_decrypt_list", {}];
const PASSTHROUGH_KEY = ["list_passthrough", {}];
const TRUST_KEY = ["ca_trust"];
/** The app trusts its certificate itself only on macOS; elsewhere it is installed by hand */
export function canTrustHere(): boolean {
return getOsType() === "macos";
}
/** Whether macOS's password prompt is up, from any control that asked for it */
export function useTrusting(): boolean {
return useIsMutating({ mutationKey: TRUST_KEY }) > 0;
}
/** Asks macOS to trust the certificate, the status read from its answer */
export function useTrustCertificate() {
const queryClient = useQueryClient();
const showToast = useShowToast();
return useRpcMutation("ca_trust", {
mutationKey: TRUST_KEY,
onSuccess: ({ trusted, installed, certPath }) =>
queryClient.setQueryData<CaStatus>(CA_KEY, { trusted, installed, certPath }),
// A cancelled prompt isn't an error, and says nothing; a failure says why.
onError: (err) =>
showToast({
id: "decrypt",
color: "danger",
message: `Failed to trust certificate: ${err.message}`,
}),
});
}
/**
* Every Decrypt and Stop Decrypting in the app: trusts the certificate first when the change can
* decrypt something and it isn't yet (macOS's password prompt), then writes the change, then a
* toast with Undo, and with Reconnect while open connections the list now decrypts stay
* encrypted. A cancelled prompt changes nothing and says nothing.
*/
export function useDecrypt() {
const queryClient = useQueryClient();
const showToast = useShowToast();
const trusting = useTrusting();
const { mutateAsync: trust } = useTrustCertificate();
const run = useCallback(
async (edit: ListEdit) => {
try {
return await applyChoice(
{
canTrust: canTrustHere(),
readTrusted: async () => {
const status = await rpc("ca_status", {});
queryClient.setQueryData<CaStatus>(CA_KEY, status);
return status.trusted;
},
trust: () => trust({}),
save: async (edit) => {
try {
return await rpc("edit_decrypt_list", edit);
} finally {
// Read again rather than taken from the answer, which can land after a newer
// change's event and would put the older list back.
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
announceEngineChange();
}
},
},
edit,
);
} catch (err) {
showToast({
id: "decrypt",
color: "danger",
message: err instanceof Error ? err.message : String(err),
});
return null;
}
},
[queryClient, showToast, trust],
);
const reconnect = useCallback(
(entries: string[]) => {
for (const entry of entries) {
rpc("reconnect_tunnels", { entry }).catch((err) =>
showToast({ id: "reconnect", color: "danger", message: String(err) }),
);
}
},
[showToast],
);
/** Applies a choice; true once it is written */
const apply = useCallback(
async (choice: DecryptChoice): Promise<boolean> => {
const result = await run(choice.edit);
if (result == null || result.status === "cancelled") return false;
if (result.status === "untrusted") {
showToast({
id: "decrypt",
color: "warning",
message: "Certificate not trusted",
});
return false;
}
// Counted within what Reconnect closes, not every open connection the list now decrypts
const scoped =
result.stillEncrypted > 0 ? await countReconnectable(reconnectEntries(choice.edit)) : 0;
const toast = choiceToast(choice, scoped);
showToast({
id: "decrypt",
message: (
<div>
<div>{toast.message}</div>
{toast.sub != null && (
<div className="text-xs text-text-subtle mt-0.5">{toast.sub}</div>
)}
</div>
),
action: ({ hide }) => (
<div className="flex gap-2">
<Button
size="xs"
variant="border"
onClick={() => {
hide();
void run(inverseEdit(choice.edit));
}}
>
Undo
</Button>
{toast.reconnect.length > 0 && (
<Button
size="xs"
variant="border"
onClick={() => {
hide();
reconnect(toast.reconnect);
}}
>
Reconnect
</Button>
)}
</div>
),
});
return true;
},
[run, reconnect, showToast],
);
return { apply, trusting };
}
/** Open tunnels the entries name that the list now decrypts, each counted once */
async function countReconnectable(entries: string[]): Promise<number> {
const ids = new Set<string>();
for (const entry of entries) {
try {
for (const t of await rpc("list_reconnectable_tunnels", { entry })) ids.add(t.id);
} catch (err) {
console.warn("Failed to list reconnectable tunnels", err);
}
}
return ids.size;
}
/**
* The addresses passed through after clients rejected the certificate, kept current as the
* engine adds and forgets them. Null until read.
*/
export function usePassthrough(): string[] | null {
const queryClient = useQueryClient();
const onChanged = useCallback(
(payload: PassthroughResponse) =>
queryClient.setQueryData<PassthroughResponse>(PASSTHROUGH_KEY, payload),
[queryClient],
);
useRpcEvent("passthrough_changed", onChanged);
return useRpcQuery("list_passthrough", {}).data?.addresses ?? null;
}
-18
View File
@@ -5,9 +5,7 @@ import type {
DecryptListEntry,
} from "@yaakapp-internal/proxy-lib";
import { useCallback } from "react";
import { useShowToast } from "../lib/toast";
import { useRpcEvent } from "./useRpcEvent";
import { useRpcMutation } from "./useRpcMutation";
import { useRpcQuery } from "./useRpcQuery";
const KEY = ["get_decrypt_list", {}];
@@ -27,19 +25,3 @@ export function useDecryptList(): DecryptListEntry[] {
useRpcEvent("decrypt_list_changed", onChanged);
return useRpcQuery("get_decrypt_list", {}).data?.entries ?? EMPTY;
}
/**
* Adds and removes entries, `edit_decrypt_list` applying them to the list as it is when they
* land, so two changes made in quick succession both stick. Everything that reads the list
* then reads it again. A failure, such as an entry that does not parse, is a toast.
*/
export function useEditDecryptList() {
const queryClient = useQueryClient();
const showToast = useShowToast();
return useRpcMutation("edit_decrypt_list", {
// Read again rather than taken from the answer, which can land after a newer change's
// event and would put the older list back.
onSuccess: () => queryClient.invalidateQueries({ queryKey: KEY }),
onError: (e) => showToast({ id: "decrypt-list", color: "danger", message: e.message }),
});
}
+26 -1
View File
@@ -2,6 +2,7 @@ import { QueryClient, QueryObserver } from "@tanstack/react-query";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test, vi } from "vite-plus/test";
import {
bodyTypeBadge,
bodyAsText,
bodyFileName,
bodyImageType,
@@ -230,7 +231,7 @@ describe("flowBodyQueryOptions", () => {
describe("a body that isn't there", () => {
test("a WebSocket's note reaches the viewer, which points at its messages", () => {
const note = "A WebSocket has no body after its 101 response. Its frames are its messages.";
const note = "A WebSocket has no body";
const body = viewerBody({ data: "", truncated: false, storedSize: 0, note });
expect(body.note).toBe(note);
expect(emptyBodyText("response", body.note)).toBe("No body. See Messages.");
@@ -243,3 +244,27 @@ describe("a body that isn't there", () => {
expect(emptyBodyText("request", body.note)).toBe("No request body");
});
});
describe("bodyTypeBadge", () => {
test("names a body by its type, as short as the Type column", () => {
expect(bodyTypeBadge("application/json; charset=utf-8", 10)).toBe("JSON");
expect(bodyTypeBadge("application/problem+json", 10)).toBe("JSON");
expect(bodyTypeBadge("text/xml", 10)).toBe("XML");
expect(bodyTypeBadge("text/html", 10)).toBe("HTML");
expect(bodyTypeBadge("text/csv", 10)).toBe("CSV");
expect(bodyTypeBadge("application/x-www-form-urlencoded", 10)).toBe("Form");
expect(bodyTypeBadge("multipart/form-data; boundary=x", 10)).toBe("Multipart");
expect(bodyTypeBadge("image/png", 10)).toBe("Image");
expect(bodyTypeBadge("image/svg+xml", 10)).toBe("SVG");
expect(bodyTypeBadge("text/plain", 10)).toBe("Text");
expect(bodyTypeBadge("application/grpc", 10)).toBe("Protobuf");
expect(bodyTypeBadge("application/octet-stream", 10)).toBe("Binary");
expect(bodyTypeBadge(null, 10)).toBe("Binary");
});
test("GraphQL when the request is an operation, and nothing without a body", () => {
expect(bodyTypeBadge("application/json", 10, true)).toBe("GraphQL");
expect(bodyTypeBadge("application/json", 0)).toBeNull();
expect(bodyTypeBadge(null, 0, true)).toBeNull();
});
});
+39
View File
@@ -208,6 +208,45 @@ export function shortContentType(contentType: string | null): string | null {
return suffix.replace(/^x-/, "");
}
/**
* The short name a body tab's badge gives a body's type, as the Type column and the Pretty and
* Raw switch read it: JSON, XML, HTML, CSV, Form, Multipart, GraphQL, Image, Binary and so on.
* Null when there is no body.
*/
export function bodyTypeBadge(
contentType: string | null,
size: number,
graphql = false,
): string | null {
if (size <= 0) return null;
if (graphql) return "GraphQL";
const essence = mimeEssence(contentType);
if (essence == null) return "Binary";
const [type = "", subtype = ""] = essence.split("/");
const suffix = subtype.split("+").pop() ?? subtype;
if (type === "multipart") return "Multipart";
if (essence === "application/x-www-form-urlencoded") return "Form";
if (essence === "application/graphql" || suffix === "graphql") return "GraphQL";
if (essence === "image/svg+xml") return "SVG";
if (type === "image") return "Image";
if (type === "audio") return "Audio";
if (type === "video") return "Video";
if (type === "font") return "Font";
if (suffix === "json" || suffix === "x-ndjson" || suffix === "ndjson") return "JSON";
if (suffix === "xml") return "XML";
if (suffix === "html" || suffix === "xhtml") return "HTML";
if (suffix === "csv") return "CSV";
if (suffix === "tab-separated-values") return "TSV";
if (suffix === "javascript" || suffix === "ecmascript") return "JS";
if (suffix === "css") return "CSS";
if (suffix === "markdown") return "Markdown";
if (suffix === "event-stream") return "SSE";
if (suffix === "pdf") return "PDF";
if (/grpc|protobuf|proto/.test(suffix)) return "Protobuf";
if (type === "text") return "Text";
return "Binary";
}
/** Extensions for media types whose subtype isn't one */
const EXTENSIONS: Record<string, string> = {
"application/javascript": "js",
+74 -2
View File
@@ -1,11 +1,17 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowSortKey } from "@yaakapp-internal/proxy-lib";
// The formatter alone: the package index reaches for the window as it loads
import { formatMillis } from "../../../packages/ui/src/components/tags/HttpResponseDurationTag";
import { describe, expect, test } from "vite-plus/test";
import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtures/custom_column_sort.json";
import {
addCustomColumn,
addSortKey,
allColumns,
BASE_FONT_SIZE,
CHAR_REM,
cellFilterTerm,
columnDef,
columnShifts,
type ColumnPrefs,
customPath,
@@ -20,6 +26,7 @@ import {
hideOtherColumns,
moveColumn,
moveColumnBy,
NUMERIC_FIT_CHARS,
nextSort,
operationOf,
parseColumnPrefs,
@@ -28,6 +35,7 @@ import {
resetWidth,
resetWidths,
resizeColumn,
resizeDistance,
showOperationFor,
shownColumns,
sortFlows,
@@ -35,6 +43,7 @@ import {
toggleSortKey,
urlParts,
} from "./columns";
import { DEFAULT_SETTINGS_VALUES } from "./settings";
function flow(number: number, extra: Partial<Flow> = {}): Flow {
return {
@@ -109,10 +118,73 @@ describe("shownColumns", () => {
prefs = resizeColumn(prefs, "path", 500);
const columns = shownColumns(prefs);
const pad = (w: number) => `calc(${w}px + 0.75rem)`;
// Size and Time, in pixels at the default interface size of 15, grow with it
const size = "calc(4.8rem + 0.75rem)";
const time = `calc(${64 / 15}rem + 0.75rem)`;
expect(gridTemplate(columns)).toBe(
["24px", ...[46, 20, 50, 240, 500, 120, 46, 50, 72, 64].map(pad)].join(" "),
["24px", ...[46, 20, 50, 240, 500, 120, 46, 50].map(pad), size, time].join(" "),
);
expect(gridWidth(columns)).toBe("calc(1232px + 10 * 0.75rem)");
expect(gridWidth(columns)).toBe(`calc(1096px + 4.8rem + ${64 / 15}rem + 10 * 0.75rem)`);
// Resized, too, and at its least width it holds its widest value: eight characters of 0.5rem
const resized = shownColumns(resizeColumn(prefs, "size", 10));
expect(gridTemplate(resized)).toContain(" calc(4rem + 0.75rem)");
});
});
describe("numeric column widths", () => {
// Every format the cells show: each unit's widest, and around each rounding edge
const sizes = [0, 9, 999, 1000, 1001, 1049, 99_949, 99_951, 999_949, 999_999, 1_000_001];
const giga = 999_949_000_000;
const durations = [0, 999, 1000, 9_949, 9_999, 59_499, 59_999, 60_000, 3_599_499, 59_999_999];
const numeric = ["size", "requestSize", "time", "latency"] as const;
test("the formats' widest values are as many characters as each numeric column holds", () => {
const widest = (values: string[]) => Math.max(...values.map((v) => v.length));
const sizeChars = widest([...sizes, giga].map(formatSize));
const timeChars = widest(durations.map(formatMillis));
expect(sizeChars).toBe(8);
expect(timeChars).toBe(8);
for (const id of numeric) {
const def = columnDef(id);
const chars = id === "size" || id === "requestSize" ? sizeChars : timeChars;
expect(NUMERIC_FIT_CHARS).toBeGreaterThanOrEqual(chars);
expect(def.scales).toBe(true);
// In pixels at the default interface size, as the row's font sets them
const fit = NUMERIC_FIT_CHARS * CHAR_REM * BASE_FONT_SIZE;
expect(def.minWidth).toBeGreaterThanOrEqual(fit);
expect(def.width).toBeGreaterThanOrEqual(fit);
}
});
test("the room for a character fits JetBrains Mono at text-xs", () => {
// A monospace advance of 0.6em, at 0.8rem
expect(CHAR_REM).toBeGreaterThanOrEqual(0.6 * 0.8);
expect(BASE_FONT_SIZE).toBe(DEFAULT_SETTINGS_VALUES.interfaceFontSize);
});
test("defaults fit each label sorted, with the sort's arrow and place", () => {
// Measured in the harness at the default interface size, sorted by four columns
const header = { size: 46, requestSize: 96, time: 50, latency: 67 };
for (const id of numeric) expect(columnDef(id).width).toBeGreaterThanOrEqual(header[id]);
});
test("a resize moves a numeric column's edge with the pointer at any interface size", () => {
const size = columnDef("size");
// At 30, twice the default, a 20px drag is 10 of the pixels its width is kept in
expect(resizeDistance(size, 20, 30)).toBe(10);
expect(resizeDistance(size, -12, 12)).toBe(-15);
expect(resizeDistance(columnDef("host"), 20, 30)).toBe(20);
});
test("a stored width narrower than the least is raised, and a wider one kept", () => {
const parsed = parseColumnPrefs({ widths: { size: 44, time: 50, requestSize: 120, host: 20 } });
expect(parsed.widths).toEqual({ size: 60, time: 60, requestSize: 120, host: 60 });
expect(shownColumns(parsed).find((c) => c.id === "size")?.current).toBe(60);
expect(resizeColumn(defaultColumnPrefs, "latency", 10).widths).toEqual({ latency: 60 });
// Reset, the defaults are back
const reset = shownColumns(resetWidths(parsed)).find((c) => c.id === "size");
expect(reset?.current).toBe(72);
expect(resetWidth(parsed, "time").widths).toEqual({ size: 60, requestSize: 120, host: 60 });
});
});
+54 -9
View File
@@ -23,6 +23,11 @@ export interface ColumnDef {
defaultVisible: boolean;
/** The header or query parameter a custom column shows: `header.x-request-id` */
path?: string;
/**
* A numeric column's: its width, resized or not, grows with the interface size, as its values
* do, so the least width always holds the widest value
*/
scales?: boolean;
}
const builtin = (
@@ -32,7 +37,29 @@ const builtin = (
minWidth: number,
align: "left" | "right",
defaultVisible: boolean,
): ColumnDef => ({ id, label, width, minWidth, align, defaultVisible });
scales?: boolean,
): ColumnDef => ({ id, label, width, minWidth, align, defaultVisible, scales });
/**
* The widest size and duration a numeric cell shows, in characters: `999.9 KB` and `999m 59s`,
* from `formatSize` and `formatMillis`
*/
export const NUMERIC_FIT_CHARS = 8;
/**
* Room for one character of a numeric cell, `font-mono text-xs`: 0.8rem, and a monospace advance
* of 0.6em, JetBrains Mono's and most others', with a little over for wider editor fonts
*/
export const CHAR_REM = 0.5;
/** The default interface font size, `DEFAULT_SETTINGS_VALUES.interfaceFontSize`, widths are at */
export const BASE_FONT_SIZE = 15;
/**
* A numeric column's least width, in pixels at the default interface size, which grow with it:
* its widest value
*/
const NUMERIC_MIN = NUMERIC_FIT_CHARS * CHAR_REM * BASE_FONT_SIZE;
export const COLUMNS: ColumnDef[] = [
builtin("number", "#", 46, 30, "left", true),
@@ -43,10 +70,11 @@ export const COLUMNS: ColumnDef[] = [
builtin("query", "Query", 120, 40, "left", true),
builtin("status", "Status", 46, 34, "left", true),
builtin("type", "Type", 50, 34, "left", true),
builtin("requestSize", "Request Size", 80, 44, "right", false),
builtin("size", "Size", 72, 44, "right", true),
builtin("time", "Time", 64, 44, "right", true),
builtin("latency", "Latency", 64, 44, "right", false),
// Each default fits its label sorted, with the sort's arrow and place, and its widest value
builtin("requestSize", "Request Size", 96, NUMERIC_MIN, "right", false, true),
builtin("size", "Size", 72, NUMERIC_MIN, "right", true, true),
builtin("time", "Time", 64, NUMERIC_MIN, "right", true, true),
builtin("latency", "Latency", 68, NUMERIC_MIN, "right", false, true),
builtin("started", "Started", 92, 60, "left", false),
builtin("remote", "Remote Address", 150, 60, "left", false),
builtin("version", "HTTP Version", 70, 44, "left", false),
@@ -211,16 +239,30 @@ export function columnWidth(prefs: ColumnPrefs, id: ColumnId): number {
return Math.max(def.minWidth, Math.round(prefs.widths[id] ?? def.width));
}
/** A column's content width, as CSS: a numeric column's grows with the interface size */
function columnTrack(c: Column): string {
return c.scales ? `${c.current / BASE_FONT_SIZE}rem` : `${c.current}px`;
}
/**
* How far a resize moved, in the pixels a column's width is kept in: a numeric column's are at
* the default interface size, `rootFontSize` being the size now
*/
export function resizeDistance(c: ColumnDef, dx: number, rootFontSize: number): number {
return c.scales && rootFontSize > 0 ? (dx * BASE_FONT_SIZE) / rootFontSize : dx;
}
/** The grid template for the header and every row: the mark, then each column and its padding */
export function gridTemplate(columns: Column[]): string {
const cells = columns.map((c) => `calc(${c.current}px + ${CELL_PADDING})`);
const cells = columns.map((c) => `calc(${columnTrack(c)} + ${CELL_PADDING})`);
return [`${MARK_WIDTH}px`, ...cells].join(" ");
}
/** The width of a row's grid, as CSS: the list scrolls sideways when it is wider than the list */
export function gridWidth(columns: Column[]): string {
const px = columns.reduce((sum, c) => sum + c.current, MARK_WIDTH);
return `calc(${px}px + ${columns.length} * ${CELL_PADDING})`;
const px = columns.filter((c) => !c.scales).reduce((sum, c) => sum + c.current, MARK_WIDTH);
const scaled = columns.filter((c) => c.scales).map((c) => ` + ${columnTrack(c)}`);
return `calc(${px}px${scaled.join("")} + ${columns.length} * ${CELL_PADDING})`;
}
/** Whether the picker may uncheck `id`: any column but the last one shown */
@@ -406,7 +448,10 @@ export function parseColumnPrefs(value: unknown): ColumnPrefs {
const v = (visible as Record<string, unknown> | undefined)?.[def.id];
if (typeof v === "boolean") prefs.visible[def.id] = v;
const w = (widths as Record<string, unknown> | undefined)?.[def.id];
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
// Narrower than the column's least width, as a numeric column's was before it grew, rises to it
if (typeof w === "number" && Number.isFinite(w) && w > 0) {
prefs.widths[def.id] = Math.max(def.minWidth, w);
}
}
// Columns no longer there are dropped; those not listed follow the rest in their default order
if (Array.isArray(order)) {
+48 -5
View File
@@ -11,8 +11,12 @@ import {
decryptMenuAction,
decrypts,
decryptsEverything,
editDecrypts,
entryLabel,
entrySubject,
everythingEdit,
inverseEdit,
listSummary,
isDecrypted,
listEdit,
normalizeEntry,
@@ -170,15 +174,31 @@ describe("the sidebar menu", () => {
const a = { kind: "host", host: "a.com" } as const;
const b = { kind: "host", host: "b.com" } as const;
test("offers Decrypt unless every selected row is decrypted", () => {
test("offers Decrypt unless every selected row is decrypted, naming what it changes", () => {
expect(decryptMenuAction(user("host:a.com"), [a])).toEqual({
label: "Stop Decrypting",
label: "Stop Decrypting a.com",
on: false,
subject: "a.com",
});
expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({
label: "Decrypt 2 Hosts",
on: true,
subject: "2 hosts",
});
expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({ label: "Decrypt", on: true });
expect(decryptMenuAction([], [])).toBeNull();
});
test("names sites, apps and mixed rows", () => {
const site = { kind: "site", site: "google.com" } as const;
const dia = { kind: "app", name: "Dia", bundleId: null } as const;
expect(decryptMenuAction([], [site])?.label).toBe("Decrypt *.google.com");
expect(decryptMenuAction([], [dia])?.label).toBe("Decrypt Dia");
expect(decryptMenuAction([], [site, { kind: "site", site: "x.com" }])?.label).toBe(
"Decrypt 2 Domains",
);
expect(decryptMenuAction([], [dia, a, site])?.label).toBe("Decrypt 3 Selected");
});
test("applies to every selected row, leaving those already so", () => {
const next = setDecrypted(user("host:a.com"), [a, b], true).map((e) => e.entry);
expect(next).toEqual(["host:a.com", "host:b.com"]);
@@ -225,8 +245,8 @@ describe("the Not decrypted detail's buttons", () => {
});
});
describe("the first-run step", () => {
test("Everything adds *, and Pick as they appear takes it out", () => {
describe("Decrypt Everything", () => {
test("adds *, and the switch off takes it out", () => {
expect(decryptsEverything(BUILT_INS)).toBe(false);
expect(decryptsEverything([...user("*"), ...BUILT_INS])).toBe(true);
expect(everythingEdit(true)).toEqual({ add: ["*"], remove: [] });
@@ -303,3 +323,26 @@ describe("the list's autocomplete", () => {
});
});
});
describe("what a change says", () => {
test("the pill counts the person's own positive entries", () => {
expect(listSummary([...user("app:Dia", "app:Slack", "host:*.google.com"), ...BUILT_INS])).toBe(
"2 apps, 1 host",
);
expect(listSummary([...user("*", "!host:a.com"), ...BUILT_INS])).toBeNull();
});
test("names an entry's subject", () => {
expect(entrySubject("host:www.google.com")).toBe("www.google.com");
expect(entrySubject("host:*.google.com")).toBe("*.google.com");
expect(entrySubject("app:Dia")).toBe("Dia");
expect(entrySubject("*")).toBe("Everything");
});
test("an edit can decrypt when it adds a positive entry or drops an exclusion", () => {
expect(editDecrypts({ add: ["host:a.com"], remove: [] })).toBe(true);
expect(editDecrypts({ add: [], remove: ["!host:a.com"] })).toBe(true);
expect(editDecrypts({ add: ["!app:Dia"], remove: ["host:a.com"] })).toBe(false);
expect(inverseEdit({ add: ["a"], remove: ["b"] })).toEqual({ add: ["b"], remove: ["a"] });
});
});
+90 -4
View File
@@ -163,6 +163,12 @@ function matches(e: ParsedEntry, q: DecryptQuery): boolean {
}
}
/** Whether one entry, as stored, names `q`, whichever way it points */
export function entryMatches(entry: string, q: DecryptQuery): boolean {
const parsed = parseEntry(entry);
return parsed != null && matches(parsed, q);
}
function specificity(e: ParsedEntry): number {
if (e.kind === "everything") return 0;
if (e.kind === "app") return Number.POSITIVE_INFINITY;
@@ -193,6 +199,52 @@ export function decryptsAnything(list: DecryptListEntry[]): boolean {
return list.some((e) => !e.entry.trim().startsWith("!"));
}
/** What a list entry is called in a button: its host or pattern, its app, or Everything */
export function entrySubject(entry: string): string {
const parsed = parseEntry(entry);
if (parsed == null) return entry;
if (parsed.kind === "everything") return "Everything";
if (parsed.kind === "app") return parsed.pattern;
return formatEntry({ ...parsed, negated: false }).slice("host:".length);
}
/**
* The person's own positive entries, counted for the pill: `2 apps, 1 host`, sites counted as
* hosts, `*` left out as the switch beside it shows it. Null when there are none.
*/
export function listSummary(list: DecryptListEntry[]): string | null {
let apps = 0;
let hosts = 0;
for (const e of list) {
if (e.builtIn) continue;
const parsed = parseEntry(e.entry);
if (parsed == null || parsed.negated) continue;
if (parsed.kind === "app") apps++;
if (parsed.kind === "host") hosts++;
}
const parts = [
apps > 0 ? `${apps} ${apps === 1 ? "app" : "apps"}` : null,
hosts > 0 ? `${hosts} ${hosts === 1 ? "host" : "hosts"}` : null,
].filter((p) => p != null);
return parts.length === 0 ? null : parts.join(", ");
}
/** Whether an edit can make something decrypt: it adds a positive entry or drops an exclusion */
export function editDecrypts(edit: { add: string[]; remove: string[] }): boolean {
return (
edit.add.some((e) => !e.trim().startsWith("!")) ||
edit.remove.some((e) => e.trim().startsWith("!"))
);
}
/** The edit that puts back what `edit` changed, for Undo */
export function inverseEdit(edit: { add: string[]; remove: string[] }): {
add: string[];
remove: string[];
} {
return { add: edit.remove, remove: edit.add };
}
/** What a sidebar row or the detail's buttons decrypt: a host, a site, or an app */
export type DecryptSubject =
| { kind: "host"; host: string }
@@ -335,10 +387,44 @@ export function setDecrypted(
export function decryptMenuAction(
list: DecryptListEntry[],
subjects: DecryptSubject[],
): { label: "Decrypt" | "Stop Decrypting"; on: boolean } | null {
): { label: string; on: boolean; subject: string } | null {
if (subjects.length === 0) return null;
const on = !subjects.every((s) => isDecrypted(list, s));
return { label: on ? "Decrypt" : "Stop Decrypting", on };
const subject = subjectsName(subjects);
return {
label: `${on ? "Decrypt" : "Stop Decrypting"} ${subject.title}`,
on,
subject: subject.text,
};
}
/** What a subject is called: its host, `*.site`, or its app */
export function subjectName(subject: DecryptSubject): string {
switch (subject.kind) {
case "host":
return subject.host;
case "site":
return `*.${subject.site}`;
case "app":
return subject.name;
}
}
/**
* Several subjects named together: one by its name, several of a kind counted, `3 Domains` in a
* menu and `3 domains` in a toast, and mixed kinds as `3 Selected`
*/
function subjectsName(subjects: DecryptSubject[]): { title: string; text: string } {
const [first] = subjects;
if (subjects.length === 1 && first != null) {
const name = subjectName(first);
return { title: name, text: name };
}
const kinds = new Set(subjects.map((s) => s.kind));
const n = subjects.length;
if (kinds.size > 1) return { title: `${n} Selected`, text: `${n} selected` };
const noun = first?.kind === "app" ? "Apps" : first?.kind === "site" ? "Domains" : "Hosts";
return { title: `${n} ${noun}`, text: `${n} ${noun.toLowerCase()}` };
}
function builtIns(list: DecryptListEntry[]): DecryptListEntry[] {
@@ -387,8 +473,8 @@ export function decryptFilterOptions(seen: SeenTargets | null | undefined): Filt
replaceValueOnly: true,
negation: { prefix: "!", info: "Exclude" },
fields: [
{ name: "host", values: seen?.hosts ?? [], info: "Host or *.site" },
{ name: "app", values: [...apps], info: "App name or bundle id" },
{ name: "host", values: seen?.hosts ?? [], info: "Host or *.domain" },
{ name: "app", values: [...apps], info: "App name or bundle ID" },
],
};
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, test, vi } from "vite-plus/test";
import { applyChoice, type ChoiceDeps, choiceToast, reconnectEntries } from "./decryptChoice";
function deps(over: Partial<ChoiceDeps> = {}) {
const calls: string[] = [];
const d: ChoiceDeps = {
canTrust: true,
readTrusted: vi.fn(async () => {
calls.push("ca_status");
return false;
}),
trust: vi.fn(async () => {
calls.push("ca_trust");
return { cancelled: false, trusted: true };
}),
save: vi.fn(async () => {
calls.push("edit_decrypt_list");
return { stillEncrypted: 2 };
}),
...over,
};
return { d, calls };
}
const decrypt = { add: ["host:www.google.com"], remove: [] };
describe("trust at the first Decrypt", () => {
test("trusts first while untrusted, then writes the change", async () => {
const { d, calls } = deps();
expect(await applyChoice(d, decrypt)).toEqual({ status: "applied", stillEncrypted: 2 });
expect(calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]);
});
test("a cancelled prompt writes nothing", async () => {
const { d, calls } = deps({
trust: async () => {
calls.push("ca_trust");
return { cancelled: true, trusted: false };
},
});
expect(await applyChoice(d, decrypt)).toEqual({ status: "cancelled" });
expect(calls).toEqual(["ca_status", "ca_trust"]);
});
test("a prompt answered without trust writes nothing either", async () => {
const { d } = deps({ trust: async () => ({ cancelled: false, trusted: false }) });
expect(await applyChoice(d, decrypt)).toEqual({ status: "untrusted" });
expect(d.save).not.toHaveBeenCalled();
});
test("a failed prompt fails the choice, nothing written", async () => {
const { d } = deps({
trust: async () => {
throw new Error("Not supported");
},
});
await expect(applyChoice(d, decrypt)).rejects.toThrow("Not supported");
expect(d.save).not.toHaveBeenCalled();
});
test("asks nothing once trusted", async () => {
const { d, calls } = deps({
readTrusted: async () => {
calls.push("ca_status");
return true;
},
});
await applyChoice(d, decrypt);
expect(calls).toEqual(["ca_status", "edit_decrypt_list"]);
});
test("stopping never asks, and dropping an exclusion does", async () => {
const stop = deps();
await applyChoice(stop.d, { add: ["!app:Dia"], remove: ["host:www.google.com"] });
expect(stop.calls).toEqual(["edit_decrypt_list"]);
const unexclude = deps();
await applyChoice(unexclude.d, { add: [], remove: ["!host:*.chase.com"] });
expect(unexclude.calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]);
});
test("where the app can't trust the certificate itself, the entry is written at once", async () => {
const { d, calls } = deps({ canTrust: false });
await applyChoice(d, decrypt);
expect(calls).toEqual(["edit_decrypt_list"]);
});
});
describe("the toast after a choice", () => {
const choice = { edit: decrypt, message: "Decrypting www.google.com" };
test("says what was chosen, and nothing to reconnect when nothing open stays encrypted", () => {
expect(choiceToast(choice, 0)).toEqual({
message: "Decrypting www.google.com",
sub: null,
reconnect: [],
});
expect(
choiceToast(
{ edit: { add: ["*"], remove: [] }, message: "Decrypting everything", sub: "Except" },
0,
).sub,
).toBe("Except");
});
test("offers Reconnect for the entry when open connections stay encrypted", () => {
expect(choiceToast(choice, 3)).toEqual({
message: "Decrypting www.google.com",
sub: "3 open connections stay encrypted.",
reconnect: ["host:www.google.com"],
});
expect(choiceToast(choice, 1).sub).toBe("1 open connection stays encrypted.");
});
test("Reconnect for an exclusion taken off names the exclusion, as the engine reads it", () => {
const stopExcluding = { edit: { add: [], remove: ["!host:*.chase.com"] }, message: "x" };
expect(choiceToast(stopExcluding, 1).reconnect).toEqual(["!host:*.chase.com"]);
});
});
describe("what Reconnect covers", () => {
test("the entries a change added, and the exclusions it took off, never what it stopped", () => {
expect(
reconnectEntries({ add: ["host:a.com", "!app:Dia"], remove: ["!host:*.b.com", "app:Slack"] }),
).toEqual(["host:a.com", "!host:*.b.com"]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { editDecrypts } from "./decrypt";
/** A change to the decrypt list, as `edit_decrypt_list` takes it */
export interface ListEdit {
add: string[];
remove: string[];
}
/** A Decrypt or Stop Decrypting chosen somewhere in the app, with what its toast says */
export interface DecryptChoice {
edit: ListEdit;
/** "Decrypting www.google.com" */
message: string;
/** Under it, when nothing more pressing is */
sub?: string;
}
/** What applying a choice needs from the app: the certificate's state and the engine */
export interface ChoiceDeps {
/** The app can trust the certificate itself, as on macOS; elsewhere the entry is written at once */
canTrust: boolean;
/** Reads whether the certificate is trusted now */
readTrusted: () => Promise<boolean>;
/** macOS's password prompt */
trust: () => Promise<{ cancelled: boolean; trusted: boolean }>;
save: (edit: ListEdit) => Promise<{ stillEncrypted: number }>;
}
export type ChoiceResult =
| { status: "applied"; stillEncrypted: number }
/** The password prompt was cancelled: nothing changed, and nothing to say */
| { status: "cancelled" }
/** The prompt was answered but the certificate is still not trusted */
| { status: "untrusted" };
/**
* Trust at the first Decrypt: a change that can make something decrypt first trusts the
* certificate while it isn't, then writes the change only once that succeeded. A change that
* only stops decrypting never asks.
*/
export async function applyChoice(deps: ChoiceDeps, edit: ListEdit): Promise<ChoiceResult> {
if (deps.canTrust && editDecrypts(edit) && !(await deps.readTrusted())) {
const result = await deps.trust();
if (result.cancelled) return { status: "cancelled" };
if (!result.trusted) return { status: "untrusted" };
}
const saved = await deps.save(edit);
return { status: "applied", stillEncrypted: saved.stillEncrypted };
}
/**
* The entries whose open connections a change can make decryptable, which Reconnect closes and
* the toast counts: those it added, and exclusions it took off
*/
export function reconnectEntries(edit: ListEdit): string[] {
return [
...edit.add.filter((e) => !e.trim().startsWith("!")),
...edit.remove.filter((e) => e.trim().startsWith("!")),
];
}
/** The toast after a choice was applied */
export interface ChoiceToast {
message: string;
sub: string | null;
/**
* The entries Reconnect closes the open connections of, one `reconnect_tunnels` each: those
* the change added, and exclusions it took off. Empty when nothing open stays encrypted.
*/
reconnect: string[];
}
/**
* Says what was chosen, with Undo; when open connections the list now decrypts stay encrypted,
* how many, with Reconnect for them. Nothing reconnects without that click.
*/
export function choiceToast(choice: DecryptChoice, stillEncrypted: number): ChoiceToast {
if (stillEncrypted <= 0)
return { message: choice.message, sub: choice.sub ?? null, reconnect: [] };
const n = stillEncrypted.toLocaleString();
const reconnect = reconnectEntries(choice.edit);
return {
message: choice.message,
sub: `${n} open ${stillEncrypted === 1 ? "connection stays" : "connections stay"} encrypted.`,
reconnect,
};
}
+21 -17
View File
@@ -5,11 +5,11 @@ import {
learnTab,
REQUEST_TAB_BODY,
REQUEST_TAB_HEADERS,
REQUEST_TAB_INFO,
RESPONSE_TAB_BODY,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_INFO,
RESPONSE_TAB_MESSAGES,
RESPONSE_TAB_TIMELINE,
type TabMemory,
} from "./defaultTab";
@@ -33,13 +33,7 @@ function memory(): TabMemory & { items: Map<string, string> } {
};
}
const RESPONSE_TABS = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
"cookies",
RESPONSE_TAB_TIMELINE,
RESPONSE_TAB_INFO,
];
const RESPONSE_TABS = [RESPONSE_TAB_BODY, RESPONSE_TAB_HEADERS, RESPONSE_TAB_COOKIES];
describe("flowShape", () => {
test("names what the flow has", () => {
@@ -59,8 +53,8 @@ describe("flowShape", () => {
describe("defaultTabFor", () => {
test("follows the table", () => {
const m = memory();
// A response body
expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_HEADERS);
// A response body, the request's Info, as there is no request body
expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("request", { ...ok, requestSize: 9 }, [], m)).toBe(REQUEST_TAB_BODY);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY);
// A request body, no response body
@@ -69,27 +63,28 @@ describe("defaultTabFor", () => {
expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
// Neither
const empty = { ...ok, responseSize: 0 };
expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_HEADERS);
expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("response", empty, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
// A stream
const ws = { ...ok, streamKind: "websocket" as const };
expect(defaultTabFor("response", ws, [...RESPONSE_TABS, RESPONSE_TAB_MESSAGES], m)).toBe(
RESPONSE_TAB_MESSAGES,
);
// No response at all
// No response at all: its timeline is in the request's Info
const failed = { ...ok, status: null, error: "refused", responseSize: 0 };
expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_TIMELINE);
expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
expect(defaultTabFor("request", failed, [], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("request", { ...failed, requestSize: 4 }, [], m)).toBe(REQUEST_TAB_BODY);
});
test("a tab chosen by hand becomes the default for the same shape and pane", () => {
const m = memory();
learnTab("response", ok, RESPONSE_TAB_INFO, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_INFO);
learnTab("response", ok, RESPONSE_TAB_COOKIES, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_COOKIES);
// Not for other shapes, nor the other pane
const post = { ...ok, requestSize: 30, responseSize: 0 };
expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
expect(defaultTabFor("request", ok, ["body", "headers", "info"], m)).toBe(REQUEST_TAB_HEADERS);
expect(defaultTabFor("request", ok, ["info", "body", "headers"], m)).toBe(REQUEST_TAB_INFO);
});
test("a learned tab the flow doesn't have falls back to the table", () => {
@@ -100,6 +95,15 @@ describe("defaultTabFor", () => {
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY);
});
test("the request half opens on Info unless it has a body, and learns like the response", () => {
const m = memory();
const tabs = [REQUEST_TAB_INFO, REQUEST_TAB_BODY, REQUEST_TAB_HEADERS];
expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("request", { ...ok, requestSize: 5 }, tabs, m)).toBe(REQUEST_TAB_BODY);
learnTab("request", ok, REQUEST_TAB_HEADERS, m);
expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_HEADERS);
});
test("works without storage", () => {
expect(defaultTabFor("response", ok, RESPONSE_TABS, null)).toBe(RESPONSE_TAB_BODY);
});
+7 -6
View File
@@ -1,6 +1,7 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
/** The request half's tabs, named as the shared request pane names them */
/** The request half's tabs, named as the shared request pane names them, Info first */
export const REQUEST_TAB_INFO = "info";
export const REQUEST_TAB_BODY = "body";
export const REQUEST_TAB_PARAMS = "params";
export const REQUEST_TAB_HEADERS = "headers";
@@ -11,8 +12,6 @@ export const REQUEST_TAB_COOKIES = "cookies";
export const RESPONSE_TAB_BODY = "body";
export const RESPONSE_TAB_HEADERS = "headers";
export const RESPONSE_TAB_COOKIES = "cookies";
export const RESPONSE_TAB_TIMELINE = "timeline";
export const RESPONSE_TAB_INFO = "info";
export const RESPONSE_TAB_MESSAGES = "messages";
export type FlowPane = "request" | "response";
@@ -33,10 +32,13 @@ export function flowShape(
return "neither";
}
/** The tab a half opens on for a shape, before anything was learned */
/**
* The tab a half opens on for a shape, before anything was learned: the request half on its
* body when it has one, else on Info; the response half on what it has
*/
export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody: boolean): string {
if (pane === "request") {
return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_HEADERS;
return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_INFO;
}
switch (shape) {
case "response_body":
@@ -44,7 +46,6 @@ export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody
case "stream":
return RESPONSE_TAB_MESSAGES;
case "no_response":
return RESPONSE_TAB_TIMELINE;
case "request_body":
case "neither":
return RESPONSE_TAB_HEADERS;
+367
View File
@@ -0,0 +1,367 @@
import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import {
connectionOf,
type EncryptedCardInput,
encryptedCard,
lineText,
stopDecryptingConnection,
} from "./encryptedCard";
const user = (...entries: string[]): DecryptListEntry[] =>
entries.map((entry) => ({ entry, builtIn: false }));
const BUILT_INS: DecryptListEntry[] = [
{ entry: "!host:*.apple.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.1password.com", builtIn: true, kind: "pinned" },
];
const dia = {
clientName: "Dia",
clientBundleId: "company.thebrowser.dia",
clientPath: "/A/Dia.app",
};
const tunnel: EncryptedCardInput["flow"] = {
url: "www.google.com:443",
...dia,
tunnelReason: "not_listed",
tunnelExcludedBy: null,
tunnelExcludedBuiltIn: false,
tlsRejected: null,
elapsedMs: 188,
error: null,
};
function card(over: Partial<EncryptedCardInput> = {}) {
return encryptedCard({
flow: tunnel,
list: BUILT_INS,
trusted: true,
canTrust: true,
passthrough: [],
...over,
});
}
const summary = (c: ReturnType<typeof card>) =>
c.actions.map((a) => [a.label, a.hint, a.edit ?? null]);
describe("connectionOf", () => {
test("reads a tunnel's authority and a refused handshake's URL alike", () => {
expect(
connectionOf({ url: "www.google.com:443", clientName: null, clientBundleId: null }),
).toEqual({
host: "www.google.com",
port: 443,
label: "www.google.com",
address: "www.google.com:443",
app: null,
});
const c = connectionOf({ url: "https://home.ts.net:8317", ...dia });
expect(c.label).toBe("home.ts.net:8317");
expect(c.app).toEqual({ name: "Dia", bundleId: "company.thebrowser.dia" });
expect(
connectionOf({ url: "[::1]:8443", clientName: null, clientBundleId: null }),
).toMatchObject({ host: "::1", port: 8443, address: "[::1]:8443", label: "[::1]:8443" });
});
});
describe("the card of an encrypted connection", () => {
test("nothing decrypted yet: the host, the app and everything, each with what it writes", () => {
const c = card();
expect(lineText(c.why)).toBe(
"**Encrypted Content:** Enable decryption to see requests to `www.google.com`",
);
expect(summary(c)).toEqual([
[
"Decrypt www.google.com",
"host:www.google.com",
{ add: ["host:www.google.com"], remove: [] },
],
["Decrypt Dia", "app:Dia", { add: ["app:Dia"], remove: [] }],
["Decrypt Everything", "*", { add: ["*"], remove: [] }],
]);
// Nothing under the buttons while the certificate is trusted
expect(c.foot).toEqual([]);
// The info button keeps its general line
expect(c.tooltip).toBeNull();
});
test("asks for the password once while the certificate isn't trusted, where it can be", () => {
expect(card({ trusted: false }).foot).toEqual(["macOS will ask for a password once."]);
expect(card({ trusted: false, canTrust: false }).foot).toEqual([]);
});
test("something else listed: not on the list, with the same choices", () => {
const c = card({ list: [...user("host:api.stripe.com"), ...BUILT_INS] });
expect(lineText(c.why)).toBe(
"**Encrypted Content:** Enable decryption to see requests to `www.google.com`",
);
expect(c.actions.map((a) => a.label)).toEqual([
"Decrypt www.google.com",
"Decrypt Dia",
"Decrypt Everything",
]);
});
test("no app button for a connection no app is named for", () => {
const c = card({
flow: { ...tunnel, clientName: null, clientBundleId: null, clientPath: null },
});
expect(c.actions.map((a) => a.label)).toEqual(["Decrypt www.google.com", "Decrypt Everything"]);
});
test("names the host nothing on the list covers, its port only when not 443", () => {
const list = [...user("host:api.stripe.com"), ...BUILT_INS];
expect(lineText(card({ list, flow: { ...tunnel, url: "t.yaak.app:443" } }).why)).toBe(
"**Encrypted Content:** Enable decryption to see requests to `t.yaak.app`",
);
expect(lineText(card({ list, flow: { ...tunnel, url: "home.ts.net:8317" } }).why)).toBe(
"**Encrypted Content:** Enable decryption to see requests to `home.ts.net:8317`",
);
});
test("a port off 443 is part of the host it writes", () => {
const c = card({ flow: { ...tunnel, url: "home.ts.net:8317" } });
expect(c.actions[0]?.hint).toBe("host:home.ts.net:8317");
expect(c.actions[0]?.label).toBe("Decrypt home.ts.net:8317");
});
test("Decrypt Everything is offered only where * would decrypt it", () => {
const flow = { ...tunnel, url: "my.1password.com:443", clientName: "1Password" };
expect(card({ flow }).actions.map((a) => a.label)).not.toContain("Decrypt Everything");
});
test("excluded by default: the host alone, dashed, and who may refuse it", () => {
const pinned = card({
flow: {
...tunnel,
url: "my.1password.com:443",
clientName: "1Password",
clientBundleId: "com.1password",
tunnelReason: "excluded",
tunnelExcludedBy: "!host:*.1password.com",
tunnelExcludedBuiltIn: true,
},
});
expect(lineText(pinned.why)).toBe("Apps that pin certificates are excluded by default.");
expect(summary(pinned)).toEqual([
[
"Decrypt my.1password.com",
"host:my.1password.com",
{ add: ["host:my.1password.com"], remove: [] },
],
]);
expect(pinned.actions[0]?.dashed).toBe(true);
expect(pinned.foot).toEqual(["1Password may reject the connection."]);
const apple = card({
flow: {
...tunnel,
url: "gateway.icloud.apple.com:443",
clientName: null,
clientBundleId: null,
tunnelReason: "excluded",
tunnelExcludedBy: "!host:*.apple.com",
tunnelExcludedBuiltIn: true,
},
});
expect(lineText(apple.why)).toBe("Apple services are excluded by default.");
expect(apple.foot).toEqual(["macOS may reject the connection."]);
});
test("excluded by the person: names it, and Stop Excluding when that alone decrypts it", () => {
const list = [...user("*", "!host:*.google.com"), ...BUILT_INS];
const c = card({
list,
flow: { ...tunnel, tunnelReason: "excluded", tunnelExcludedBy: "!host:*.google.com" },
});
expect(lineText(c.why)).toBe("This connection is excluded by `!host:*.google.com`.");
// Nothing added wins over the person's own exclusion, so taking it off is the way in
expect(summary(c)).toEqual([
[
"Stop Excluding *.google.com",
"!host:*.google.com",
{ add: [], remove: ["!host:*.google.com"] },
],
]);
// Their exact exclusion of the host is one Decrypt takes off itself
const exact = card({ list: [...user("*", "!host:www.google.com"), ...BUILT_INS] });
expect(summary(exact)[0]).toEqual([
"Decrypt www.google.com",
"host:www.google.com",
{ add: [], remove: ["!host:www.google.com"] },
]);
// Every way in blocked but taking the exclusions off
const both = [...user("*", "!host:*.google.com", "!app:Dia"), ...BUILT_INS];
const blocked = card({ list: both });
expect(summary(blocked)).toEqual([
[
"Stop Excluding 2 Entries",
"!host:*.google.com, !app:Dia",
{ add: [], remove: ["!host:*.google.com", "!app:Dia"] },
],
]);
expect(blocked.actions[0]?.removes).toBe(true);
});
test("once the list decrypts it: opened before, the opposite, and Reconnect while open", () => {
const list = [...user("host:www.google.com"), ...BUILT_INS];
const closed = card({ list });
expect(lineText(closed.why)).toBe(
"This connection was opened before `www.google.com` was decrypted.",
);
expect(summary(closed)).toEqual([
[
"Stop Decrypting www.google.com",
"host:www.google.com",
{ add: [], remove: ["host:www.google.com"] },
],
]);
expect(closed.foot).toEqual([]);
const open = card({ list, flow: { ...tunnel, elapsedMs: null } });
expect(open.actions.map((a) => a.kind)).toEqual(["reconnect", "stop"]);
const everything = card({ list: [...user("*"), ...BUILT_INS] });
expect(lineText(everything.why)).toBe(
"This connection was opened before everything was decrypted.",
);
expect(everything.actions[0]?.label).toBe("Stop Decrypting Everything");
});
});
describe("the card of a refused handshake", () => {
const refused: EncryptedCardInput["flow"] = {
...tunnel,
url: "https://sync.acme.dev",
clientName: "Acme",
clientBundleId: "dev.acme",
clientPath: "/A/Acme.app",
tunnelReason: null,
tlsRejected: "bad_certificate",
elapsedMs: 6,
};
const listed = [...user("*"), ...BUILT_INS];
test("an app: likely pinned, Stop Decrypting first, Retry while passed through", () => {
const c = card({ flow: refused, list: listed, passthrough: ["sync.acme.dev:443"] });
expect(c.rejected).toBe(true);
expect([lineText(c.why), c.tooltip]).toEqual([
"Acme rejected the certificate.",
"Acme likely pins its certificate, so it cannot be decrypted.",
]);
expect(summary(c)).toEqual([
["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }],
["Retry", "next connection", null],
]);
expect(c.actions[0]?.primary).toBe(true);
expect(c.foot).toEqual(["This host stays encrypted until Yaak Proxy is relaunched."]);
});
test("once no longer passed through, Retry goes and the foot says so", () => {
const c = card({ flow: refused, list: listed, passthrough: [] });
expect(c.actions.map((a) => a.kind)).toEqual(["stop"]);
expect(c.foot).toEqual(["The next connection will be decrypted."]);
});
test("no longer passed through but excluded since: no claim that it will be decrypted", () => {
const excluded = [...user("*", "!app:Acme"), ...BUILT_INS];
const c = card({ flow: refused, list: excluded, passthrough: [] });
expect(c.actions).toEqual([]);
expect(c.foot).toEqual([]);
});
test("an untrusted certificate keeps its line once no longer passed through", () => {
const c = card({ flow: refused, list: listed, trusted: false, passthrough: [] });
expect(c.foot).toEqual(["This host stays encrypted until the certificate is trusted."]);
});
test("a program: its own trust store, Copy Environment first", () => {
const python = {
...refused,
url: "https://pypi.org",
clientName: "python3",
clientBundleId: null,
clientPath: "/opt/homebrew/bin/python3",
};
const c = card({ flow: python, list: listed, passthrough: ["pypi.org:443"] });
expect([lineText(c.why), c.tooltip]).toEqual([
"python3 rejected the certificate.",
"python3 uses its own trust store, so it does not trust the certificate.",
]);
expect(c.actions.map((a) => [a.kind, a.label])).toEqual([
["copy_environment", "Copy Environment"],
["stop", "Stop Decrypting python3"],
["try_again", "Retry"],
]);
expect(c.foot).toEqual(["Paste it into a terminal, then rerun python3."]);
});
test("an unknown client: the host", () => {
const c = card({
flow: { ...refused, clientName: null, clientBundleId: null, clientPath: null },
list: listed,
});
expect(lineText(c.why)).toBe("An app rejected the certificate.");
expect(summary(c)[0]).toEqual([
"Stop Decrypting sync.acme.dev",
"!host:sync.acme.dev",
{ add: ["!host:sync.acme.dev"], remove: [] },
]);
});
test("an untrusted certificate explains it: Trust Certificate first", () => {
const c = card({
flow: refused,
list: listed,
trusted: false,
passthrough: ["sync.acme.dev:443"],
});
expect(lineText(c.why)).toBe("The certificate is not trusted.");
expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]);
expect(c.foot).toEqual(["This host stays encrypted until the certificate is trusted."]);
});
test("a tunnel after a refusal names the client that refused", () => {
const after = {
...tunnel,
url: "sync.acme.dev:443",
tunnelReason: "rejected" as const,
};
const c = card({
flow: after,
list: listed,
rejectedBy: { clientName: "Acme", clientBundleId: "dev.acme", clientPath: null },
});
expect(lineText(c.why)).toBe("Acme rejected the certificate for this host.");
// Stop Decrypting acts on this connection, so on Dia's own
expect(c.actions[0]?.hint).toBe("!app:Dia");
});
});
describe("Stop Decrypting a refused connection", () => {
const c = connectionOf({ url: "sync.acme.dev:443", clientName: "Acme", clientBundleId: null });
test("takes off the entry naming exactly this host when that alone stops it", () => {
const action = stopDecryptingConnection(user("host:sync.acme.dev"), c);
expect(action?.edit).toEqual({ add: [], remove: ["host:sync.acme.dev"] });
expect(action?.label).toBe("Stop Decrypting sync.acme.dev");
});
test("else excludes the app, or the host without one, which always wins", () => {
expect(stopDecryptingConnection(user("*"), c)?.edit).toEqual({
add: ["!app:Acme"],
remove: [],
});
const anonymous = { ...c, app: null };
expect(stopDecryptingConnection(user("*", "host:sync.acme.dev"), anonymous)?.edit).toEqual({
add: ["!host:sync.acme.dev"],
remove: [],
});
});
test("nothing when the list does not decrypt it", () => {
expect(stopDecryptingConnection(BUILT_INS, c)).toBeNull();
});
});
+423
View File
@@ -0,0 +1,423 @@
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
import {
type DecryptQuery,
type DecryptSubject,
decrypts,
decryptsAnything,
detailActions,
EVERYTHING,
entryMatches,
entrySubject,
listEdit,
setDecrypted,
subjectEntry,
} from "./decrypt";
import { hostnameOf } from "./search";
/** Where an encrypted connection went, and the app that opened it, as the list judges it */
export interface Connection {
/** The host alone, an IPv6 address without brackets */
host: string;
port: number;
/** The host as a button names it: with its port when that is not 443 */
label: string;
/** `host:port`, an IPv6 host in brackets, as the engine's pass-through set lists it */
address: string;
app: { name: string; bundleId: string | null } | null;
}
type ConnectionFlow = Pick<Flow, "url" | "clientName" | "clientBundleId">;
/** The connection a tunnel or a refused handshake records: `https://host:port` or `host:port` */
export function connectionOf(flow: ConnectionFlow): Connection {
const scheme = flow.url.indexOf("://");
const rest = scheme === -1 ? flow.url : flow.url.slice(scheme + 3);
const authority = (rest.split(/[/?#]/)[0] ?? "").toLowerCase();
const name = hostnameOf(authority);
const portText = authority.slice(name.length + 1);
const port = /^\d+$/.test(portText) ? Number(portText) : 443;
const host = name.replace(/^\[|\]$/g, "");
return {
host,
port,
label: port === 443 ? name : `${name}:${port}`,
address: `${name}:${port}`,
app: flow.clientName == null ? null : { name: flow.clientName, bundleId: flow.clientBundleId },
};
}
function queryOf(c: Connection): DecryptQuery {
return { host: c.host, port: c.port, app: c.app };
}
/** What a button on the card does */
export type CardActionKind =
| "decrypt"
| "stop"
| "reconnect"
| "trust"
| "try_again"
| "copy_environment";
export interface CardAction {
kind: CardActionKind;
label: string;
/** What it writes, shown beside it: an entry, or a hint like `next connection` */
hint: string | null;
/** The hint is an entry it takes off the list, so it is struck through */
removes?: boolean;
/** The list change, for `decrypt` and `stop` */
edit?: { add: string[]; remove: string[] };
/** What the toast says was chosen */
toast?: { message: string; sub?: string };
/** The suggested action */
primary?: boolean;
/** The narrowest override of an exclusion that ships for a reason */
dashed?: boolean;
/** What the icon shows: a host, the app, everything, or the action's own */
icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal";
}
/**
* A line of text, the hosts, apps and entries in it shown as code, as Yaak shows literal values,
* and a lead in bold
*/
export type Line = (string | { code: string } | { bold: string })[];
/** A line as plain text, its code in backticks and its bold in asterisks */
export function lineText(line: Line): string {
return line
.map((part) =>
typeof part === "string" ? part : "code" in part ? `\`${part.code}\`` : `**${part.bold}**`,
)
.join("");
}
export interface EncryptedCardModel {
rejected: boolean;
/** Why the connection is encrypted, in one sentence */
why: Line;
/** What the info button beside it says, in place of the general line; null for that line */
tooltip: string | null;
actions: CardAction[];
/** Small lines under the buttons */
foot: string[];
}
export interface EncryptedCardInput {
flow: Pick<
Flow,
| "url"
| "clientName"
| "clientBundleId"
| "clientPath"
| "tunnelReason"
| "tunnelExcludedBy"
| "tunnelExcludedBuiltIn"
| "tlsRejected"
| "elapsedMs"
| "error"
>;
list: DecryptListEntry[];
/** The certificate is trusted, null until read */
trusted: boolean | null;
/** The app can trust the certificate itself, as on macOS */
canTrust: boolean;
/** The addresses passed through after a rejection, null until read */
passthrough: string[] | null;
/** For a tunnel passed through after a rejection: the client that refused, when known */
rejectedBy?: Pick<Flow, "clientName" | "clientBundleId" | "clientPath"> | null;
}
/** A refused handshake, or a tunnel passed through because of one */
export function isRejection(flow: Pick<Flow, "tlsRejected" | "tunnelReason">): boolean {
return flow.tlsRejected != null || flow.tunnelReason === "rejected";
}
/** A tunnel still open: written as it opens, done once it closes */
export function isOpen(flow: Pick<Flow, "elapsedMs" | "error">): boolean {
return flow.elapsedMs == null && flow.error == null;
}
/**
* The card for an HTTPS connection the proxy did not decrypt: why, from what the engine recorded
* as it opened, and the changes to the list that would decrypt the next one like it, judged
* against the list as it is now. Once the list decrypts it, the card says it opened before and
* offers the opposite, and Reconnect while it is open. Nothing on it narrates what happens next.
*/
export function encryptedCard(input: EncryptedCardInput): EncryptedCardModel {
return isRejection(input.flow) ? rejectedCard(input) : tunnelCard(input);
}
function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): EncryptedCardModel {
const c = connectionOf(flow);
const q = queryOf(c);
const decryptsNow = decrypts(list, q);
const foot: string[] = [];
if (decryptsNow) {
// Decrypted from the next connection on: the entries that do it, each to take back.
const decrypting = list.filter(
(e) => !e.builtIn && !e.entry.startsWith("!") && entryMatches(e.entry, q),
);
const actions: CardAction[] = decrypting.map((e) => stopEntry(e.entry));
if (isOpen(flow)) {
actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" });
}
const first = decrypting[0]?.entry;
const what: Line[number] =
first === EVERYTHING ? "everything" : { code: first == null ? c.label : entrySubject(first) };
return {
rejected: false,
why: ["This connection was opened before ", what, " was decrypted."],
tooltip: null,
actions,
foot: [],
};
}
const actions: CardAction[] = [];
const builtInBlock = list.find(
(e) => e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q),
);
const hostSubject: DecryptSubject = { kind: "host", host: c.label };
const judged = detailActions(list, c.label, c.app);
if (builtInBlock != null) {
// Kept out by default for a reason: only the host itself, the narrowest override.
if (judged.host) {
actions.push({ ...decryptSubject(list, hostSubject, c.label, "host"), dashed: true });
foot.length = 0;
foot.push(
builtInBlock.kind === "apple" || c.app == null
? "macOS may reject the connection."
: `${c.app.name} may reject the connection.`,
);
}
} else {
if (judged.host) actions.push(decryptSubject(list, hostSubject, c.label, "host"));
if (judged.app && c.app != null) {
actions.push(decryptSubject(list, { kind: "app", ...c.app }, c.app.name, "app"));
}
const everything = list.some((e) => e.entry === EVERYTHING);
if (!everything && decrypts([{ entry: EVERYTHING, builtIn: false }, ...list], q)) {
actions.push({
kind: "decrypt",
label: "Decrypt Everything",
hint: EVERYTHING,
edit: { add: [EVERYTHING], remove: [] },
toast: { message: "Decrypting everything", sub: "Excluded hosts and apps stay encrypted." },
icon: "everything",
});
}
if (actions.length === 0) {
// The person's own exclusions keep it out: taking them off, when that alone decrypts it.
const own = list.filter(
(e) => !e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q),
);
const without = list.filter((e) => !own.includes(e));
if (own.length > 0 && decrypts(without, q)) {
const [only] = own;
const label =
own.length === 1 && only != null
? `Stop Excluding ${entrySubject(only.entry.slice(1))}`
: `Stop Excluding ${own.length} Entries`;
actions.push({
kind: "decrypt",
label,
hint: own.map((e) => e.entry).join(", "),
removes: true,
edit: { add: [], remove: own.map((e) => e.entry) },
toast: { message: label.replace("Stop Excluding", "Stopped excluding") },
icon: "lock_open",
});
}
}
}
if (actions.length > 0 && canTrust && trusted === false) {
foot.push("macOS will ask for a password once.");
}
return {
rejected: false,
why: whyLine(flow, list, c),
tooltip: null,
actions,
foot: actions.length === 0 ? [] : foot,
};
}
/**
* The why line of a connection the list does not decrypt, from the reason the engine recorded
* as the tunnel opened: what excluded it, or, with nothing to exclude it, its host.
*/
function whyLine(flow: EncryptedCardInput["flow"], list: DecryptListEntry[], c: Connection): Line {
if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) {
if (flow.tunnelExcludedBuiltIn) {
const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind;
return [
kind === "apple"
? "Apple services are excluded by default."
: kind === "pinned"
? "Apps that pin certificates are excluded by default."
: "This connection is excluded by default.",
];
}
return ["This connection is excluded by ", { code: flow.tunnelExcludedBy }, "."];
}
return [
{ bold: "Encrypted Content:" },
" Enable decryption to see requests to ",
{ code: c.label },
];
}
function decryptSubject(
list: DecryptListEntry[],
subject: DecryptSubject,
name: string,
icon: CardAction["icon"],
): CardAction {
return {
kind: "decrypt",
label: `Decrypt ${name}`,
hint: subjectEntry(subject),
edit: listEdit(list, setDecrypted(list, [subject], true)),
toast: { message: `Decrypting ${name}` },
icon,
};
}
function stopEntry(entry: string): CardAction {
const name = entrySubject(entry);
return {
kind: "stop",
label: `Stop Decrypting ${name}`,
hint: entry,
removes: true,
edit: { add: [], remove: [entry] },
toast: { message: `Stopped decrypting ${entry === EVERYTHING ? "everything" : name}` },
icon: "lock",
};
}
/**
* Stop Decrypting on a refused connection, judged with its host, port and app together: takes
* off the person's entry naming exactly this host when that alone stops it, else excludes the
* app when it is known, else the host, which the list always honours. Null when the list does
* not decrypt it.
*/
export function stopDecryptingConnection(
list: DecryptListEntry[],
connection: Connection,
): CardAction | null {
const q = queryOf(connection);
if (!decrypts(list, q)) return null;
const hostEntry = subjectEntry({ kind: "host", host: connection.label });
const own = list.find((e) => !e.builtIn && e.entry === hostEntry);
if (
own != null &&
!decrypts(
list.filter((e) => e !== own),
q,
)
) {
return { ...stopEntry(own.entry), label: `Stop Decrypting ${connection.label}` };
}
const name = connection.app?.name ?? connection.label;
const add = connection.app == null ? `!${hostEntry}` : `!app:${connection.app.name}`;
return {
kind: "stop",
label: `Stop Decrypting ${name}`,
hint: add,
edit: { add: [add], remove: [] },
toast: { message: `Stopped decrypting ${name}` },
icon: "lock",
};
}
/** Who refused: an app with a bundle, a program without one, or a client the app couldn't name */
type Refuser =
| { kind: "app"; name: string }
| { kind: "program"; name: string }
| { kind: "unknown" };
function refuserOf(
client: Pick<Flow, "clientName" | "clientBundleId" | "clientPath"> | null | undefined,
): Refuser {
if (client?.clientName == null) return { kind: "unknown" };
if (client.clientBundleId != null) return { kind: "app", name: client.clientName };
return { kind: "program", name: client.clientName };
}
function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
const { flow, list, trusted, canTrust, passthrough } = input;
const c = connectionOf(flow);
// The refused handshake names its own client; a tunnel after it, the one that refused.
const own = flow.tlsRejected != null;
const refuser = refuserOf(own ? flow : input.rejectedBy);
const stop = stopDecryptingConnection(list, c);
const passedThrough = passthrough == null ? null : passthrough.includes(c.address);
const tryAgain: CardAction | null =
passedThrough === false
? null
: { kind: "try_again", label: "Retry", hint: "next connection", icon: "refresh" };
// No longer passed through: the next connection is decrypted while the list still decrypts
// it. Excluded since, it stays encrypted, and there is nothing to add.
const until: string[] | null =
passedThrough === false
? stop == null
? []
: ["The next connection will be decrypted."]
: null;
if (canTrust && trusted === false && decryptsAnything(list)) {
return {
rejected: true,
why: ["The certificate is not trusted."],
tooltip: null,
actions: [
{ kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" },
...(stop == null ? [] : [stop]),
],
// Whether or not it is still passed through: untrusted, the next one is rejected too.
foot: ["This host stays encrypted until the certificate is trusted."],
};
}
const forHost = own ? "" : " for this host";
if (refuser.kind === "program") {
return {
rejected: true,
why: [`${refuser.name} rejected the certificate${forHost}.`],
tooltip: `${refuser.name} uses its own trust store, so it does not trust the certificate.`,
actions: [
{
kind: "copy_environment",
label: "Copy Environment",
hint: "SSL_CERT_FILE, …",
primary: true,
icon: "terminal",
},
...(stop == null ? [] : [stop]),
...(tryAgain == null ? [] : [tryAgain]),
],
foot: until ?? [`Paste it into a terminal, then rerun ${refuser.name}.`],
};
}
return {
rejected: true,
why: [
refuser.kind === "app"
? `${refuser.name} rejected the certificate${forHost}.`
: "An app rejected the certificate.",
],
tooltip:
refuser.kind === "app"
? `${refuser.name} likely pins its certificate, so it cannot be decrypted.`
: null,
actions: [
...(stop == null ? [] : [{ ...stop, primary: true }]),
...(tryAgain == null ? [] : [tryAgain]),
],
foot: until ?? ["This host stays encrypted until Yaak Proxy is relaunched."],
};
}
+6 -28
View File
@@ -1,12 +1,8 @@
import { describe, expect, test } from "vite-plus/test";
import { decryptStep, setupIncomplete } from "./firstRun";
import { setupIncomplete } from "./firstRun";
describe("setupIncomplete", () => {
const done = { trusted: true, systemProxySupported: true, systemProxyEverOn: true };
test("is incomplete while the certificate is untrusted", () => {
expect(setupIncomplete({ ...done, trusted: false })).toBe(true);
});
const done = { systemProxySupported: true, systemProxyEverOn: true };
test("is incomplete until the system proxy has been on once", () => {
expect(setupIncomplete({ ...done, systemProxyEverOn: false })).toBe(true);
@@ -17,29 +13,11 @@ describe("setupIncomplete", () => {
expect(
setupIncomplete({ ...done, systemProxySupported: false, systemProxyEverOn: false }),
).toBe(false);
expect(setupIncomplete({ ...done, systemProxySupported: false, trusted: false })).toBe(true);
});
test("waits for both to be read, so the card does not flash", () => {
expect(setupIncomplete({ ...done, trusted: null })).toBe(false);
expect(setupIncomplete({ ...done, systemProxySupported: null, trusted: false })).toBe(false);
});
});
describe("decryptStep", () => {
const builtIns = [{ entry: "!host:*.apple.com", builtIn: true }];
test("picks hosts as they appear by default, after the certificate", () => {
expect(decryptStep(builtIns, false)).toEqual({ choice: "pick", state: "todo" });
expect(decryptStep(builtIns, true)).toEqual({ choice: "pick", state: "next" });
});
test("is done once everything, or a host, is on the list", () => {
const everything = [{ entry: "*", builtIn: false }, ...builtIns];
expect(decryptStep(everything, true)).toEqual({ choice: "everything", state: "done" });
const host = [{ entry: "host:api.example.com", builtIn: false }];
expect(decryptStep(host, true)).toEqual({ choice: "pick", state: "done" });
// An exclusion alone decrypts nothing.
expect(decryptStep([{ entry: "!host:a.com", builtIn: false }], true).state).toBe("next");
test("waits for the system proxy to be read, so the card does not flash", () => {
expect(setupIncomplete({ ...done, systemProxySupported: null, systemProxyEverOn: false })).toBe(
false,
);
});
});
+6 -29
View File
@@ -1,10 +1,5 @@
import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib";
import { decryptsEverything } from "./decrypt";
/** What decides whether setup is done, as the app last read it */
export interface SetupState {
/** The certificate is trusted, or null until read */
trusted: boolean | null;
/** The system proxy can be set on this platform, or null until read */
systemProxySupported: boolean | null;
/** The system proxy has been on at some point since this install */
@@ -12,31 +7,13 @@ export interface SetupState {
}
/**
* The first-run card shows while setup is incomplete: the certificate untrusted, or the system
* proxy never on since this install, where there is one. Not while either is still being read,
* so the card does not flash. Once setup is done an empty list is the "no flows yet" state,
* after a Clear too.
* The first-run card shows while setup is incomplete: the system proxy never on since this
* install, where there is one. The certificate is not part of it: nothing needs it until
* something is decrypted, which asks for it then. Not while the system proxy is still being
* read, so the card does not flash. Once setup is done an empty list is the "no flows yet"
* state, after a Clear too.
*/
export function setupIncomplete(s: SetupState): boolean {
if (s.trusted == null || s.systemProxySupported == null) return false;
if (!s.trusted) return true;
if (s.systemProxySupported == null) return false;
return s.systemProxySupported && !s.systemProxyEverOn;
}
/** The first-run card's "Choose what to decrypt" step, as the decrypt list has it */
export interface DecryptStep {
/** Everything is decrypted, `*` on the list; else hosts are picked as they appear */
choice: "everything" | "pick";
state: "todo" | "next" | "done";
}
/**
* Picking hosts as they appear is the default and needs nothing done, so the step waits on the
* certificate like the others and is done once a choice is on the list: everything, or any host
* or app picked already.
*/
export function decryptStep(list: DecryptListEntry[], trusted: boolean): DecryptStep {
const choice = decryptsEverything(list) ? "everything" : "pick";
const picked = list.some((e) => !e.builtIn && !e.entry.startsWith("!"));
return { choice, state: picked ? "done" : trusted ? "next" : "todo" };
}
+11 -7
View File
@@ -33,8 +33,8 @@ export function useSaveFlowBody(): (flow: Flow, which: FlowBody) => Promise<void
if (path == null) return;
const saved = await rpc("save_flow_body", { flowId: flow.id, which, path });
const notes = [
saved.raw ? "as it was on the wire, since it failed to decode" : null,
saved.truncated ? "cut at the capture limit" : null,
saved.raw ? "as received, since it failed to decode" : null,
saved.truncated ? "truncated at the capture limit" : null,
].filter((n) => n != null);
showToast({
message: `Saved ${formatSize(saved.size)}${notes.length > 0 ? `, ${notes.join(", ")}` : ""}`,
@@ -135,22 +135,26 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
return;
}
await navigator.clipboard.writeText(value);
showToast({ message: `Copied ${label}` });
showToast({ message: "Copied to clipboard" });
} catch (err) {
showToast({ message: `Failed to copy ${label}: ${String(err)}`, color: "danger" });
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
}
};
const noResponse = (flow.error != null || flow.cancelled) && flow.status == null;
// A failed TLS handshake, recorded without a request, has nothing to replay.
const noRequest = flow.method === "";
return [
{
label: flow.savedRequestId == null ? "Save as Yaak Request" : "Save to Yaak Again",
icon: "bookmark",
disabled: noRequest,
onSelect: () => openSave(flow),
},
{ type: "separator" },
{
label: "Copy as cURL",
icon: "square_terminal",
disabled: noRequest,
onSelect: async () => {
let body: string | null = null;
if (flow.requestSize > 0) {
@@ -169,7 +173,7 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
// A command that sends other bytes than the app did would mislead.
if (body == null) {
showToast({
message: "Body is binary or truncated; export HAR instead",
message: "The body is binary or truncated. Export as HAR instead.",
color: "info",
});
return;
@@ -205,12 +209,12 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
onSelect: () => saveBody(flow, "response"),
},
{
label: "Export HAR",
label: "Export as HAR",
icon: "download",
onSelect: async () => {
try {
const written = await exportHar({ ids: [flow.id] }, "flow.har");
if (written != null) showToast({ message: "Exported the flow as HAR" });
if (written != null) showToast({ message: "Exported 1 flow as HAR" });
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
+7 -1
View File
@@ -60,6 +60,12 @@ const flow: Flow = {
grpcMessage: null,
graphqlOperation: null,
tunnelled: false,
tunnelReason: null,
tunnelExcludedBy: null,
tunnelExcludedBuiltIn: false,
tunnelRejectedBy: null,
reconnectRequested: false,
tlsRejected: null,
agentRead: false,
clientAddr: null,
elapsedConnectMs: null,
@@ -219,7 +225,7 @@ describe("flowResponseToView", () => {
test("says when the client gave up before a response", () => {
const view = flowResponseToView({ ...flow, status: null, elapsedMs: null, cancelled: true });
expect(view?.error).toBe("Cancelled by the client");
expect(view?.error).toBe("Cancelled by the app");
});
});
+1 -1
View File
@@ -119,7 +119,7 @@ function responseView(flow: Flow, body: FlowBodyData | null, ended: boolean): Ht
elapsedMs: flow.elapsedMs ?? 0,
elapsedHeadersMs: flow.elapsedHeadersMs ?? 0,
elapsedDnsMs: 0,
error: flow.error ?? (flow.cancelled && flow.status == null ? "Cancelled by the client" : null),
error: flow.error ?? (flow.cancelled && flow.status == null ? "Cancelled by the app" : null),
remoteAddr: flow.remoteAddr,
version: flow.upstreamHttpVersion ?? flow.clientHttpVersion,
cookieHeaders: {
+42 -4
View File
@@ -8,8 +8,14 @@ vi.mock("@yaakapp-internal/ui", async () => {
return { selectedIdsFamily: () => atom<string[]>([]) };
});
const { activeQueryAtom, allTrafficTotalAtom, flowTotalAtom, hideSystemAtom, nonSystemTotalAtom } =
await import("./store");
const {
activeQueryAtom,
allTrafficTotalAtom,
flowTotalAtom,
hideEncryptedAtom,
hideSystemAtom,
allTrafficShownAtom,
} = await import("./store");
describe("Show in All Traffic, in System's menu", () => {
test("shows System's traffic in All traffic by default", () => {
@@ -20,13 +26,45 @@ describe("Show in All Traffic, in System's menu", () => {
test("leaves System out of All traffic and brings it back at once", () => {
const store = createStore();
store.set(nonSystemTotalAtom, 3);
store.set(allTrafficShownAtom, 3);
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
store.set(hideSystemAtom, true);
expect(store.get(activeQueryAtom)).toEqual({ query: "", hideSystem: true });
expect(store.get(activeQueryAtom)).toEqual({
query: "",
hideSystem: true,
hideEncrypted: false,
});
expect(store.get(allTrafficTotalAtom)).toBe(3);
store.set(hideSystemAtom, false);
expect(store.get(activeQueryAtom)).toBeNull();
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
});
});
describe("Show in All Traffic, in Encrypted's menu", () => {
test("shows encrypted connections in All traffic by default", () => {
expect(createStore().get(hideEncryptedAtom)).toBe(false);
});
test("leaves encrypted connections out of All traffic, with System or alone", () => {
const store = createStore();
store.set(allTrafficShownAtom, 5);
store.set(hideEncryptedAtom, true);
expect(store.get(activeQueryAtom)).toEqual({
query: "",
hideSystem: false,
hideEncrypted: true,
});
expect(store.get(allTrafficTotalAtom)).toBe(5);
store.set(hideSystemAtom, true);
expect(store.get(activeQueryAtom)).toEqual({
query: "",
hideSystem: true,
hideEncrypted: true,
});
store.set(hideEncryptedAtom, false);
store.set(hideSystemAtom, false);
expect(store.get(activeQueryAtom)).toBeNull();
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import { chooseOptions, connectionCount, type StripInput, stripState } from "./httpsStrip";
const BUILT_INS: DecryptListEntry[] = [
{ entry: "!host:*.apple.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.1password.com", builtIn: true, kind: "pinned" },
];
const base: StripInput = {
list: BUILT_INS,
encrypted: 22,
dismissed: false,
trusted: false,
canTrust: true,
};
describe("the strip", () => {
test("shows while nothing is decrypted and something is encrypted", () => {
expect(stripState(base)).toBe("offer");
expect(stripState({ ...base, trusted: true })).toBe("offer");
});
test("goes once something is listed, with nothing encrypted, or dismissed", () => {
const listed = [{ entry: "app:Dia", builtIn: false }, ...BUILT_INS];
expect(stripState({ ...base, list: listed, trusted: true })).toBe("hidden");
expect(stripState({ ...base, encrypted: 0 })).toBe("hidden");
expect(stripState({ ...base, dismissed: true })).toBe("hidden");
// Exclusions alone decrypt nothing
const excluded = [{ entry: "!app:Dia", builtIn: false }, ...BUILT_INS];
expect(stripState({ ...base, list: excluded })).toBe("offer");
});
test("comes back broken, however dismissed, while something listed can't be decrypted", () => {
const listed = [{ entry: "*", builtIn: false }, ...BUILT_INS];
expect(stripState({ ...base, list: listed, dismissed: true })).toBe("broken");
// Not where the app can't trust it itself, nor before the status is read
expect(stripState({ ...base, list: listed, canTrust: false })).toBe("hidden");
expect(stripState({ ...base, list: listed, trusted: null })).toBe("hidden");
});
test("counts connections", () => {
expect(connectionCount(1)).toBe("1 connection");
expect(connectionCount(1200)).toBe("1,200 connections");
});
});
describe("Choose…", () => {
const flow = (url: string, app: Partial<Flow>, tunnelled = true) => ({
url,
tunnelled,
clientName: null,
clientBundleId: null,
clientPath: null,
...app,
});
const dia = { clientName: "Dia", clientBundleId: "company.dia", clientPath: "/A/Dia.app" };
const slack = { clientName: "Slack", clientBundleId: "com.slack", clientPath: "/A/Slack.app" };
const flows = [
flow("www.google.com:443", dia),
flow("fonts.gstatic.com:443", dia),
flow("clients2.google.com:443", dia),
flow("edgeapi.slack.com:443", slack),
// Decrypted already, so not one of the encrypted
flow("https://slack.com/api", slack, false),
// A daemon, and what a built-in keeps out, are deliberate choices elsewhere
flow("gateway.icloud.com:443", { clientName: "cloudd", clientPath: "/usr/libexec/cloudd" }),
flow("my.1password.com:443", { clientName: "1Password", clientBundleId: "com.1password" }),
];
test("lists apps and sites seen, most connections first, checked while decrypted", () => {
const list = [{ entry: "host:*.slack.com", builtIn: false }, ...BUILT_INS];
const { apps, sites } = chooseOptions(flows, list);
expect(apps.map((a) => [a.name, a.count, a.checked])).toEqual([
["Dia", 3, false],
["Slack", 1, false],
]);
expect(sites.map((s) => [s.site, s.count, s.checked])).toEqual([
["google.com", 2, false],
["gstatic.com", 1, false],
["slack.com", 1, true],
]);
});
});
+107
View File
@@ -0,0 +1,107 @@
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
import { decryptsAnything, entryMatches, isDecrypted } from "./decrypt";
import { connectionOf } from "./encryptedCard";
import { isSystemFlow, siteOf } from "./search";
/** What the strip above the list shows */
export type StripState =
/** Not shown */
| "hidden"
/** HTTPS is encrypted, with Decrypt Everything and Choose… */
| "offer"
/** The list decrypts something and the certificate isn't trusted: Trust, however dismissed */
| "broken";
export interface StripInput {
list: DecryptListEntry[];
/** Encrypted connections in the capture */
encrypted: number;
/** Its × was clicked, remembered */
dismissed: boolean;
/** The certificate is trusted, null until read */
trusted: boolean | null;
/** The app can trust the certificate itself, as on macOS */
canTrust: boolean;
}
/**
* Shows while nothing is decrypted and some HTTPS is encrypted, until dismissed; goes once
* anything is on the list, from anywhere. A list that decrypts something while the certificate
* isn't trusted is broken, and says so whatever else holds.
*/
export function stripState({
list,
encrypted,
dismissed,
trusted,
canTrust,
}: StripInput): StripState {
const listed = decryptsAnything(list);
if (listed && canTrust && trusted === false) return "broken";
if (listed || encrypted === 0 || dismissed) return "hidden";
return "offer";
}
/** "22 connections" */
export function connectionCount(n: number): string {
return `${n.toLocaleString()} ${n === 1 ? "connection" : "connections"}`;
}
export interface ChooseApp {
name: string;
bundleId: string | null;
path: string | null;
count: number;
checked: boolean;
}
export interface ChooseSite {
site: string;
count: number;
checked: boolean;
}
/** At most this many apps, and sites, in Choose… */
const CHOOSE_MAX = 8;
/**
* Choose…'s rows: the apps and sites of the encrypted connections seen, most connections first,
* each checked while the list decrypts it. System's daemons and what a built-in exclusion keeps
* out are left out, as decrypting those is deliberate, from the card or Settings.
*/
export function chooseOptions(
flows: Pick<Flow, "url" | "tunnelled" | "clientName" | "clientBundleId" | "clientPath">[],
list: DecryptListEntry[],
): { apps: ChooseApp[]; sites: ChooseSite[] } {
const builtIns = list.filter((e) => e.builtIn && e.entry.startsWith("!"));
const apps = new Map<string, ChooseApp>();
const sites = new Map<string, ChooseSite>();
for (const flow of flows) {
if (!flow.tunnelled || isSystemFlow(flow)) continue;
const c = connectionOf(flow);
const q = { host: c.host, port: c.port, app: c.app };
if (builtIns.some((e) => entryMatches(e.entry, q))) continue;
if (c.app != null) {
const app = apps.get(c.app.name) ?? {
name: c.app.name,
bundleId: c.app.bundleId,
path: flow.clientPath,
count: 0,
checked: isDecrypted(list, { kind: "app", ...c.app }),
};
app.count++;
apps.set(app.name, app);
}
const name = siteOf(c.label);
const site = sites.get(name) ?? {
site: name,
count: 0,
checked: isDecrypted(list, { kind: "site", site: name }),
};
site.count++;
sites.set(name, site);
}
const top = <T extends { count: number }>(rows: Iterable<T>) =>
[...rows].sort((a, b) => b.count - a.count).slice(0, CHOOSE_MAX);
return { apps: top(apps.values()), sites: top(sites.values()) };
}
+2 -2
View File
@@ -14,8 +14,8 @@ describe("lockedLine", () => {
test("gives any other reason without the title's words", () => {
const lock = {
keychain: false,
message: "Traffic is locked: the proxy is already running in the yaak CLI (pid 4).",
message: "Traffic is locked: the proxy is already running in the CLI (pid 4).",
};
expect(lockedLine(lock, "macos")).toBe("The proxy is already running in the yaak CLI (pid 4).");
expect(lockedLine(lock, "macos")).toBe("The proxy is already running in the CLI (pid 4).");
});
});
+1 -1
View File
@@ -271,7 +271,7 @@ describe("syncSidebarSelection", () => {
test("All traffic is exclusive either way round", () => {
const { store, selected, idOf, choose } = sidebar();
const [all, github] = [idOf("All traffic"), idOf("github.com")];
const [all, github] = [idOf("All Traffic"), idOf("github.com")];
choose([github]);
choose([github, all]);
expect(store.get(selected)).toEqual([all]);
+2 -2
View File
@@ -20,7 +20,7 @@ describe("opener", () => {
await revealPath("/data/app.yaak.proxy", report);
expect(opener.revealItemInDir).toHaveBeenCalledWith("/data/app.yaak.proxy");
expect(report).toHaveBeenCalledWith(
"Failed to show /data/app.yaak.proxy: not allowed: opener.reveal_item_in_dir",
"Failed to reveal /data/app.yaak.proxy: not allowed: opener.reveal_item_in_dir",
);
});
@@ -43,6 +43,6 @@ describe("opener", () => {
test("names the file manager by platform", () => {
expect(revealLabel("macos")).toBe("Reveal in Finder");
expect(revealLabel("windows")).toBe("Show in Explorer");
expect(revealLabel("linux")).toBe("Show in Folder");
expect(revealLabel("linux")).toBe("Show in File Manager");
});
});
+2 -2
View File
@@ -8,7 +8,7 @@ export async function revealPath(path: string, report: ReportFailure): Promise<v
try {
await revealItemInDir(path);
} catch (err) {
report(`Failed to show ${path}: ${String(err)}`);
report(`Failed to reveal ${path}: ${String(err)}`);
}
}
@@ -25,5 +25,5 @@ export async function openLink(url: string, report: ReportFailure): Promise<void
export function revealLabel(os: string): string {
if (os === "macos") return "Reveal in Finder";
if (os === "windows") return "Show in Explorer";
return "Show in Folder";
return "Show in File Manager";
}
+11 -2
View File
@@ -55,8 +55,17 @@ describe("terms", () => {
describe("activeQuery", () => {
test("is null for all traffic, and carries hiding System", () => {
expect(activeQuery(" ", false)).toBeNull();
expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true });
expect(activeQuery(" stripe ", false)).toEqual({ query: "stripe", hideSystem: false });
expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true, hideEncrypted: false });
expect(activeQuery(" stripe ", false)).toEqual({
query: "stripe",
hideSystem: false,
hideEncrypted: false,
});
expect(activeQuery("", false, true)).toEqual({
query: "",
hideSystem: false,
hideEncrypted: true,
});
});
});
+10 -4
View File
@@ -3,17 +3,23 @@ import { formatField, parseQuery } from "@yaakapp-internal/query";
/**
* A search over every flow in the database, as `list_flows` takes it: the search box's text,
* which the backend parses and runs, and whether System's flows are hidden.
* which the backend parses and runs, and whether System's flows and encrypted connections are
* hidden.
*/
export interface FlowQuery {
query: string;
hideSystem: boolean;
hideEncrypted: boolean;
}
/** The search the list shows, or null for all traffic */
export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null {
if (text.trim() === "" && !hideSystem) return null;
return { query: text.trim(), hideSystem };
export function activeQuery(
text: string,
hideSystem: boolean,
hideEncrypted = false,
): FlowQuery | null {
if (text.trim() === "" && !hideSystem && !hideEncrypted) return null;
return { query: text.trim(), hideSystem, hideEncrypted };
}
/** `@field:value`, or `-@field:value`, the value quoted when it needs to be */
+1 -1
View File
@@ -41,7 +41,7 @@ export async function loadSeenValues(paths: string[]): Promise<Record<string, st
const PROBLEM_LABELS: Record<QueryDiagnostic["kind"], string> = {
unknownField: "Unknown field",
invalidValue: "Invalid value",
notYet: "Not yet",
notYet: "Not supported",
};
/** The terms of a search the backend cannot run, as the box underlines them */
+12
View File
@@ -67,6 +67,18 @@ describe("proxySettingsView", () => {
]);
expect(view.app.name).toBe("Yaak Proxy");
});
test("shows Updates in a build with the updater, without Yaak's notifications row", () => {
const view = proxySettingsView(DEFAULT_SETTINGS_VALUES, APP, "light", { checking: true });
expect(view.updates).toEqual({ checking: true, notificationsSetting: false });
});
test("follows the beta channel until set otherwise", () => {
expect(DEFAULT_SETTINGS_VALUES.updateChannel).toBe("beta");
expect(readSettingsValues(JSON.stringify({ updateChannel: "stable" })).updateChannel).toBe(
"stable",
);
});
});
describe("size options", () => {
+9 -4
View File
@@ -4,6 +4,7 @@ import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme/src
import type {
SettingsAppInfo,
SettingsThemeOption,
SettingsUpdatesView,
SettingsValues,
SettingsView,
} from "@yaakapp-internal/ui";
@@ -22,7 +23,8 @@ export const DEFAULT_SETTINGS_VALUES: SettingsValues = {
coloredMethods: false,
useNativeTitlebar: false,
hideWindowControls: false,
updateChannel: "stable",
// Early access ships on Yaak's beta tags, so the proxy follows beta until it goes stable
updateChannel: "beta",
autoupdate: true,
autoDownloadUpdates: true,
checkNotifications: true,
@@ -70,13 +72,15 @@ export function resolveAppearance(appearance: string, system: "light" | "dark"):
/**
* The proxy's settings as the shared tabs render them. The proxy has Yaak's two themes, no
* installed-font list, updater or feedback prompts yet, and its license comes with the license
* work, so those parts are left out, as are the Interface rows it doesn't act on.
* installed-font list or feedback prompts yet, and its license comes with the license work, so
* those parts are left out, as are the Interface rows it doesn't act on. `updates` is null in
* a build without the updater, which leaves its section out too.
*/
export function proxySettingsView(
values: SettingsValues,
app: SettingsAppInfo,
systemAppearance: "light" | "dark",
updates: SettingsUpdatesView | null = null,
): SettingsView {
const light = THEMES.find((t) => t.id === values.themeLight && !t.dark) ?? lightOption();
const dark = THEMES.find((t) => t.id === values.themeDark && t.dark) ?? darkOption();
@@ -93,7 +97,8 @@ export function proxySettingsView(
},
fonts: null,
windowControlsSetting: false,
updates: null,
// The proxy fetches no notifications, so its Updates section has no row for them
updates: updates && { ...updates, notificationsSetting: false },
feedbackPrompts: false,
license: null,
// Not acted on in the proxy yet: its editors take no keymap or wrap setting, its list draws
+16 -9
View File
@@ -50,7 +50,7 @@ describe("buildSidebarTree", () => {
test("groups apps, programs by folder, sites and System", () => {
expect((root.children ?? []).flatMap((c) => shape(c))).toEqual([
"All traffic 40",
"All Traffic 40",
"Apps 3",
" node (fieldwork) 2",
" github.com 2",
@@ -215,13 +215,20 @@ describe("decrypting from the sidebar", () => {
const root = buildSidebarTree([...flows, tunnel], 5);
const items = itemsById(root);
test("Not decrypted follows All traffic while there are tunnelled flows", () => {
expect((root.children ?? []).slice(0, 2).map((c) => shape(c)[0])).toEqual([
"All traffic 5",
"Not decrypted 1",
]);
expect(items.get("tunnelled")?.query).toBe("@is:tunnelled");
expect(itemsById(buildSidebarTree(flows, 4)).has("tunnelled")).toBe(false);
test("Encrypted comes last, after System, while there are encrypted connections", () => {
expect((root.children ?? []).map((c) => shape(c)[0]).at(-1)).toBe("Encrypted 1");
expect(items.get("encrypted")?.query).toBe("@is:encrypted");
expect(itemsById(buildSidebarTree(flows, 4)).has("encrypted")).toBe(false);
});
test("marks what is all encrypted, once something is decrypted", () => {
expect(items.get("host:bank.example.com")?.encryptedOnly).toBe(true);
expect(items.get("site:example.com")?.encryptedOnly).toBe(true);
// Slack has decrypted flows too
expect(items.get("app:Slack")?.encryptedOnly).toBeUndefined();
// In the first minute, when every row would be marked, none is
const onlyTunnels = itemsById(buildSidebarTree([tunnel], 1));
expect(onlyTunnels.get("host:bank.example.com")?.encryptedOnly).toBeUndefined();
});
test("hosts, sites and apps name what their menu's Decrypt covers", () => {
@@ -331,7 +338,7 @@ describe("saved searches", () => {
];
const root = buildSidebarTree(flows, 4, saved);
expect((root.children ?? []).slice(0, 2).flatMap((c) => shape(c))).toEqual([
"All traffic 4",
"All Traffic 4",
"Saved 0",
" Errors 12",
" Stripe 0",
+50 -27
View File
@@ -14,7 +14,7 @@ import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf
export type SidebarKind =
| "all"
| "tunnelled"
| "encrypted"
| "saved"
| "group"
| "app"
@@ -48,6 +48,11 @@ export interface SidebarItem {
value?: string;
/** What Decrypt in the row's menu decrypts: its host, site or app */
decrypt?: DecryptSubject;
/**
* Every flow under the row is encrypted, marked once something in the capture is decrypted,
* as in the first minute every row would be
*/
encryptedOnly?: boolean;
/** The saved search a Saved row is, for renaming and deleting it */
savedId?: string;
/**
@@ -71,7 +76,7 @@ export const SAVED_ID = "saved";
export const APPS_ID = "apps";
export const DOMAINS_ID = "domains";
export const SYSTEM_ID = "system";
export const TUNNELLED_ID = "tunnelled";
export const ENCRYPTED_ID = "encrypted";
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
interface PlaceRow {
@@ -196,12 +201,14 @@ function programPath(flow: Flow): string | undefined {
interface Trie {
item: SidebarItem;
children: Map<string, Trie>;
/** Encrypted flows under it */
encrypted?: number;
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** Counts a flow under `parent`'s child `row`, making the child the first time */
function child(parent: Trie, row: PlaceRow): Trie {
function child(parent: Trie, row: PlaceRow, encrypted: boolean): Trie {
let node = parent.children.get(row.id);
if (node == null) {
node = {
@@ -224,13 +231,25 @@ function child(parent: Trie, row: PlaceRow): Trie {
delete node.item.path;
}
node.item.count++;
if (encrypted) node.encrypted = (node.encrypted ?? 0) + 1;
return node;
}
/** Adds a flow under `parent` by where it went: its site, then its host, then its path */
function addPlace(parent: Trie, rows: PlaceRow[]) {
function addPlace(parent: Trie, rows: PlaceRow[], encrypted: boolean) {
let node = parent;
for (const row of rows) node = child(node, row);
for (const row of rows) node = child(node, row, encrypted);
}
/** Marks the apps, sites and hosts whose every flow is encrypted */
function markEncrypted(trie: Trie) {
for (const c of trie.children.values()) {
const kind = c.item.kind;
if ((kind === "app" || kind === "site" || kind === "host") && c.encrypted === c.item.count) {
c.item.encryptedOnly = true;
}
markEncrypted(c);
}
}
function toNodes(
@@ -264,9 +283,8 @@ function group(id: string, label: string): Trie {
/**
* The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then
* Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with
* nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through
* undecrypted, when there is any, and Saved lists the saved searches, each with its count from
* the database. `total` is All traffic's count: every flow it covers in the database.
* nothing in it is left out. Encrypted, last, gathers the HTTPS passed through undecrypted,
* when there is any, and Saved lists the saved searches, each with its count from the database. `total` is All traffic's count: every flow it covers in the database.
*/
export function buildSidebarTree(
flows: Flow[],
@@ -284,12 +302,12 @@ export function buildSidebarTree(
const domains = group(DOMAINS_ID, "Domains");
const system = { ...group(SYSTEM_ID, "System") };
system.item.query = fieldTerm("is", "system");
let tunnelled = 0;
let encrypted = 0;
for (const flow of flows) {
const place = placeOf(flow);
if (flow.tunnelled) tunnelled++;
addPlace(domains, place.rows);
if (flow.tunnelled) encrypted++;
addPlace(domains, place.rows, flow.tunnelled);
if (place.app == null) continue;
const parent = place.system ? system : apps;
const id = `app:${place.app}`;
@@ -318,31 +336,22 @@ export function buildSidebarTree(
delete app.item.path;
}
app.item.count++;
if (flow.tunnelled) app.encrypted = (app.encrypted ?? 0) + 1;
parent.item.count++;
addPlace(app, place.appRows);
addPlace(app, place.appRows, flow.tunnelled);
}
// Once something is decrypted: a refused handshake isn't, so it doesn't count
if (encrypted > 0 && flows.some((f) => !f.tunnelled && f.tlsRejected == null)) {
for (const g of [apps, domains, system]) markEncrypted(g);
}
for (const site of domains.children.values()) domains.item.count += site.children.size;
root.children?.push({
item: { id: ALL_ID, kind: "all", label: "All traffic", count: total, query: "" },
item: { id: ALL_ID, kind: "all", label: "All Traffic", count: total, query: "" },
parent: root,
depth: 1,
draggable: false,
});
if (tunnelled > 0) {
root.children?.push({
item: {
id: TUNNELLED_ID,
kind: "tunnelled",
label: "Not decrypted",
count: tunnelled,
query: fieldTerm("is", "tunnelled"),
},
parent: root,
depth: 1,
draggable: false,
});
}
if (saved.length > 0) {
const node: TreeNode<SidebarItem> = {
item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" },
@@ -378,6 +387,20 @@ export function buildSidebarTree(
node.children = toNodes(g.children.values(), node);
root.children?.push(node);
}
if (encrypted > 0) {
root.children?.push({
item: {
id: ENCRYPTED_ID,
kind: "encrypted",
label: "Encrypted",
count: encrypted,
query: fieldTerm("is", "encrypted"),
},
parent: root,
depth: 1,
draggable: false,
});
}
return root;
}
+32 -13
View File
@@ -81,9 +81,14 @@ export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, unde
getOnInit: true,
});
/** Encrypted connections are left out of All traffic, by Show in All Traffic in Encrypted's menu */
export const hideEncryptedAtom = atomWithStorage("sidebar_hide_encrypted", false, undefined, {
getOnInit: true,
});
/** The search the list shows, from the filter applied, or null for all traffic */
export const activeQueryAtom = atom<FlowQuery | null>((get) =>
activeQuery(get(appliedFilterAtom), get(hideSystemAtom)),
activeQuery(get(appliedFilterAtom), get(hideSystemAtom), get(hideEncryptedAtom)),
);
/** The saved searches, in their order */
@@ -103,13 +108,16 @@ const savedRowsAtom = atom<SavedRow[]>((get) => {
}));
});
/** Flows All traffic covers in the database: every one, or all but System's while hidden */
/**
* Flows All traffic covers in the database: every one, or all but System's and the encrypted
* connections while either is hidden
*/
export const allTrafficTotalAtom = atom((get) =>
get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom),
get(hideSystemAtom) || get(hideEncryptedAtom) ? get(allTrafficShownAtom) : get(flowTotalAtom),
);
/** Flows in the database not from System, counted while System is hidden */
export const nonSystemTotalAtom = atom(0);
/** Flows in the database All traffic shows, counted while System or Encrypted is hidden */
export const allTrafficShownAtom = atom(0);
/**
* The flows the sidebar counts: the window's, taken at most once a second, so a burst of traffic
@@ -450,9 +458,11 @@ export function syncFlows(store: Store) {
win.total === res.total ? win : { ...win, total: res.total },
);
});
if (store.get(hideSystemAtom)) {
void rpc("list_flows", { hideSystem: true, limit: 0 }).then((res) => {
if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total);
const hideSystem = store.get(hideSystemAtom);
const hideEncrypted = store.get(hideEncryptedAtom);
if (hideSystem || hideEncrypted) {
void rpc("list_flows", { hideSystem, hideEncrypted, limit: 0 }).then((res) => {
if (thisLoad === loads) store.set(allTrafficShownAtom, res.matching ?? res.total);
});
}
countSaved();
@@ -485,7 +495,10 @@ export function syncFlows(store: Store) {
return;
}
counting = true;
rpc("count_saved_queries", { hideSystem: store.get(hideSystemAtom) })
rpc("count_saved_queries", {
hideSystem: store.get(hideSystemAtom),
hideEncrypted: store.get(hideEncryptedAtom),
})
.then((counts) =>
store.set(savedCountsAtom, Object.fromEntries(counts.map((c) => [c.id, c.count]))),
)
@@ -643,9 +656,12 @@ export function syncFlows(store: Store) {
// Read again, it keeps showing its rows until the new ones are in.
const win = (keepRows ? store.get(searchWindowAtom)?.win : null) ?? emptyWindow;
store.set(searchWindowAtom, { key, query, win, loading: true, buffered: [] });
void rpc("watch_query", { key, query: query.query, hideSystem: query.hideSystem }).catch(
(err) => console.warn("Failed to watch the search", err),
);
void rpc("watch_query", {
key,
query: query.query,
hideSystem: query.hideSystem,
hideEncrypted: query.hideEncrypted,
}).catch((err) => console.warn("Failed to watch the search", err));
const settle = (page: FlowWindow | null) =>
store.set(searchWindowAtom, (s) => {
if (s?.key !== key) return s;
@@ -693,6 +709,9 @@ export function syncFlows(store: Store) {
store.sub(hideSystemAtom, () => {
if (loaded) recount();
});
store.sub(hideEncryptedAtom, () => {
if (loaded) recount();
});
// A search counting back from now, `@time:<5m`, is read again while it is shown and the list
// follows new traffic, so flows leave it and join it as they age. Saved ones are counted
@@ -772,7 +791,7 @@ let reloadFlows: (() => void) | null = null;
/** Empties the list, the search's too, and their counts, as a clear leaves them */
const emptyFlowsAtom = atom(null, (_get, set) => {
set(flowWindowAtom, emptyWindow);
set(nonSystemTotalAtom, 0);
set(allTrafficShownAtom, 0);
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: emptyWindow, buffered: [] }));
set(sortedWindowAtom, (s) =>
s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] },
+23 -18
View File
@@ -14,12 +14,12 @@ describe("systemProxySwitch", () => {
test("says whether this Mac's traffic is routed now", () => {
expect(systemProxySwitch({ ...off, routing: true })).toEqual({
checked: true,
description: "Routing this Mac's web traffic through Yaak Proxy",
description: "Traffic from every app will be recorded.",
failed: false,
});
expect(systemProxySwitch(off)).toEqual({
checked: false,
description: "Not routing. Only apps pointed at port 9090 are captured",
description: "Only traffic sent to port 9090 will be recorded.",
failed: false,
});
});
@@ -27,7 +27,7 @@ describe("systemProxySwitch", () => {
test("shows the new value while the password prompt is up", () => {
expect(systemProxySwitch({ ...off, pending: true })).toEqual({
checked: true,
description: "Waiting for your password",
description: "Waiting for macOS",
failed: false,
});
});
@@ -41,9 +41,9 @@ describe("systemProxySwitch", () => {
});
test("snaps back on a cancelled prompt and says so", () => {
expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({
expect(systemProxySwitch({ ...off, error: "The password prompt was cancelled." })).toEqual({
checked: false,
description: "Cancelled in the password prompt",
description: "The password prompt was cancelled.",
failed: true,
});
expect(systemProxySwitch({ ...off, error: "networksetup failed" }).description).toBe(
@@ -53,27 +53,32 @@ describe("systemProxySwitch", () => {
});
describe("helperRow", () => {
test("offers each next step, and says which way changes go", () => {
test("offers each next step, and says what the helper is for", () => {
expect(helperRow("not_registered", "admin_prompt")).toMatchObject({
title: "Helper: not installed",
description: "The helper is not installed.",
action: "install",
});
expect(helperRow("requires_approval", "admin_prompt")).toMatchObject({
title: "Helper: awaiting approval",
description: "The helper is awaiting approval.",
action: "approve",
});
const enabled = helperRow("enabled", "helper");
expect(enabled).toMatchObject({ title: "Helper: enabled", action: "unregister" });
expect(enabled.description).toMatch(/No password/);
expect(enabled).toMatchObject({
description: "The helper is installed.",
action: "unregister",
});
expect(enabled.tooltip).toMatch(/without a password prompt/);
});
test("says when the password prompt still has settings to put back", () => {
expect(helperRow("enabled", "admin_prompt").description).toMatch(/password prompt/);
expect(helperRow("enabled", "admin_prompt").tooltip).toBe(
"One more password prompt is needed to restore the system proxy settings.",
);
});
test("offers to install when macOS can't find it, rather than blaming a dev build", () => {
expect(helperRow("not_found", "admin_prompt")).toMatchObject({
title: "Helper: not found by macOS",
description: "macOS could not find the helper.",
action: "install",
});
});
@@ -89,13 +94,13 @@ describe("helperRow", () => {
for (const state of states) {
for (const backend of ["helper", "admin_prompt"] as const) {
const row = helperRow(state, backend);
expect(row.title.length).toBeLessThanOrEqual(30);
expect(row.description.length).toBeLessThanOrEqual(60);
expect(row.tooltip.length).toBeLessThanOrEqual(80);
}
}
});
test("has a titled row for every state the backend sends", () => {
test("has a row for every state the backend sends", () => {
// A missing key here fails the typecheck, so a new state can't be left out.
const states: Record<HelperState, true> = {
unavailable: true,
@@ -106,7 +111,7 @@ describe("helperRow", () => {
};
for (const state of Object.keys(states) as HelperState[]) {
for (const backend of ["helper", "admin_prompt", "sudo"] as const) {
expect(helperRow(state, backend).title).toMatch(/^Helper: \S/);
expect(helperRow(state, backend).description).toMatch(/helper.*\.$/);
}
}
});
@@ -114,15 +119,15 @@ describe("helperRow", () => {
test("still gives a row for a state this build doesn't know", () => {
const row = helperRow("from_the_future" as HelperState, "admin_prompt");
expect(row).toEqual({
title: "Helper: from_the_future",
description: "Changes use the password prompt.",
description: "The helper is from_the_future.",
tooltip: "The helper changes the system proxy without a password prompt.",
action: null,
});
});
test("says plainly that a dev build cannot use it", () => {
expect(helperRow("unavailable", "admin_prompt")).toMatchObject({
title: "Helper: not in dev builds",
description: "The helper is not available in dev builds.",
action: null,
});
});
+26 -22
View File
@@ -28,7 +28,7 @@ export interface SystemProxySwitch {
/** What the system proxy switch shows */
export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch {
if (state.pending != null) {
const description = state.helper ? "Changing the system proxy" : "Waiting for your password";
const description = state.helper ? "Changing the system proxy" : "Waiting for macOS";
return { checked: state.pending, description, failed: false };
}
// A failed change leaves the switch where the system proxy actually is.
@@ -37,71 +37,75 @@ export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch {
const cancelled = /cancel/i.test(state.error);
return {
checked,
description: cancelled ? "Cancelled in the password prompt" : state.error,
description: cancelled ? "The password prompt was cancelled." : state.error,
failed: true,
};
}
return {
checked,
description: checked
? "Routing this Mac's web traffic through Yaak Proxy"
: `Not routing. Only apps pointed at port ${state.port ?? 9090} are captured`,
? "Traffic from every app will be recorded."
: `Only traffic sent to port ${state.port ?? 9090} will be recorded.`,
failed: false,
};
}
export interface HelperRow {
title: string;
/** Which way changes go, and why */
/** The helper's state, in one sentence */
description: string;
/** What the helper is for, or what this state still needs, behind the row's info icon */
tooltip: string;
/** What the row's button does, if it has one: the first two open the approval dialog */
action: "install" | "approve" | "unregister" | null;
}
/** The privileged helper's row in the state pill menu, kept short */
/** What the helper is for, the same in every state but one */
const HELPER_TOOLTIP = "The helper changes the system proxy without a password prompt.";
/** The privileged helper's row in Settings, kept short */
export function helperRow(helper: HelperState, backend: SystemProxyBackend): HelperRow {
switch (helper) {
case "unavailable":
return {
title: "Helper: not in dev builds",
description: "Changes use the password prompt.",
description: "The helper is not available in dev builds.",
tooltip: HELPER_TOOLTIP,
action: null,
};
case "not_found":
// What macOS reports until the helper is first installed.
return {
title: "Helper: not found by macOS",
description: "Changes use the password prompt.",
description: "macOS could not find the helper.",
tooltip: HELPER_TOOLTIP,
action: "install",
};
case "not_registered":
return {
title: "Helper: not installed",
description: "Changes use the password prompt.",
description: "The helper is not installed.",
tooltip: HELPER_TOOLTIP,
action: "install",
};
case "requires_approval":
return {
title: "Helper: awaiting approval",
description: "Changes use the password prompt.",
description: "The helper is awaiting approval.",
tooltip: HELPER_TOOLTIP,
action: "approve",
};
case "enabled":
return {
title: "Helper: enabled",
description:
description: "The helper is installed.",
tooltip:
backend === "helper"
? "No password prompts."
: "One more password prompt puts back your settings.",
? HELPER_TOOLTIP
: "One more password prompt is needed to restore the system proxy settings.",
action: "unregister",
};
default: {
// Every state is handled above; one this build doesn't know still gets a row, rather
// than taking the whole menu down.
// than taking the whole page down.
const unknown: never = helper;
return {
title: `Helper: ${String(unknown)}`,
description: "Changes use the password prompt.",
description: `The helper is ${String(unknown)}.`,
tooltip: HELPER_TOOLTIP,
action: null,
};
}
+13
View File
@@ -24,6 +24,19 @@ export function subscribe<T>(event: string, callback: (payload: T) => void): ()
return subscription.unsubscribe;
}
/**
* Subscribe to a Tauri event sent to this window, such as the update prompt, which belongs to
* the window that asked. Returns an unsubscribe function, as `subscribe` does.
*/
export function subscribeHere<T>(event: string, callback: (payload: T) => void): () => void {
const subscription = unsubscribeWhenReady(
getCurrentWebviewWindow().listen<T>(event, (e) => {
if (subscription.active()) callback(e.payload);
}),
);
return subscription.unsubscribe;
}
/** Calls `callback` each time the window regains focus. Returns an unsubscribe function. */
export function onWindowFocus(callback: () => void): () => void {
const subscription = unsubscribeWhenReady(
+87
View File
@@ -0,0 +1,87 @@
import { toastsAtom } from "@yaakapp-internal/ui/toast";
import { atom, createStore } from "jotai";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { DEFAULT_SETTINGS_VALUES } from "./settings";
const command = vi.fn<(cmd: string, args?: Record<string, unknown>) => Promise<unknown>>();
vi.mock("./tauri", () => ({
command: (cmd: string, args?: Record<string, unknown>) => command(cmd, args),
broadcast: vi.fn(),
onWindowFocus: vi.fn(),
subscribeHere: vi.fn(),
}));
vi.mock("./opener", () => ({ openLink: vi.fn() }));
// Outside the app there's no Tauri for the shared components' platform
vi.mock("@yaakapp-internal/platform", () => ({ platform: { osType: () => "macos" } }));
vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "2026.11.0" }));
// The real atom reads local storage as it loads, which tests have none of
vi.mock("./interfaceSettings", () => ({ settingsValuesAtom: atom(DEFAULT_SETTINGS_VALUES) }));
const { checkArgs, checkForUpdatesNow, updateCheckingAtom } = await import("./updates");
async function messages(store: ReturnType<typeof createStore>) {
// Toasts are added on the next tick
await new Promise((resolve) => setTimeout(resolve, 0));
return store.get(toastsAtom).map((t) => ({ color: t.color, message: t.message }));
}
describe("checkArgs", () => {
test("sends the channel and download choice the settings make", () => {
expect(checkArgs(DEFAULT_SETTINGS_VALUES, false)).toEqual({
channel: "beta",
background: false,
autoDownload: true,
});
expect(
checkArgs(
{ ...DEFAULT_SETTINGS_VALUES, updateChannel: "stable", autoDownloadUpdates: false },
true,
),
).toEqual({ channel: "stable", background: true, autoDownload: false });
});
});
describe("checkForUpdatesNow", () => {
beforeEach(() => {
command.mockReset();
});
test("says when there is nothing newer", async () => {
command.mockResolvedValue(false);
const store = createStore();
await checkForUpdatesNow(store);
expect(command).toHaveBeenCalledWith("cmd_check_for_updates", {
channel: "beta",
background: false,
autoDownload: true,
});
expect(await messages(store)).toEqual([
{
color: "info",
message: "No update available. You are on the latest version, 2026.11.0.",
},
]);
expect(store.get(updateCheckingAtom)).toBe(false);
});
test("leaves an update found to the prompt the backend sends", async () => {
command.mockResolvedValue(true);
const store = createStore();
await checkForUpdatesNow(store);
expect(await messages(store)).toEqual([]);
});
test("says, in a toast rather than a dialog, when the server can't be reached", async () => {
command.mockRejectedValue("Updater error: error sending request");
const store = createStore();
await checkForUpdatesNow(store);
expect(await messages(store)).toEqual([
{
color: "danger",
message: "Could not check for updates: Updater error: error sending request",
},
]);
expect(store.get(updateCheckingAtom)).toBe(false);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { getVersion } from "@tauri-apps/api/app";
import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater";
import {
minPromiseMillis,
type SettingsValues,
showToast,
showUpdateAvailableToast,
showUpdateInstalledToast,
showUpdateStateToast,
} from "@yaakapp-internal/ui";
import { atom, type createStore } from "jotai";
import { settingsValuesAtom } from "./interfaceSettings";
import { openLink } from "./opener";
import { broadcast, command, onWindowFocus, subscribeHere } from "./tauri";
type Store = ReturnType<typeof createStore>;
const APP_NAME = "Yaak Proxy";
/** The events the updater sends the window that checked, as Yaak's are named */
const UPDATE_AVAILABLE_EVENT = "update_available";
const UPDATE_INSTALLED_EVENT = "update_installed";
/** Where an update already in hand stands, for a check asked for meanwhile */
const UPDATE_STATE_EVENT = "update_state";
/** The app menu's Check for Updates…, sent to the window it was chosen in */
const CHECK_FOR_UPDATES_EVENT = "menu_check_for_updates";
/** How long after launch, or the window coming forward, a background check waits, as Yaak's */
const BACKGROUND_CHECK_DELAY_MS = 3000;
/** A check the user asked for is running, for the Settings button's spinner */
export const updateCheckingAtom = atom(false);
/** Whether this build has the updater; development builds don't */
export function updaterEnabled(): Promise<boolean> {
return command<boolean>("cmd_updater_enabled");
}
/** What a check sends the backend: the channel and behavior the settings choose */
export function checkArgs(values: SettingsValues, background: boolean) {
return {
channel: values.updateChannel,
background,
autoDownload: values.autoDownloadUpdates,
};
}
/**
* Checks now, as the user asked, and says so when there is nothing newer or the server can't
* be reached. An update found is offered by the prompt the backend sends this window.
*/
export async function checkForUpdatesNow(store: Store): Promise<void> {
if (store.get(updateCheckingAtom)) return;
store.set(updateCheckingAtom, true);
try {
const args = checkArgs(store.get(settingsValuesAtom), false);
const found = await minPromiseMillis(command<boolean>("cmd_check_for_updates", args), 500);
if (!found) {
const version = await getVersion().catch(() => null);
showToast(store, {
id: "no-updates",
color: "info",
message:
version == null
? "No update available. You are on the latest version."
: `No update available. You are on the latest version, ${version}.`,
});
}
} catch (err) {
showToast(store, {
id: "no-updates",
color: "danger",
message: `Could not check for updates: ${String(err)}`,
});
} finally {
store.set(updateCheckingAtom, false);
}
}
/** A quiet check, as the settings allow; the backend keeps to the channel's interval */
function checkInBackground(store: Store) {
const values = store.get(settingsValuesAtom);
if (!values.autoupdate) return;
command<boolean>("cmd_check_for_updates", checkArgs(values, true)).catch((err) =>
console.warn("Failed to check for updates", err),
);
}
/** Offers an update the backend found, as Yaak does, and answers it */
async function offerUpdate(store: Store, info: UpdateInfo) {
const { replyEventId } = info;
const reply = (response: UpdateResponse) => broadcast(replyEventId, response);
// Acknowledged at once, so the backend doesn't fall back to its native dialog
if (info.install === "integrated") await reply({ type: "ack" });
showUpdateAvailableToast(store, {
appName: APP_NAME,
update: info,
onOpenUrl: (url) => openLink(url, (message) => showToast(store, { message, color: "danger" })),
onInstall: () => reply({ type: "action", action: "install" }),
});
}
/**
* Hears the updater in this window: the prompt for an update it found, the offer to relaunch
* once one installed, and the menu's Check for Updates…. With `background`, as the main window
* does, also checks shortly after launch and each time the window comes forward.
*/
export async function syncUpdates(store: Store, { background }: { background: boolean }) {
if (!(await updaterEnabled().catch(() => false))) return;
subscribeHere<UpdateInfo>(UPDATE_AVAILABLE_EVENT, (info) => {
offerUpdate(store, info).catch(console.error);
});
subscribeHere<UpdateState>(UPDATE_STATE_EVENT, (state) => {
showUpdateStateToast(store, { appName: APP_NAME, state });
});
subscribeHere<string>(UPDATE_INSTALLED_EVENT, (version) => {
showUpdateInstalledToast(store, {
appName: APP_NAME,
version,
onRelaunch: () => {
command<void>("cmd_restart").catch(console.error);
},
});
});
subscribeHere<void>(CHECK_FOR_UPDATES_EVENT, () => {
checkForUpdatesNow(store).catch(console.error);
});
if (background) {
const later = () => setTimeout(() => checkInBackground(store), BACKGROUND_CHECK_DELAY_MS);
later();
onWindowFocus(later);
}
}
+3 -3
View File
@@ -33,9 +33,9 @@ describe("windowSync", () => {
});
test("a change is announced as coming from this window", () => {
tauri.label = "settings";
tauri.label = "other__settings";
announceEngineChange();
expect(tauri.sent).toEqual([{ event: "window_changed_engine", payload: { from: "settings" } }]);
expect(tauri.sent).toEqual([{ event: "window_changed_engine", payload: { from: "other__settings" } }]);
});
test("a change in another window reads the state and the settings' queries again", () => {
@@ -44,7 +44,7 @@ describe("windowSync", () => {
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
syncFromOtherWindows(store, queryClient);
tauri.handlers.get("window_changed_engine")?.({ from: "settings" });
tauri.handlers.get("window_changed_engine")?.({ from: "other__settings" });
expect(refreshProxyState).toHaveBeenCalledWith(store);
expect(invalidate).toHaveBeenCalledTimes(1);
+2 -1
View File
@@ -3,7 +3,7 @@ import { openSettingsVia, requestedSettingsTab, windowKind } from "./windows";
describe("windowKind", () => {
test("the Settings window holds the settings", () => {
expect(windowKind("settings")).toBe("settings");
expect(windowKind("other__settings")).toBe("settings");
});
test("a main window holds the app", () => {
@@ -13,6 +13,7 @@ describe("windowKind", () => {
test("a page outside the app's windows holds the app on its own", () => {
expect(windowKind("harness")).toBe("page");
expect(windowKind("settings")).toBe("page");
expect(windowKind("settings_0")).toBe("page");
expect(windowKind("")).toBe("page");
});
+5 -2
View File
@@ -1,5 +1,8 @@
/** The Settings window's label, which `cmd_open_settings` gives it */
export const SETTINGS_WINDOW_LABEL = "settings";
/**
* The Settings window's label, which `cmd_open_settings` gives it: opened as a child of the
* main window, as Yaak's, it takes the prefix the shared window code gives child windows
*/
export const SETTINGS_WINDOW_LABEL = "other__settings";
/** The event that moves an open Settings window to another tab */
export const SETTINGS_TAB_EVENT = "settings_tab";
+3
View File
@@ -11,6 +11,7 @@ import { syncSettingsValues } from "./lib/interfaceSettings";
import { syncNavigation, syncSidebarSelection } from "./lib/navigation";
import { syncFlows, syncProxyState } from "./lib/store";
import { currentWindowLabel } from "./lib/tauri";
import { syncUpdates } from "./lib/updates";
import { syncFromOtherWindows } from "./lib/windowSync";
import { windowKind } from "./lib/windows";
import "./main.css";
@@ -22,6 +23,8 @@ const settingsWindow = windowKind(currentWindowLabel()) === "settings";
syncProxyState(jotaiStore);
syncSettingsValues(jotaiStore);
// Either window shows the updates it checks for; the app's window also checks in the background
syncUpdates(jotaiStore, { background: !settingsWindow }).catch(console.error);
syncFromOtherWindows(jotaiStore, queryClient);
// Only the app's window lists traffic and navigates it
if (!settingsWindow) {
+2
View File
@@ -87,7 +87,9 @@ protoc-bin-vendored = "3"
tonic = { version = "0.14.6", default-features = false }
tonic-reflection = "0.14.6"
reqwest = { workspace = true }
rustls = { workspace = true, features = ["ring"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] }
tokio-rustls = "0.26"
yaak-crypto = { workspace = true, features = ["test-support"] }
yaak-proxy-helper = { workspace = true, features = ["test-support"] }
+36
View File
@@ -147,6 +147,37 @@ graphqlOperation: string | null,
* the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set.
*/
tunnelled: boolean,
/**
* Why a tunnelled flow was not decrypted. None for other flows, and for tunnels recorded
* before the reason was.
*/
tunnelReason: TunnelReason | null,
/**
* For a tunnel the decrypt list excluded, the exclusion that kept it out, as stored:
* `!host:*.apple.com`.
*/
tunnelExcludedBy: string | null,
/**
* That exclusion is one of the built-in ones.
*/
tunnelExcludedBuiltIn: boolean,
/**
* For a tunnel passed through after a client rejected the proxy's certificate, the id of
* the flow of that refused handshake, when it was recorded.
*/
tunnelRejectedBy: string | null,
/**
* The proxy closed this tunnel on request, so its client would reconnect.
*/
reconnectRequested: boolean,
/**
* A client's refused TLS handshake, recorded as its host and port began to be passed
* through: how it refused, its alert (`unknown_ca`, `bad_certificate`,
* `certificate_unknown`, ...) or `hang_up`. Such a flow has no method, its URL is
* `https://<authority>`, and its error says it rejected the certificate. A handshake that
* failed otherwise is recorded the same way with this None and the failure as its error.
*/
tlsRejected: string | null,
/**
* An agent read the flow through the control API: its detail, a body or its messages.
* Never written by an upsert, which keeps what is stored.
@@ -291,3 +322,8 @@ key: string, matches: boolean, };
* The streaming protocol a flow carries.
*/
export type StreamKind = "websocket" | "sse" | "grpc" | "grpc_web";
/**
* Why a tunnel was passed through undecrypted.
*/
export type TunnelReason = "not_listed" | "excluded" | "rejected";
+148 -7
View File
@@ -1,5 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FilterHistoryEntry, Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery } from "./gen_models";
import type { FilterHistoryEntry, Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery, TunnelReason } from "./gen_models";
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
@@ -35,6 +35,23 @@ export type CaStatusRequest = Record<string, never>;
export type CaTrustRequest = Record<string, never>;
/**
* What came of asking to trust the CA, with its status after.
*/
export type CaTrustResult = {
/**
* The person cancelled the password prompt, so nothing changed. Not an error.
*/
cancelled: boolean,
/**
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
*/
trusted: boolean,
/**
* A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
*/
installed: boolean, certPath: string, };
export type ClearFilterHistoryRequest = Record<string, never>;
export type ClearFlowsRequest = Record<string, never>;
@@ -60,7 +77,7 @@ path: string,
*/
cwd: string | null, };
export type CountSavedQueriesRequest = { hideSystem?: boolean, };
export type CountSavedQueriesRequest = { hideSystem?: boolean, hideEncrypted?: boolean, };
export type CreateSavedQueryRequest = { name: string, query: string, };
@@ -115,7 +132,22 @@ entry: string,
/**
* Shipped with the app: an exclusion of Apple's services or a pinned app's hosts.
*/
builtIn: boolean, };
builtIn: boolean,
/**
* Why a built-in exclusion ships. None for the user's own entries.
*/
kind?: ExclusionKind, };
/**
* The decrypt list as saved, and what it means for connections already open.
*/
export type DecryptListSaved = {
/**
* Open connections the list now decrypts that stay encrypted until their clients
* reconnect: `reconnect_tunnels` closes them so they do. Closed by nothing else. Zero from
* an owner too old to count them.
*/
stillEncrypted: number, entries: Array<DecryptListEntry>, };
export type DeleteFilterHistoryRequest = { filter: string, };
@@ -131,6 +163,11 @@ export type DroppedHeader = { name: string, value: string, reason: DropReason, }
*/
export type EditDecryptListRequest = { add: Array<string>, remove: Array<string>, };
/**
* Why a built-in exclusion ships.
*/
export type ExclusionKind = "apple" | "pinned";
export type ExportHarRequest = {
/**
* The file to write.
@@ -171,6 +208,11 @@ query?: string,
* `@is:system` itself.
*/
hideSystem?: boolean,
/**
* Leaves out HTTPS connections passed through undecrypted, as hiding Encrypted does,
* unless the query says `@is:encrypted` itself.
*/
hideEncrypted?: boolean,
/**
* At most this many flows, the newest.
*/
@@ -216,6 +258,22 @@ export type FolderSummary = { id: string, name: string,
*/
folderId: string | null, };
/**
* An address to intercept again: `host`, `host:port` or `[::1]:8443`, as a flow's URL has
* it, the port 443 when left out.
*/
export type ForgetPassthroughRequest = { address: string, };
export type ForgetPassthroughResponse = {
/**
* It was passed through.
*/
forgotten: boolean,
/**
* The addresses still passed through.
*/
addresses: Array<string>, };
export type FormatBodyRequest = { text: string, };
export type GetDecryptListRequest = Record<string, never>;
@@ -391,6 +449,8 @@ messages: Array<FlowMessage>,
*/
total: number, };
export type ListPassthroughRequest = Record<string, never>;
export type ListSavedQueriesRequest = Record<string, never>;
export type ListSeenRequest = Record<string, never>;
@@ -419,6 +479,28 @@ export type OpenTerminalRequest = {
*/
vars: Array<[string, string]>, };
/**
* An open tunnel, as the app is told of it.
*/
export type OpenTunnel = {
/**
* Its id for closing it, unique to this run of the engine, so one listed before the
* engine restarted closes nothing after.
*/
id: string,
/**
* The host it leads to, without an IPv6 address's brackets.
*/
host: string, port: number,
/**
* The app that opened it, when known.
*/
appName: string | null, appBundleId: string | null,
/**
* Its flow, when it was recorded.
*/
flowId: string | null, reason: TunnelReason, };
export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
/**
* The engine's port while it runs.
@@ -430,6 +512,25 @@ proxyPort: number | null, };
*/
export type Owner = "app" | "cli";
/**
* The addresses passed through after clients rejected the CA changed: one was added or
* forgotten.
*/
export type PassthroughChangedPayload = {
/**
* Each `host:port`, an IPv6 host in brackets, sorted.
*/
addresses: Array<string>, };
/**
* The addresses passed through after clients rejected the CA.
*/
export type PassthroughResponse = {
/**
* Each `host:port`, an IPv6 host in brackets, sorted.
*/
addresses: Array<string>, };
export type PreviewSaveRequest = { flowId: string, };
/**
@@ -476,6 +577,31 @@ export type QueryValuesRequest = {
*/
path: string, };
/**
* Tunnels to close: those in `ids`, or, without them, every one `list_reconnectable_tunnels`
* returns for the same `entry` and `flowId`.
*/
export type ReconnectTunnelsRequest = {
/**
* `OpenTunnel` ids. Ones from an earlier run of the engine close nothing.
*/
ids?: Array<string>,
/**
* An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
* names. An exclusion, `!host:*.chase.com`, names those it would keep out.
*/
entry?: string,
/**
* Only the tunnel this flow records.
*/
flowId?: string, };
export type ReconnectTunnelsResponse = {
/**
* Tunnels closed: those still open of the ones asked for.
*/
closed: number, };
/**
* A filter applied from the search box, or from the history itself, put at its top.
*/
@@ -494,9 +620,9 @@ export type RenameSavedQueryRequest = { id: string, name: string, };
export type ResetCapturedDataRequest = Record<string, never>;
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, };
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, passthrough_changed: PassthroughChangedPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaTrustResult], forget_passthrough: [ForgetPassthroughRequest, ForgetPassthroughResponse], list_passthrough: [ListPassthroughRequest, PassthroughResponse], list_reconnectable_tunnels: [TunnelScope, Array<OpenTunnel>], reconnect_tunnels: [ReconnectTunnelsRequest, ReconnectTunnelsResponse], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptListSaved], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptListSaved], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
/**
@@ -546,7 +672,7 @@ droppedHeaders: Array<DroppedHeader>,
*/
keptHeaders: number,
/**
* The body as it is saved: "JSON", "form URL-encoded with 7 fields". None without a body.
* The body as it is saved: "JSON", "a URL-encoded form with 7 fields". None without a body.
*/
body: string | null,
/**
@@ -672,12 +798,27 @@ export type TakeOverRequest = Record<string, never>;
*/
export type TrafficRetention = "clearOnLaunch" | "keep";
/**
* Which open tunnels to consider: all of them, those one decrypt-list entry names, or the one
* a flow records.
*/
export type TunnelScope = {
/**
* An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
* names. An exclusion, `!host:*.chase.com`, names those it would keep out.
*/
entry?: string,
/**
* Only the tunnel this flow records.
*/
flowId?: string, };
export type ValidateQueryRequest = { query: string, };
/**
* The search the app shows, which flows written from now on are matched against, their events
* naming `key`. No query stops it.
*/
export type WatchQueryRequest = { key: string, query?: string, hideSystem?: boolean, };
export type WatchQueryRequest = { key: string, query?: string, hideSystem?: boolean, hideEncrypted?: boolean, };
export type WorkspaceSummary = { id: string, name: string, folders: Array<FolderSummary>, };
@@ -0,0 +1,14 @@
-- Why an HTTPS connection was passed through undecrypted: `not_listed`, `excluded` (with the
-- exclusion that kept it out, and whether that one is built in) or `rejected` (with the flow of
-- the refused handshake that started passing its address through, when that was recorded).
ALTER TABLE flows ADD COLUMN tunnel_reason TEXT;
ALTER TABLE flows ADD COLUMN tunnel_excluded_by TEXT;
ALTER TABLE flows ADD COLUMN tunnel_excluded_built_in BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE flows ADD COLUMN tunnel_rejected_by TEXT;
-- A tunnel the proxy closed on request so its client reconnects.
ALTER TABLE flows ADD COLUMN reconnect_requested BOOLEAN NOT NULL DEFAULT FALSE;
-- A client's refused TLS handshake with the proxy, recorded once as its address is passed
-- through: the client's alert (`unknown_ca`, `bad_certificate`, ...) or `hang_up`.
ALTER TABLE flows ADD COLUMN tls_rejected TEXT;
+1 -2
View File
@@ -12,8 +12,7 @@ use yaak_models::blob_manager::BlobManager;
const MESSAGE_PAGE: usize = 500;
/// What a WebSocket's response body reads as: it has none.
pub const WEBSOCKET_BODY_NOTE: &str =
"A WebSocket has no body after its 101 response. Its frames are its messages.";
pub const WEBSOCKET_BODY_NOTE: &str = "A WebSocket has no body";
/// Whether a flow's body is its stream's messages rather than bytes stored on their own: an
/// event stream's response, a gRPC call's request, and its response when the server answered
+50 -4
View File
@@ -13,6 +13,25 @@ pub struct CaStatus {
pub cert_path: String,
}
/// What came of asking to trust the CA, with its status after.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CaTrustResult {
#[serde(flatten)]
pub status: CaStatus,
/// The person cancelled the password prompt, so nothing changed. Not an error.
pub cancelled: bool,
}
/// What came of [`trust`] when it did not fail.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trusted {
Yes,
/// The person cancelled the password prompt.
Cancelled,
}
#[cfg(target_os = "macos")]
const CA_NAME: &str = "Yaak Proxy CA";
#[cfg(target_os = "macos")]
@@ -30,9 +49,9 @@ pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
}
/// Adds the CA to the login keychain as a trusted root. macOS asks for the user's password;
/// the app never elevates.
/// the app never elevates. Cancelling that prompt is not a failure.
#[cfg(target_os = "macos")]
pub async fn trust(cert_path: &Path) -> Result<(), String> {
pub async fn trust(cert_path: &Path) -> Result<Trusted, String> {
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
let keychain = Path::new(&home).join("Library/Keychains/login.keychain-db");
let output = security(&[
@@ -45,12 +64,25 @@ pub async fn trust(cert_path: &Path) -> Result<(), String> {
])
.await?;
if output.status.success() {
return Ok(());
return Ok(Trusted::Yes);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if is_cancelled(&stderr) {
return Ok(Trusted::Cancelled);
}
Err(if stderr.is_empty() { format!("security exited with {}", output.status) } else { stderr })
}
/// `security`'s error for a cancelled authorization prompt: "The authorization was canceled by
/// the user." (`errAuthorizationCanceled`, -128).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn is_cancelled(stderr: &str) -> bool {
let stderr = stderr.to_lowercase();
stderr.contains("canceled by the user")
|| stderr.contains("cancelled by the user")
|| stderr.contains("errauthorizationcanceled")
}
#[cfg(target_os = "macos")]
async fn security(args: &[&str]) -> Result<std::process::Output, String> {
tokio::process::Command::new(SECURITY)
@@ -70,6 +102,20 @@ pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
}
#[cfg(not(target_os = "macos"))]
pub async fn trust(_cert_path: &Path) -> Result<(), String> {
pub async fn trust(_cert_path: &Path) -> Result<Trusted, String> {
Err("Trusting the certificate from the app is only supported on macOS".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cancelled_prompt_is_told_apart() {
let cancelled =
"SecTrustSettingsSetTrustSettings: The authorization was canceled by the user.";
assert!(is_cancelled(cancelled));
assert!(!is_cancelled("SecCertificateCreateFromData: Unknown format in import."));
assert!(!is_cancelled(""));
}
}
+47 -4
View File
@@ -3,7 +3,7 @@
//! and `blobs.sqlite` read-only so captured traffic stays reachable.
use crate::bodies::{self, DecodedBody};
use crate::ca::CaStatus;
use crate::ca::CaTrustResult;
use crate::control::{
self, ControlInfo, ControlStatus, DecryptRequest, ErrorBody, NOTE_HEADER, RecordingRequest,
StartRequest, SystemProxyRequest, TRUNCATED_HEADER,
@@ -16,6 +16,7 @@ use crate::macos::SystemProxyStatus;
use crate::models::{Flow, FlowBody};
use crate::save::{SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
use crate::settings::{DecryptList, ProxySettings};
use crate::tunnels::{OpenTunnel, TunnelScope};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::path::Path;
@@ -129,10 +130,49 @@ impl ControlClient {
resp.text().await.map_err(|e| e.to_string())
}
pub async fn trust_ca(&self) -> Result<CaStatus, String> {
pub async fn trust_ca(&self) -> Result<CaTrustResult, String> {
self.post("/ca/trust", &serde_json::json!({})).await
}
/// The addresses the owner's engine passes through after clients rejected the CA.
pub async fn passthrough(&self) -> Result<crate::PassthroughResponse, String> {
self.get("/passthrough").await
}
/// Has the owner's engine intercept one passed-through address again.
pub async fn forget_passthrough(
&self,
req: &crate::ForgetPassthroughRequest,
) -> Result<crate::ForgetPassthroughResponse, String> {
self.post("/passthrough/forget", req).await
}
/// The owner's open tunnels its decrypt list would decrypt if their clients reconnected.
pub async fn reconnectable_tunnels(
&self,
scope: &TunnelScope,
) -> Result<Vec<OpenTunnel>, String> {
let query = {
let mut query = url::form_urlencoded::Serializer::new(String::new());
if let Some(entry) = &scope.entry {
query.append_pair("entry", entry);
}
if let Some(flow_id) = &scope.flow_id {
query.append_pair("flowId", flow_id);
}
query.finish()
};
self.get(&format!("/tunnels/reconnectable?{query}")).await
}
/// Closes tunnels in the owner's engine so their clients reconnect.
pub async fn reconnect_tunnels(
&self,
req: &crate::ReconnectTunnelsRequest,
) -> Result<crate::ReconnectTunnelsResponse, String> {
self.post("/tunnels/reconnect", req).await
}
/// Resumes or pauses storing flows in the owner's engine.
pub async fn set_recording(&self, enabled: bool) -> Result<ControlStatus, String> {
self.post("/recording", &RecordingRequest { enabled }).await
@@ -154,7 +194,7 @@ impl ControlClient {
pub async fn edit_decrypt_list(
&self,
edit: &crate::EditDecryptListRequest,
) -> Result<DecryptList, String> {
) -> Result<crate::DecryptListSaved, String> {
let body = serde_json::to_vec(edit).map_err(|e| e.to_string())?;
let req = self
.http
@@ -166,7 +206,10 @@ impl ControlClient {
}
/// Replaces the decrypt list in the owner, which saves it and applies it to its engine.
pub async fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptList, String> {
pub async fn set_decrypt_list(
&self,
entries: &[String],
) -> Result<crate::DecryptListSaved, String> {
let body = serde_json::to_vec(&DecryptRequest { entries: entries.to_vec() })
.map_err(|e| e.to_string())?;
let req = self
+36 -11
View File
@@ -11,8 +11,10 @@ use crate::macos::SystemProxyStatus;
use crate::messages::{self, FlowMessage, MessageKind};
use crate::models::{FlowBody, find_header};
use crate::save::SaveFlowRequest;
use crate::tunnels::TunnelScope;
use crate::{
AddGrpcSchemaRequest, EditDecryptListRequest, ListMessagesResponse, ProxyCtx, ProxyState,
AddGrpcSchemaRequest, EditDecryptListRequest, ForgetPassthroughRequest, ListMessagesResponse,
PassthroughResponse, ProxyCtx, ProxyState, ReconnectTunnelsRequest, ReconnectTunnelsResponse,
ReflectGrpcHostRequest, bodies,
};
use bytes::Bytes;
@@ -393,7 +395,7 @@ pub(crate) fn already_owned(owner: &ControlInfo) -> String {
pub(crate) fn owner_name(owner: Owner) -> &'static str {
match owner {
Owner::App => "Yaak Proxy",
Owner::Cli => "the yaak CLI",
Owner::Cli => "the CLI",
}
}
@@ -626,15 +628,24 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
};
Ok(response(StatusCode::OK, "application/x-pem-file", ca.pem().to_string()))
}
(&Method::POST, ["ca", "trust"]) => {
// Off the runtime's few workers: it may wait for the keychain or a conversion.
let ca = {
let ctx = ctx.clone();
blocking(move || ctx.load_ca()).await?
};
ca::trust(ca.cert_path()).await.map_err(bad_request)?;
ctx.forget_passthrough();
json(&ca::status(ca.cert_path()).await?)
(&Method::POST, ["ca", "trust"]) => json(&ctx.trust_ca().await.map_err(bad_request)?),
(&Method::GET, ["passthrough"]) => {
json(&PassthroughResponse { addresses: ctx.passthrough() })
}
(&Method::POST, ["passthrough", "forget"]) => {
let body: ForgetPassthroughRequest = read_json(req).await?;
json(&ctx.forget_passthrough_request(&body).map_err(bad_request)?)
}
(&Method::GET, ["tunnels", "reconnectable"]) => {
let scope = tunnel_scope(&query);
let ctx = ctx.clone();
json(&blocking(move || ctx.reconnectable_tunnels(&scope)).await.map_err(bad_request)?)
}
(&Method::POST, ["tunnels", "reconnect"]) => {
let body: ReconnectTunnelsRequest = read_json(req).await?;
let ctx = ctx.clone();
let closed = blocking(move || ctx.reconnect_tunnels(body.ids.as_deref(), &body.scope));
json(&ReconnectTunnelsResponse { closed: closed.await.map_err(bad_request)? })
}
(&Method::GET, ["system-proxy"]) => {
let ctx = ctx.clone();
@@ -723,6 +734,20 @@ fn message_page(query: &str) -> Result<(Option<i64>, Option<usize>), ApiError> {
Ok((after, limit))
}
/// `entry` and `flowId` from a query string.
fn tunnel_scope(query: &str) -> TunnelScope {
let mut scope = TunnelScope::default();
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
let value = Some(value.into_owned()).filter(|v| !v.is_empty());
match key.as_ref() {
"entry" => scope.entry = value,
"flowId" => scope.flow_id = value,
_ => {}
}
}
scope
}
fn body_limit(query: &str) -> Result<usize, ApiError> {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
if key == "bodyLimit" {
+6 -20
View File
@@ -65,14 +65,11 @@ pub fn flow_to_request(flow: &Flow, body: &DecodedBody) -> Conversion {
let (headers, dropped_headers) = split_headers(flow, body);
let has_authorization = headers.iter().any(|h| h.name.eq_ignore_ascii_case("authorization"));
if has_authorization {
warnings.push("The Authorization header is saved as captured, in plain text.".into());
warnings.push("The Authorization header is saved in plain text.".into());
}
let (body_type, body_fields) = request_body(flow, body, &mut warnings);
if body.truncated {
warnings.push(format!(
"The body was cut at the capture limit, so only its first {} bytes are saved.",
body.data.len()
));
warnings.push(format!("Only the first {} bytes of the body are saved.", body.data.len()));
}
// No name, as in Yaak, which then shows the URL.
@@ -169,10 +166,7 @@ fn request_body(
let content_type = find_header(&flow.request_headers, "content-type").unwrap_or_default();
let mime = content_type.split(';').next().unwrap_or_default().trim().to_ascii_lowercase();
let Ok(text) = std::str::from_utf8(&body.data) else {
warnings.push(
"The body is binary and is saved as text, so bytes that are not UTF-8 are replaced."
.into(),
);
warnings.push("The body is binary and may be altered when saved.".into());
return text_body(BODY_TYPE_OTHER, &String::from_utf8_lossy(&body.data));
};
@@ -195,9 +189,7 @@ fn request_body(
);
}
if mime == "multipart/form-data" {
warnings.push(
"The multipart body is saved as text with its Content-Type, not as form fields.".into(),
);
warnings.push("The multipart body is saved as text, not form fields.".into());
}
text_body(BODY_TYPE_OTHER, text)
}
@@ -300,10 +292,7 @@ mod tests {
("Proxy-Authorization", DropReason::HopByHop),
]
);
assert_eq!(
c.warnings,
vec!["The Authorization header is saved as captured, in plain text."]
);
assert_eq!(c.warnings, vec!["The Authorization header is saved in plain text."]);
}
#[test]
@@ -463,9 +452,6 @@ mod tests {
let c = flow_to_request(&f, &data);
assert_eq!(names(&c.request), vec!["Content-Type"]);
assert_eq!(c.dropped_headers[0].reason, DropReason::BodyDecoded);
assert_eq!(
c.warnings,
vec!["The body was cut at the capture limit, so only its first 4 bytes are saved."]
);
assert_eq!(c.warnings, vec!["Only the first 4 bytes of the body are saved."]);
}
}
+4 -8
View File
@@ -154,10 +154,7 @@ fn any_encrypted(data_dir: &Path) -> bool {
}
fn missing_data_key(data_dir: &Path) -> String {
format!(
"The proxy's captured traffic is encrypted, but its key is missing from {}",
data_dir.display()
)
format!("The encryption key is missing from {}.", data_dir.display())
}
/// Both databases, opened together.
@@ -324,12 +321,11 @@ pub fn encrypt_existing(
Ok(converted)
}
const KEY_UNAVAILABLE: &str =
"Yaak Proxy stores traffic encrypted and couldn't read its key from the keychain";
const KEY_UNAVAILABLE: &str = "Failed to read the encryption key from the keychain";
/// Why the databases cannot be opened, or the CA made, without the data key.
pub fn key_unavailable(reason: &str) -> String {
format!("{KEY_UNAVAILABLE}: {reason}. Allow it access, then retry.")
format!("{KEY_UNAVAILABLE}: {reason}")
}
/// `reason` is [`key_unavailable`]'s: the keychain would not give up the key.
@@ -618,7 +614,7 @@ mod tests {
&applied[6..],
[
"00000007", "00000008", "00000009", "00000010", "00000011", "00000012", "00000013",
"00000014"
"00000014", "00000015"
]
);
let mark_columns = columns(&conn, "flow_clear_mark");
@@ -18,7 +18,7 @@ pub struct FilterHistoryEntry {
}
fn failed(what: &str, e: impl std::fmt::Display) -> String {
format!("Failed to {what} the filter history: {e}")
format!("Failed to {what} the search history: {e}")
}
/// Every filter kept, most recently applied first. Empty for a database from before the
+90 -4
View File
@@ -1,7 +1,7 @@
use crate::bodies;
use crate::db::{ProxyQueryManager, Stores};
use crate::models::{Flow, FlowBody};
use crate::query::{FlowColumns, FlowQuery};
use crate::query::{FlowColumns, FlowQuery, Hidden};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use rusqlite::types::Value;
@@ -23,6 +23,10 @@ pub struct FlowFilter {
/// `@is:system` itself.
#[ts(optional, as = "Option<bool>")]
pub hide_system: bool,
/// Leaves out HTTPS connections passed through undecrypted, as hiding Encrypted does,
/// unless the query says `@is:encrypted` itself.
#[ts(optional, as = "Option<bool>")]
pub hide_encrypted: bool,
/// At most this many flows, the newest.
#[ts(optional, type = "number")]
pub limit: Option<usize>,
@@ -338,7 +342,8 @@ impl FlowFilter {
Self { query: Some(query.into()), ..Self::default() }
}
/// The filter from a query string's `query`, `hide_system`, `limit`, `after` and `before`.
/// The filter from a query string's `query`, `hide_system`, `hide_encrypted`, `limit`,
/// `after` and `before`.
pub fn from_query(query: &str) -> Result<Self, String> {
let mut filter = Self::default();
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
@@ -351,6 +356,12 @@ impl FlowFilter {
.transpose()?
.unwrap_or_default()
}
"hide_encrypted" => {
filter.hide_encrypted = value
.map(|v| v.parse().map_err(|_| format!("Invalid hide_encrypted {v:?}")))
.transpose()?
.unwrap_or_default()
}
"after" => filter.after = value,
"before" => filter.before = value,
"limit" => {
@@ -368,9 +379,11 @@ impl FlowFilter {
let mut query = url::form_urlencoded::Serializer::new(String::new());
let limit = self.limit.map(|l| l.to_string());
let hide_system = self.hide_system.then(|| "true".to_string());
let hide_encrypted = self.hide_encrypted.then(|| "true".to_string());
for (key, value) in [
("query", &self.query),
("hide_system", &hide_system),
("hide_encrypted", &hide_encrypted),
("limit", &limit),
("after", &self.after),
("before", &self.before),
@@ -384,7 +397,12 @@ impl FlowFilter {
/// The search, prepared to run now.
pub fn flow_query(&self) -> FlowQuery {
FlowQuery::new(self.query.as_deref().unwrap_or_default(), self.hide_system)
FlowQuery::new(self.query.as_deref().unwrap_or_default(), self.hidden())
}
/// What the filter leaves out unless its query names it.
pub fn hidden(&self) -> Hidden {
Hidden { system: self.hide_system, encrypted: self.hide_encrypted }
}
/// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows.
@@ -701,7 +719,7 @@ pub fn delete_all(db: &ProxyQueryManager) -> Result<(), String> {
}
Ok::<_, yaak_database::Error>(())
})
.map_err(|e| format!("Failed to delete captured traffic: {e}"))
.map_err(|e| format!("Failed to delete recorded traffic: {e}"))
}
/// Flows deleted per transaction while deleting cleared ones, so a flow being written never
@@ -1867,6 +1885,67 @@ mod tests {
assert_eq!(hidden("@host:*.github.com OR @is:SYSTEM"), vec!["fl_daemon", "fl_node"]);
}
/// Hiding encrypted connections leaves them out of the list and its count, the totals the
/// sidebar reads, and saved searches' counts, unless the search names them.
#[test]
fn hides_encrypted_connections() {
let dir = tempfile::tempdir().unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let flow = |id: &str, url: &str, tunnelled: bool, path: Option<&str>| Flow {
id: id.into(),
url: url.into(),
method: if tunnelled { "CONNECT" } else { "GET" }.into(),
tunnelled,
client_path: path.map(String::from),
..Default::default()
};
for f in [
flow("fl_get", "https://api.example.com/v1", false, None),
flow("fl_tunnel", "https://bank.example.com", true, None),
flow("fl_daemon_tunnel", "https://p01.icloud.com", true, Some("/usr/libexec/trustd")),
] {
db.with_tx(|tx| tx.upsert(&f, &yaak_database::UpdateSource::Background)).unwrap();
}
let filter = |query: &str, hide_system: bool| FlowFilter {
hide_encrypted: true,
hide_system,
..FlowFilter::search(query)
};
let ids = |filter: FlowFilter| -> Vec<String> {
let mut ids: Vec<String> =
list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect();
ids.sort();
ids
};
assert_eq!(ids(filter("", false)), vec!["fl_get"]);
assert_eq!(ids(filter("example", false)), vec!["fl_get"]);
assert_eq!(ids(filter("@is:encrypted", false)), vec!["fl_daemon_tunnel", "fl_tunnel"]);
assert_eq!(ids(filter("@is:encrypted", true)), vec!["fl_tunnel"]);
assert_eq!(ids(filter("@is:tunnelled", false)).len(), 2);
// The totals: everything stored, and what shows with encrypted connections hidden.
let page = sorted_page(&db, &FlowFilter { limit: Some(0), ..filter("", false) }).unwrap();
assert_eq!((page.total, page.matching), (3, Some(1)));
let page = sorted_page(&db, &FlowFilter::default()).unwrap();
assert_eq!((page.total, page.matching), (3, None));
assert!(FlowFilter { hide_encrypted: true, ..Default::default() }.narrows());
assert_eq!(
count_each(&db, &[filter("", false), filter("@is:encrypted", true)]).unwrap(),
[1, 1]
);
// Saved searches count as the list would show them.
let all = crate::saved_queries::create(&db, "All HTTPS", "@scheme:https").unwrap();
let count = |hidden: Hidden| {
let counts = crate::saved_queries::counts(&db, hidden).unwrap();
counts.into_iter().find(|c| c.id == all.id).unwrap().count
};
assert_eq!(count(Hidden::default()), 3);
assert_eq!(count(Hidden { system: false, encrypted: true }), 1);
assert_eq!(count(Hidden { system: true, encrypted: false }), 2);
}
#[test]
fn tells_system_daemons_apart() {
assert!(is_system(None, Some("/usr/libexec/nsurlsessiond")));
@@ -1897,6 +1976,13 @@ mod tests {
};
assert_eq!(filter.to_query(), "query=post+%40status%3A4xx&hide_system=true&limit=20");
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
let filter = FlowFilter { hide_encrypted: true, ..filter };
assert_eq!(
filter.to_query(),
"query=post+%40status%3A4xx&hide_system=true&hide_encrypted=true&limit=20"
);
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
assert!(FlowFilter::from_query("hide_encrypted=maybe").is_err());
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
assert!(FlowFilter::from_query("limit=lots").is_err());
assert!(FlowFilter::from_query("hide_system=maybe").is_err());
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more