Compare commits

..
Author SHA1 Message Date
Gregory SchierandClaude Opus 5.5 10ae70ec27 Wait for pending edits before a boundary snapshot, and time each request on its own
Boundary snapshots flushed the debounced writes but didn't wait for them,
so the snapshot could read the row before the edit it was meant to keep.
They now await flushAllModelWrites first. A closing window can't await,
so it sends the writes and then the snapshot straight behind them: in
the browser the worker keeps that order, and on the desktop the snapshot
can still win, leaving the edit saved but unversioned until its next
send.

The idle tracker kept one timer for all requests, so a write to any
request, from another window or sync, restarted the active one's
countdown. Each request now has its own, cleared when it fires or when
that request is captured at a boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:42:11 -07:00
Gregory SchierandClaude Opus 5.5 9eac9d3da2 Version the browser send from the read it was prepared from
The browser host snapshotted the request through its own RPC, then
prepare_http_send read the row again to build what was sent. An edit
committed between the two left the response pointing at a version it
never sent.

prepare_http_send now versions the stored row it has just read, in the
same call, and hands the id back with the prepared send; the response
takes it from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:42:11 -07:00
Gregory SchierandClaude Opus 5.5 4686d6b4a5 Version the request a send loaded, not a second read of it
Since the plugin fix, every HTTP send versioned the stored row by
reading it again in resolve_send_inputs. On the desktop that read runs
after awaiting plugin startup, so an edit landing in between was
versioned while the earlier copy was sent, and the response named
content it never sent.

Callers now say where their request came from. One that loaded the
stored row and passes it on as is (the desktop send, the by-id sends,
MCP) gets that same copy versioned, with no second read. One that may
hold a rendered request (plugin sends) keeps the read-by-id fallback,
so rendered values still never reach history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:39:11 -07:00
Gregory SchierandClaude Opus 5.5 07956fe68e Merge main into feat/request-versioning
Clean merge with no conflicts or compile fixes. Regenerated bindings
differ from the merged files only in formatting, so none are included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:27:51 -07:00
Gregory SchierandClaude Opus 5.5 7be5b49b0b Move a request's versions with it when it changes workspace
A workspace delete removes versions by workspace_id, but a request
moved to another workspace kept versions tagged with the old one, so
deleting the old workspace deleted versions that responses in the new
workspace still pointed at.

The workspace cascade has to stay: a workspace delete removes its
requests in bulk and never runs each request's own version cascade.
Instead, every request upsert retags that request's versions to its
current workspace, one indexed UPDATE that changes no rows unless the
request moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:30:47 -07:00
Gregory SchierandClaude Opus 5.5 17e719a646 Keep gRPC and WebSocket versioning the request they loaded
The previous commit had connect re-read the request by id inside its
transaction, which runs after template rendering and auth. An edit made
during those awaits would have been versioned even though the
connection used the earlier copy. Connect already holds the stored row
it loaded, so it goes back to snapshotting that; only the HTTP send,
whose request may arrive rendered, reads the stored row by id, through
the new snapshot_stored_request_for_send.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:42:41 -07:00
Gregory SchierandClaude Opus 5.5 34d0aed906 Version the stored request on send, not the one being sent
A plugin can send a request it has already rendered, such as the
response template function. resolve_send_inputs snapshotted whatever
request it was handed, so those versions held rendered values, secrets
included, where the user had templates, and a restore would have
written the literals back.

snapshot_request_for_send now takes a request id and reads the stored
row inside its transaction. A request with no id or no stored row gets
no version, as a failed snapshot already did. gRPC and WebSocket connect
go through the same method, so all three capture the same thing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:35:24 -07:00
Gregory SchierandClaude Opus 5.5 07662257c1 Put the restored request in the store before refreshing editors
Restore told open editors to re-read the request before its change event
had reached the store, so header and parameter rows could keep showing
the old content. The restore RPC now returns the restored request, and
the frontend puts it in the store before calling wasUpdatedExternally,
the same way deleteModel applies its change locally once the RPC
resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:57:40 -07:00
Gregory SchierandClaude Opus 5.5 a68f7a080c Only show a version comparison for the response it belongs to
The comparison query kept its last answer as placeholder data across
version changes, so switching to a response with no version, or to one
whose comparison was still loading, could leave the Request Changed
dropdown offering another response's diff and restore. Placeholder data
now carries over only within the same version, and the hook returns a
comparison only when it is for the version asked about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:57:34 -07:00
Gregory SchierandClaude Opus 5.5 8ca2097d22 Drop the request versioning plan doc
The design lives in the PR description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:23:42 -07:00
Gregory SchierandClaude Opus 5.5 1062de4862 Format the request versioning code with rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:23:42 -07:00
Gregory SchierandClaude Opus 5.5 1b0a196c50 Merge main into feat/request-versioning
Conflicts were all at registration points; both sides are kept.

Main now routes every write through one connection (#642): ClientDb only
reads and writes go through `with_tx` on a WriteDb. Versioning follows:
snapshot, restore, prune and the cascade delete move to WriteDb, while the
lookups and the "has it changed" check stay on ClientDb. Callers take a
write transaction the way their neighbours do:

- The HTTP send snapshots in its own transaction from
  `resolve_send_inputs`, and a failure there (including not getting the
  writer) is still logged and swallowed, so the response just has no
  version.
- gRPC and WebSocket connect snapshot inside the transaction that upserts
  the connection.
- The snapshot and restore RPCs, on the desktop and in the browser host,
  each run in a transaction.
- The request and workspace cascades live on main's WriteDb deletes.

The Request Changed dropdown moves into the response pane's
`statusLineRight` slot, since main moved the status line into the shared
pane view.

The plugin runtime's copy of the model bindings picks up `versionId`, and
`gen_rpc.ts` is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:23:32 -07:00
Gregory Schier e642206ff5 fix(models): don't mint a version for row ids the editor writes
#619 got here first and got more of it right. Two things adopted from it, and
one backed out.

**Adopted, and this one is a bug fix.** The pair editor generates an `id` for
every header and parameter row the first time it touches a request, and that
write reaches the database like any other. Stripping only the top-level `id`
left those in, so merely opening a request minted a version whose diff was
nothing but ids. `strip_ids` drops every `id` at every depth. The regression
test fails without it.

**Adopted.** Hashes carry a `v1:` prefix, so a hash written by an algorithm a
build doesn't understand is recognizable as unreadable rather than silently
read as "different". And `sorted_keys` + `to_string` replaces the hand-rolled
canonical writer — same guarantee, less code, and byte-identical to the
implementation that is landing.

**Backed out.** `comparable()` in import.rs goes back to its own copy. #619
rewrites that function and most of the file around it, including dropping
`sortPriority` from the comparison, so pointing it at the shared module now
would collide in the hottest region of that branch to save six lines. This
module is shaped to be what #619 calls when it lands, and says so; the
convergence is one deletion away rather than a reconciliation.

That leaves `folderId` as the only real disagreement between the two callers,
which PLACEMENT_KEYS now documents as such: import counts a move between
folders as a change, versioning must not.
2026-09-06 09:25:28 -07:00
Gregory Schier aa3bf934a9 refactor(models): share the content-stripping primitive with import
Import already had this: `comparable()` in import.rs, added last week, strips
identity fields off a model so a re-import can tell a change from a conflict.
Request versioning arrived with its own copy of the same idea.

Now both call `yaak_models::content`, which owns the mechanism and — more
usefully — the reasoning. Two things had no single home before: why identity
fields can never count as content, and why hashing has to sort object keys
itself rather than trusting `serde_json::Map` to be a `BTreeMap`
(`preserve_order` is on in some builds of this workspace and off in others).

The key lists stay separate on purpose, as IDENTITY_KEYS and PLACEMENT_KEYS.
Import counts a move as a change, because equality there means "same content in
the same place"; versioning must not, or dragging a request around the sidebar
would mint versions nobody asked for. A test on each side pins that difference,
since the obvious next refactor is to collapse the two lists into one and
neither behaviour would fail loudly if you did.

Directory sync deliberately keeps its own Sha1 and is not folded in. It
checksums the bytes of a file to notice someone edited it on disk, so it has to
reflect formatting and key order — the exact things this module discards.
2026-09-06 09:07:59 -07:00
Gregory Schier 646f5a09b0 fix(models): let a snapshot race resolve to the version that won
Two sends of the same request can both miss the content-hash lookup and race to
insert. The unique index settles it; the loser wants exactly what the winner
wrote, not an error that costs its response a version link.
2026-09-05 21:24:41 -07:00
Gregory Schier e47088b507 docs: update the request versioning plan with what shipped 2026-09-05 21:23:39 -07:00
Gregory Schier 3ad6172bb1 test(send): cover the version link end to end
Drives the real HTTP pipeline against a stub executor and a real database, so
the assertion is the one the feature actually rests on: a stored send leaves a
response naming the request behind it, two sends of an unchanged request name
the same version, and editing afterwards is something the response pane can
detect.
2026-09-05 21:23:04 -07:00
Gregory Schier 7bd159b0b1 feat(client): offer the request a response was sent from
When the selected response's version no longer matches the live request, the
response header grows a "Request Changed" control with View Diff and Restore.
It follows the GraphQL editor's shape — a state-labelled dropdown button rather
than a new icon in the action row — because the label is the whole point: it
has to say *that* something is different before it offers to do anything about
it. Hidden when the two agree, and for responses recorded before versioning
existed, which have no version and never will.

The diff reuses the Git dialog's DiffViewer over YAML renderings of the two
documents, so a request diff reads like a commit diff.

Restore flushes pending edits first (the backend restores from the database, so
a debounced keystroke would otherwise land on top of it) and calls
wasUpdatedExternally afterwards, because the write came from this window and
the store's echo suppression would leave open editors showing the old content.

EditSessionTracker turns the boundaries only the frontend can see — switching
requests, losing focus, closing, falling idle for 60s — into snapshot calls. It
tracks no content of its own: content addressing already decides whether a
boundary had anything behind it, which is what keeps this a timer and two
assignments instead of a change-tracking system.
2026-09-05 21:20:20 -07:00
Gregory Schier b08b3277da feat(rpc): expose snapshot, compare and restore for request versions
Three commands, all host-independent so the browser build answers them from
the same model layer as the desktop:

- models_snapshot_request, for the edit-session boundaries only the frontend
  can see. It takes a reason and nothing else — the caller does not decide
  whether anything changed, because content addressing already has.
- models_request_version, which returns a version and the live request's
  content together so they are guaranteed comparable, plus the same
  content-hash verdict the backend uses rather than a second opinion formed in
  TypeScript.
- models_restore_request_version.

The browser host also snapshots before its own send, since its send pipeline
is in TypeScript rather than in the shared crate.

Bindings regenerated with CI's `cargo test --all --features
yaak-app-client/wry`, which also drops a stale ImportSourceResource from the
rpc-schema copy and adds a missing ImportSource to the plugins copy.
2026-09-05 21:16:29 -07:00
Gregory Schier 19a43e3785 feat(send): link every response to the request version that produced it
HTTP snapshots in resolve_send_inputs, the last point in the pipeline that
still holds the stored request — below it the request has been resolved against
its folder and workspace and then rendered, and neither is what a restore
should put back. Every host reaches sending through that function, so the
desktop, the CLI and plugin-triggered sends all get versions without each
knowing about them. gRPC and WebSocket connect do the same at their connection
upserts.

snapshot_request_for_send swallows its own errors: a send is not worth failing
over history that couldn't be written, and an ephemeral request has no id to
version. Either way the response just has no version to offer.
2026-09-05 21:05:34 -07:00
Gregory Schier 77fe1367a0 feat(models): add content-addressed request versions
One table versions HTTP, gRPC and WebSocket requests alike. A version stores
the model's editable content — the serialized model minus model/id/timestamps/
workspace/folder/sortPriority — and is addressed by the sha256 of that
document, with (model_id, content_hash) unique.

Content addressing is what makes the trigger side simple: snapshot_request can
be called by a send, a window blur and an idle timer on the same unedited
request and leave one row behind, so no caller has to reason about whether
anything changed. Dropping bookkeeping keys is what makes it stable — moving a
request between folders or re-sorting it rewrites those fields and nothing
else, and neither should mint a version or show up in a diff. The same rule
covers all three request types because they differ only in content fields.

Responses and gRPC/WebSocket connections gain a nullable version_id.

Versions are local history, so ModelVersion is deliberately absent from
AnyModel: no model-change rows, no frontend store bucket, no sync, no export.
Retention keeps anything a response points at, plus the newest 50 per request
within 30 days; request and workspace deletes cascade.
2026-09-05 21:01:14 -07:00
Gregory Schier 862fb6d65a docs: plan for request versioning 2026-09-05 20:55:59 -07:00
201 changed files with 4469 additions and 9631 deletions

No files matched your search

+1 -190
View File
@@ -2,24 +2,10 @@ 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' && github.event_name == 'push'
if: github.repository == 'mountain-loop/yaak'
permissions:
contents: write
@@ -304,178 +290,3 @@ 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,7 +11507,6 @@ dependencies = [
"yaak-tauri-utils",
"yaak-templates",
"yaak-tls",
"yaak-updater",
"yaak-window",
"yaak-ws",
]
@@ -11523,12 +11522,9 @@ 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",
]
@@ -11952,14 +11948,12 @@ dependencies = [
"r2d2_sqlite",
"reqwest",
"rusqlite",
"rustls",
"sea-query",
"serde",
"serde_json",
"system-configuration",
"tempfile",
"tokio",
"tokio-rustls",
"tonic",
"tonic-reflection",
"ts-rs",
@@ -12097,23 +12091,6 @@ 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,7 +44,6 @@ members = [
"crates-tauri/yaak-proxy-helper",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-updater",
"crates-tauri/yaak-window",
]
@@ -117,7 +116,6 @@ 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]
@@ -3,6 +3,7 @@ import {
CountBadge,
type ExtraPaneTab,
type HostBodyViewerArgs,
HStack,
HttpResponsePaneView,
Icon,
type ResponseTextViewerExtras,
@@ -29,6 +30,7 @@ import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestVersionDropdown } from "./RequestVersionDropdown";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
@@ -170,11 +172,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
}
statusLineRight={
activeResponse != null && (
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
<HStack space={1}>
<RequestVersionDropdown response={activeResponse} />
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</HStack>
)
}
/>
@@ -9,6 +9,8 @@ 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";
@@ -25,6 +27,12 @@ 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;
@@ -115,6 +123,20 @@ 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",
@@ -0,0 +1,79 @@
import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
import { Icon } from "@yaakapp-internal/ui";
import { stringify } from "yaml";
import { useRequestVersion } from "../hooks/useRequestVersion";
import { showDialog } from "../lib/dialog";
import { restoreRequestVersion } from "../lib/restoreRequestVersion";
import { Button } from "./core/Button";
import { DiffViewer } from "./core/Editor/DiffViewer";
import { Dropdown } from "./core/Dropdown";
interface Props {
response: Pick<HttpResponse, "requestId" | "versionId">;
}
/**
* Offers the request a response was sent from, when that is no longer the
* request you have.
*
* Hidden while the two agree, which is the overwhelmingly common case and the
* one where there is nothing to say. Responses recorded before versioning
* existed have no version and stay quiet forever.
*/
export function RequestVersionDropdown({ response }: Props) {
const comparison = useRequestVersion(response.versionId, response.requestId);
if (comparison == null || !comparison.differs) {
return null;
}
return (
<Dropdown
items={[
{
label: "View Diff",
leftSlot: <Icon icon="git_branch" />,
onSelect: () => showRequestVersionDiff(comparison),
},
{
label: "Restore This Version",
leftSlot: <Icon icon="history" />,
onSelect: () => restoreRequestVersion(comparison.version),
},
]}
>
<Button
size="2xs"
variant="border"
color="notice"
className="font-sans"
title="This request has changed since this response was sent"
forDropdown
>
Request Changed
</Button>
</Dropdown>
);
}
function showRequestVersionDiff(comparison: RequestVersionComparison) {
showDialog({
id: "request-version-diff",
title: "Request Changes Since This Response",
size: "full",
noPadding: true,
render: () => (
<div className="h-full flex flex-col px-4 pb-4">
<DiffViewer
original={toYaml(comparison.version.document)}
modified={toYaml(comparison.currentDocument)}
className="flex-1 min-h-0"
/>
</div>
),
});
}
/** Matches how the Git dialog renders a model for diffing. */
function toYaml(document: unknown): string {
return stringify(document, { indent: 2, lineWidth: 0 });
}
@@ -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, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
@@ -14,7 +14,12 @@ 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";
@@ -51,10 +56,13 @@ export function SettingsAgents() {
const cli = cliSetup(client);
return (
<SettingsPage
title="Agents"
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
>
<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>
<SettingsList>
<SettingsSection title={null}>
<div className="pb-4 border-b border-border-subtle">
@@ -126,6 +134,6 @@ export function SettingsAgents() {
/>
</SettingsSection>
</SettingsList>
</SettingsPage>
</VStack>
);
}
@@ -1,6 +1,6 @@
import type { ClientCertificate } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm";
@@ -218,19 +218,24 @@ export function SettingsCertificates() {
};
return (
<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}>
<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 && (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
@@ -243,7 +248,7 @@ export function SettingsCertificates() {
/>
))}
</VStack>
) : null}
</SettingsPage>
)}
</VStack>
);
}
@@ -1,8 +1,8 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import {
Heading,
HStack,
Icon,
SettingsPage,
Table,
TableBody,
TableCell,
@@ -94,58 +94,59 @@ export function SettingsHotkeys() {
}
return (
<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 };
<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)
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
</SettingsPage>
} 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>
);
}
@@ -1,6 +1,6 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import {
@@ -26,11 +26,15 @@ export function SettingsProxy() {
};
return (
<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?" />}
>
<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?" />
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
@@ -156,7 +160,7 @@ export function SettingsProxy() {
</>
)}
</SettingsList>
</SettingsPage>
</VStack>
);
}
@@ -0,0 +1,133 @@
// @vitest-environment happy-dom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { RequestVersionComparison } from "@yaakapp-internal/models";
import { atom, getDefaultStore } from "jotai";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
rpc: vi.fn<(cmd: string, args: { versionId: string }) => Promise<unknown>>(),
}));
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
vi.mock("./useAllRequests", async () => {
const { atom } = await import("jotai");
return { allRequestsAtom: atom([{ id: "rq_1", updatedAt: "t1" }]) };
});
function comparison(versionId: string): RequestVersionComparison {
return {
version: { id: versionId, modelId: "rq_1" },
currentDocument: {},
differs: true,
} as unknown as RequestVersionComparison;
}
/** Each version's comparison resolves only when the test says so. */
function deferredRpc() {
const pending = new Map<string, () => void>();
mocks.rpc.mockImplementation(
(_cmd, { versionId }) =>
new Promise((resolve) => pending.set(versionId, () => resolve(comparison(versionId)))),
);
return async (versionId: string) => {
await vi.waitFor(() => expect(pending.has(versionId)).toBe(true));
await act(async () => pending.get(versionId)?.());
};
}
describe("useRequestVersion", () => {
let container: HTMLDivElement;
let root: Root;
let seen: (RequestVersionComparison | null)[];
let useRequestVersion: typeof import("./useRequestVersion").useRequestVersion;
let allRequestsAtom: ReturnType<typeof atom<{ id: string; updatedAt: string }[]>>;
const queryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
let client: QueryClient;
function Probe({ versionId }: { versionId: string | null }) {
seen.push(useRequestVersion(versionId, "rq_1"));
return null;
}
async function show(versionId: string | null) {
await act(async () =>
root.render(
<QueryClientProvider client={client}>
<Probe versionId={versionId} />
</QueryClientProvider>,
),
);
}
const current = () => seen[seen.length - 1];
/** Wait for the comparison for a version to reach the component. */
async function answered(versionId: string) {
await vi.waitFor(() => expect(current()?.version.id).toBe(versionId));
}
beforeEach(async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
({ useRequestVersion } = await import("./useRequestVersion"));
({ allRequestsAtom } = (await import("./useAllRequests")) as unknown as {
allRequestsAtom: typeof allRequestsAtom;
});
getDefaultStore().set(allRequestsAtom, [{ id: "rq_1", updatedAt: "t1" }]);
client = queryClient();
seen = [];
container = document.createElement("div");
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
client.clear();
mocks.rpc.mockReset();
});
test("a response with no version has no comparison, even right after one that had", async () => {
const resolve = deferredRpc();
await show("mv_a");
await resolve("mv_a");
await answered("mv_a");
seen = [];
await show(null);
expect(seen.length).toBeGreaterThan(0);
expect(seen.every((c) => c == null)).toBe(true);
});
test("switching versions never shows the previous version's comparison", async () => {
const resolve = deferredRpc();
await show("mv_a");
await resolve("mv_a");
await answered("mv_a");
seen = [];
await show("mv_b");
// Still loading: nothing, rather than mv_a's answer
expect(seen.length).toBeGreaterThan(0);
expect(seen.every((c) => c == null)).toBe(true);
await resolve("mv_b");
await answered("mv_b");
});
test("an edit to the live request keeps the answer while it refetches", async () => {
const resolve = deferredRpc();
await show("mv_a");
await resolve("mv_a");
await answered("mv_a");
seen = [];
await act(async () =>
getDefaultStore().set(allRequestsAtom, [{ id: "rq_1", updatedAt: "t2" }]),
);
// The refetch is pending, but the dropdown does not flicker away meanwhile
expect(seen.length).toBeGreaterThan(0);
expect(seen.every((c) => c?.version.id === "mv_a")).toBe(true);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/react-query";
import type { RequestVersionComparison } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { allRequestsAtom } from "./useAllRequests";
import { rpc } from "../lib/rpc";
/**
* The request version a response was sent from, alongside the request as it
* stands now.
*
* Refetches when the live request is written, which is what keeps the "has this
* changed?" answer honest while someone edits. The comparison itself is the
* backend's — the frontend never hashes anything.
*
* Only ever answers for the version asked about: a response with no version
* gets nothing, and switching responses never shows the last one's comparison
* while the new one loads.
*/
export function useRequestVersion(
versionId: string | null | undefined,
requestId: string | null,
): RequestVersionComparison | null {
const requests = useAtomValue(allRequestsAtom);
const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt;
const { data } = useQuery({
// Keep the last answer across edits to the live request, but never across
// versions, or a response would borrow another response's comparison
placeholderData: (prev, prevQuery) => (prevQuery?.queryKey[1] === versionId ? prev : undefined),
queryKey: ["request_version", versionId, liveUpdatedAt],
enabled: versionId != null,
queryFn: () =>
rpc<RequestVersionComparison>("models_request_version", { versionId: versionId! }),
});
return versionId != null && data?.version.id === versionId ? data : null;
}
+1 -1
View File
@@ -1,5 +1,5 @@
import deepEqual from "@gilbarbara/deep-equal";
import type { UpdateInfo } from "@yaakapp-internal/tauri-updater";
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
import type { Atom } from "jotai";
import { atom } from "jotai";
import { selectAtom } from "jotai/utils";
@@ -0,0 +1,122 @@
import type { ModelVersionReason } from "@yaakapp-internal/models";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditSessionTracker } from "./editSessionTracker";
type Capture = [requestId: string, reason: ModelVersionReason];
function tracker(idleMs = 1000) {
const captured: Capture[] = [];
return {
captured,
tracker: new EditSessionTracker((id, reason) => captured.push([id, reason]), idleMs),
};
}
describe("EditSessionTracker", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test("captures a request once it has been left alone", () => {
const { tracker: t, captured } = tracker();
t.noteEdit("rq_1");
vi.advanceTimersByTime(999);
expect(captured).toEqual([]);
vi.advanceTimersByTime(1);
expect(captured).toEqual([["rq_1", "idle"]]);
});
test("a burst of edits is one capture, not one per keystroke", () => {
const { tracker: t, captured } = tracker();
for (let i = 0; i < 10; i++) {
t.noteEdit("rq_1");
vi.advanceTimersByTime(500);
}
expect(captured).toEqual([]);
vi.advanceTimersByTime(1000);
expect(captured).toEqual([["rq_1", "idle"]]);
});
test("each request keeps its own countdown when two are edited in turn", () => {
const { tracker: t, captured } = tracker();
t.noteEdit("rq_a");
vi.advanceTimersByTime(600);
// An edit to B, from this window, another one, or sync, must not touch A's countdown
t.noteEdit("rq_b");
vi.advanceTimersByTime(400);
expect(captured).toEqual([["rq_a", "idle"]]);
// B keeps writing; A is done and stays done
t.noteEdit("rq_b");
vi.advanceTimersByTime(999);
expect(captured).toEqual([["rq_a", "idle"]]);
vi.advanceTimersByTime(1);
expect(captured).toEqual([
["rq_a", "idle"],
["rq_b", "idle"],
]);
});
test("a boundary capture settles that request's countdown, and only that one", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_a");
t.noteEdit("rq_a");
t.noteEdit("rq_b");
t.noteActiveRequest("rq_c");
expect(captured).toEqual([["rq_a", "switch"]]);
vi.advanceTimersByTime(1000);
// A was already captured when it was left; B's countdown still ran
expect(captured).toEqual([
["rq_a", "switch"],
["rq_b", "idle"],
]);
});
test("a close can bring its own snapshot", () => {
const { tracker: t, captured } = tracker();
const onClose: Capture[] = [];
t.noteActiveRequest("rq_1");
t.noteEdit("rq_1");
t.noteBoundary((id, reason) => onClose.push([id, reason]));
vi.advanceTimersByTime(1000);
expect(onClose).toEqual([["rq_1", "switch"]]);
expect(captured).toEqual([]);
});
test("captures the request being left, not the one being opened", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_1");
expect(captured).toEqual([]);
t.noteActiveRequest("rq_2");
expect(captured).toEqual([["rq_1", "switch"]]);
});
test("re-selecting the same request is not a boundary", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_1");
t.noteActiveRequest("rq_1");
expect(captured).toEqual([]);
});
test("blur and close capture the request still on screen", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_1");
t.noteBoundary();
t.noteBoundary();
expect(captured).toEqual([
["rq_1", "switch"],
["rq_1", "switch"],
]);
});
test("nothing is captured before a request is open", () => {
const { tracker: t, captured } = tracker();
t.noteBoundary();
t.noteActiveRequest(null);
expect(captured).toEqual([]);
});
});
@@ -0,0 +1,78 @@
import type { ModelVersionReason } from "@yaakapp-internal/models";
/**
* How long a request has to sit untouched before its edits become a version.
* Long enough that typing a URL is one version rather than forty, short enough
* that walking away from a half-finished edit still records it.
*/
export const IDLE_MS = 60_000;
type Snapshot = (requestId: string, reason: ModelVersionReason) => void;
/**
* When a request's editing session ends.
*
* The backend versions a request on every send, which covers "what produced
* this response". This covers the rest: an edit someone made and then walked
* away from, which no send would ever have captured.
*
* It deliberately knows nothing about *what* changed. Versions are
* content-addressed, so a boundary that turns out to have nothing behind it
* costs one query and creates nothing — which is what lets this stay a few
* timers instead of a change-tracking system.
*/
export class EditSessionTracker {
/**
* One idle countdown per request. Writes arrive for any request — from other
* windows and sync too — and an edit to one must never restart or cancel
* another's countdown.
*/
private timers = new Map<string, ReturnType<typeof setTimeout>>();
private activeRequestId: string | null = null;
constructor(
private readonly snapshot: Snapshot,
private readonly idleMs: number = IDLE_MS,
) {}
/** A request was written. Restarts that request's idle countdown. */
noteEdit(requestId: string) {
this.clearTimer(requestId);
this.timers.set(
requestId,
setTimeout(() => {
this.timers.delete(requestId);
this.snapshot(requestId, "idle");
}, this.idleMs),
);
}
/** The user moved to a different request, so the one they left is finished. */
noteActiveRequest(requestId: string | null) {
if (requestId === this.activeRequestId) return;
const left = this.activeRequestId;
this.activeRequestId = requestId;
if (left != null) this.capture(left, this.snapshot);
}
/**
* The window lost focus or is closing. A close can't wait for anything, so it
* passes its own snapshot function.
*/
noteBoundary(snapshot: Snapshot = this.snapshot) {
if (this.activeRequestId != null) this.capture(this.activeRequestId, snapshot);
}
/** Capture at a boundary, which also settles any idle countdown still pending */
private capture(requestId: string, snapshot: Snapshot) {
this.clearTimer(requestId);
snapshot(requestId, "switch");
}
private clearTimer(requestId: string) {
const timer = this.timers.get(requestId);
if (timer == null) return;
clearTimeout(timer);
this.timers.delete(requestId);
}
}
+123 -35
View File
@@ -6,19 +6,16 @@ import type {
ShowToastRequest,
} from "@yaakapp-internal/plugins";
import { updateAllPlugins } from "@yaakapp-internal/plugins";
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 type {
PluginUpdateNotification,
UpdateInfo,
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { HStack, Icon, InlineCode, 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";
@@ -139,11 +136,6 @@ 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);
@@ -157,35 +149,131 @@ export function initGlobalListeners() {
}
function showUpdateInstalledToast(version: string) {
showUpdateInstalledToastIn(jotaiStore, {
appName: "Yaak",
version,
onRelaunch: () => rpc("cmd_restart", {}),
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>
),
});
}
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded, install } = updateInfo;
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
// 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);
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;
}
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),
// 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>
),
});
}
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
calls: [] as string[],
finishWrites: null as (() => void) | null,
flushAllModelWrites: vi.fn(),
rpc: vi.fn(),
}));
vi.mock("@yaakapp-internal/models", () => ({
flushAllModelWrites: mocks.flushAllModelWrites,
flushAllPendingPatches: vi.fn(),
}));
vi.mock("@yaakapp-internal/platform", () => ({ platform: {} }));
vi.mock("../hooks/useActiveRequestId", () => ({ activeRequestIdAtom: null }));
vi.mock("./jotai", () => ({ jotaiStore: {} }));
vi.mock("./rpc", () => ({ rpc: mocks.rpc }));
describe("snapshotRequestVersion", () => {
beforeEach(() => {
mocks.calls = [];
mocks.flushAllModelWrites.mockImplementation(
() =>
new Promise<void>((resolve) => {
mocks.calls.push("flush");
mocks.finishWrites = () => {
mocks.calls.push("writes landed");
resolve();
};
}),
);
mocks.rpc.mockImplementation(async (cmd: string) => {
mocks.calls.push(cmd);
return { id: "mv_1" };
});
});
test("asks for the snapshot only once pending writes have landed", async () => {
const { snapshotRequestVersion } = await import("./requestVersions");
snapshotRequestVersion("rq_1", "switch");
await Promise.resolve();
expect(mocks.calls).toEqual(["flush"]);
mocks.finishWrites?.();
await vi.waitFor(() =>
expect(mocks.calls).toEqual(["flush", "writes landed", "models_snapshot_request"]),
);
expect(mocks.rpc).toHaveBeenCalledWith("models_snapshot_request", {
requestId: "rq_1",
reason: "switch",
});
});
test("a failed write still lets the snapshot go ahead", async () => {
mocks.flushAllModelWrites.mockRejectedValue(new Error("write failed"));
const { snapshotRequestVersion } = await import("./requestVersions");
snapshotRequestVersion("rq_1", "idle");
await vi.waitFor(() => expect(mocks.rpc).toHaveBeenCalledTimes(1));
});
});
+75
View File
@@ -0,0 +1,75 @@
import { flushAllModelWrites, flushAllPendingPatches } from "@yaakapp-internal/models";
import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { EditSessionTracker } from "./editSessionTracker";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { jotaiStore } from "./jotai";
import { rpc } from "./rpc";
const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"];
/**
* Ask the backend to capture a request's current content.
*
* Quiet by design: a version that fails to write is not worth a toast, because
* every caller below is reacting to the user leaving rather than asking for
* anything.
*/
export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) {
captureRequestVersion(requestId, reason).catch((err: unknown) => {
console.warn("Failed to snapshot request version", err);
});
}
async function captureRequestVersion(requestId: string, reason: ModelVersionReason) {
// Edits reach the database on a debounce. Wait until every pending write has
// landed, or the snapshot can read the row before the edit this boundary is
// meant to capture. A write that failed leaves nothing newer to wait for.
await flushAllModelWrites().catch((err: unknown) => {
console.warn("A pending write failed before a request snapshot", err);
});
await rpc<ModelVersion>("models_snapshot_request", { requestId, reason });
}
/**
* The same, for a window that is closing, where nothing can be awaited.
*
* The pending writes go out first and the snapshot right behind them. In the
* browser that is enough: the worker runs commands in the order they arrive.
* On the desktop each command runs on its own, so the snapshot can still reach
* the database before the last write does. The edit itself is saved either
* way, and the request's next send versions it.
*/
function snapshotRequestVersionOnClose(requestId: string, reason: ModelVersionReason) {
flushAllPendingPatches();
rpc<ModelVersion>("models_snapshot_request", { requestId, reason }).catch((err: unknown) => {
console.warn("Failed to snapshot request version", err);
});
}
export function initRequestVersionSnapshots() {
const tracker = new EditSessionTracker(snapshotRequestVersion);
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (!REQUEST_MODELS.includes(payload.model.model)) continue;
tracker.noteEdit(payload.model.id);
}
});
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
jotaiStore.sub(activeRequestIdAtom, () => {
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
});
platform.window.onFocusChanged((focused) => {
if (!focused) tracker.noteBoundary();
});
// Closing is the last boundary there is. Nothing can be awaited here, but the
// write is already on its way and the backend outlives the window.
window.addEventListener("beforeunload", () =>
tracker.noteBoundary(snapshotRequestVersionOnClose),
);
}
@@ -0,0 +1,54 @@
import type { ModelVersion } from "@yaakapp-internal/models";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
calls: [] as string[],
flushAllModelWrites: vi.fn(),
mergeModelsInStore: vi.fn(),
wasUpdatedExternally: vi.fn(),
rpc: vi.fn(),
}));
vi.mock("@yaakapp-internal/models", () => ({
flushAllModelWrites: mocks.flushAllModelWrites,
mergeModelsInStore: mocks.mergeModelsInStore,
}));
vi.mock("../hooks/useRequestUpdateKey", () => ({
wasUpdatedExternally: mocks.wasUpdatedExternally,
}));
vi.mock("./rpc", () => ({ rpc: mocks.rpc }));
vi.mock("./toast", () => ({ showToast: vi.fn() }));
const restored = { model: "http_request", id: "rq_1", url: "https://example.com/one" };
const version = { id: "mv_1", modelId: "rq_1" } as ModelVersion;
describe("applyRequestVersion", () => {
beforeEach(() => {
mocks.calls = [];
mocks.flushAllModelWrites.mockImplementation(async () => mocks.calls.push("flush"));
mocks.rpc.mockImplementation(async (cmd: string) => {
mocks.calls.push(cmd);
return restored;
});
mocks.mergeModelsInStore.mockImplementation(() => mocks.calls.push("store"));
mocks.wasUpdatedExternally.mockImplementation(() => mocks.calls.push("editors"));
});
test("puts the restored request in the store before editors re-read it", async () => {
const { applyRequestVersion } = await import("./restoreRequestVersion");
await applyRequestVersion(version);
expect(mocks.calls).toEqual(["flush", "models_restore_request_version", "store", "editors"]);
expect(mocks.mergeModelsInStore).toHaveBeenCalledWith("http_request", [restored]);
expect(mocks.wasUpdatedExternally).toHaveBeenCalledWith("rq_1");
});
test("a failed restore leaves the store and editors alone", async () => {
mocks.rpc.mockRejectedValue(new Error("no such version"));
const { applyRequestVersion } = await import("./restoreRequestVersion");
await expect(applyRequestVersion(version)).rejects.toThrow("no such version");
expect(mocks.mergeModelsInStore).not.toHaveBeenCalled();
expect(mocks.wasUpdatedExternally).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,44 @@
import { flushAllModelWrites, mergeModelsInStore } from "@yaakapp-internal/models";
import type { AnyModel, ModelVersion } from "@yaakapp-internal/models";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { fireAndForget } from "./fireAndForget";
import { rpc } from "./rpc";
import { showToast } from "./toast";
/**
* Put an old version's content back into the live request.
*
* The backend captures whatever the request currently holds before
* overwriting it, so this is not a destructive action even when the last edit
* was never versioned — but the request is still rewritten under the user's
* cursor, so it is announced.
*/
export function restoreRequestVersion(version: ModelVersion) {
fireAndForget(
(async () => {
await applyRequestVersion(version);
showToast({
id: "request-version-restored",
color: "success",
message: "Restored the request that produced this response",
});
})(),
);
}
/** The restore itself, in the order the editors need it. */
export async function applyRequestVersion(version: ModelVersion): Promise<void> {
// The backend restores from the database, so anything still sitting in a
// debounce has to land first — otherwise it would overwrite the restore
await flushAllModelWrites();
const restored = await rpc<AnyModel>("models_restore_request_version", {
versionId: version.id,
});
// Editors re-read from the store when told to, and the change event can
// arrive after this resolves, so put the restored request there first. The
// event re-applying it later changes nothing.
mergeModelsInStore(restored.model, [restored]);
// The write came from this window, so the store's echo suppression would
// otherwise leave open editors showing what was there before
wasUpdatedExternally(restored.id);
}
+2
View File
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
import { initGit } from "./init/git";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
import { initRequestVersionSnapshots } from "./lib/requestVersions";
import { jotaiStore } from "./lib/jotai";
import { router } from "./lib/router";
@@ -36,6 +37,7 @@ initGit();
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
initRequestVersionSnapshots();
await changeModelStoreWorkspace(null); // Load global models
console.log("Creating React root");
+2 -7
View File
@@ -42,13 +42,8 @@ export function AppIcon({ path, className = "size-4" }: { path: string; classNam
}, [path]);
return url == null ? (
<span aria-hidden className={classNames(className, "inline-block shrink-0 aspect-square")} />
<span aria-hidden className={classNames(className, "inline-block shrink-0")} />
) : (
// Square whatever squeezes it, beside text that truncates
<img
alt=""
src={url}
className={classNames(className, "shrink-0 aspect-square object-contain")}
/>
<img alt="" src={url} className={classNames(className, "shrink-0")} />
);
}
@@ -47,14 +47,27 @@ export function ClearFlowsButton() {
<IconButton
className="pointer-events-auto"
icon="trash"
title={action === "disabled" || hotkey == null ? "Clear flows" : `Clear flows ${hotkey}`}
title={
locked != null
? "Clear flows (traffic is locked)"
: total === 0
? "Clear flows (none yet)"
: 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 Flows">
<Dialog
open={confirming}
onClose={() => setConfirming(false)}
size="sm"
title="Clear traffic?"
>
{confirming && (
<ConfirmClear
total={total}
@@ -90,8 +103,8 @@ function ConfirmClear({
<form id={formId} className="flex flex-col gap-3" onSubmit={handleSubmit}>
<p>
{total === 1
? "Permanently clear 1 flow?"
: `Permanently clear ${total.toLocaleString()} flows?`}
? "1 flow will be removed."
: `${total.toLocaleString()} flows will be removed.`}
</p>
<Checkbox checked={dontAskAgain} title="Don't ask again" onChange={setDontAskAgain} />
<DialogFooter
+49 -197
View File
@@ -1,208 +1,56 @@
import { Prec } from "@codemirror/state";
import { type EditorView, keymap } from "@codemirror/view";
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
import {
Button,
CONTROL_TOOLTIP_DELAY,
Icon,
IconButton,
Input,
SettingRow,
SettingRowBoolean,
Tooltip,
} from "@yaakapp-internal/ui";
import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui";
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useMemo, useRef, useState } from "react";
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
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";
import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt";
/**
* 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.
* 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.
*/
export function DecryptionSettings() {
export function DecryptListEditor({ className }: { className?: string }) {
const list = useDecryptList();
const { apply } = useDecrypt();
const [busy, setBusy] = useState(false);
const save = useEditDecryptList();
// 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);
// 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);
}
// 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] });
};
const remove = (entry: string) =>
void change(
{ add: [], remove: [entry] },
entry.startsWith("!")
? `Stopped excluding ${entrySubject(entry.slice(1))}`
: `Stopped decrypting ${entrySubject(entry)}`,
);
return (
<>
<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={classNames(className, "grid gap-2")}>
<EntryInput
seenRef={seenRef}
list={list}
disabled={save.isPending}
onAdd={(entry) => save.mutate({ add: [entry], remove: [] })}
/>
<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>
<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>
)}
{!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) => (
{list.map((e) => (
<EntryRow
key={e.entry}
entry={e}
disabled={disabled}
onRemove={() => onRemove(e.entry)}
disabled={save.isPending}
onRemove={() => remove(e.entry)}
/>
))}
</>
</div>
</div>
);
}
@@ -233,10 +81,12 @@ function EntryRow({
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
{entryLabel(entry.entry)}
</span>
{entry.builtIn && entry.kind != null && (
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
{entry.kind === "apple" ? "Apple" : "Pinned"}
</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>
)}
<IconButton
size="xs"
@@ -289,7 +139,7 @@ function EntryInput({
if (disabledRef.current) return true;
const entry = normalizeEntry(text);
if (entry == null) {
setError("Invalid host or app");
setError("Not a host or app");
return true;
}
setError(null);
@@ -302,18 +152,20 @@ function EntryInput({
}, [seenRef]);
return (
<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>
<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>
);
}
@@ -1,309 +0,0 @@
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;
}
+122 -33
View File
@@ -1,8 +1,18 @@
import { Button, Icon, IconTooltip, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
import {
Button,
CONTROL_TOOLTIP_DELAY,
Icon,
InlineCode,
LoadingIcon,
Tooltip,
} 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";
@@ -10,35 +20,49 @@ import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./Proxy
type StepState = "todo" | "next" | "wait" | "done" | "fail";
/**
* 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.
* 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.
*/
export function FirstRun() {
const proxyState = useAtomValue(proxyStateAtom);
const setup = useProxySetup();
const { systemProxy, setSystemProxy } = setup;
const { caStatus, trust, 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"
: "next";
const ready = systemOn || !systemSupported;
: 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);
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
const trafficState: StepState =
encrypting != null ? "wait" : !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
@@ -48,21 +72,46 @@ 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
? "Traffic from every app on this Mac will be recorded."
: "Traffic appears here as apps send it."}
{systemSupported ? "Four" : "Three"} steps, once. Then every app on this Mac sends its
traffic here.
</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={1}
n={2}
state={systemState}
title={systemOn ? "System proxy overridden" : "Override system proxy"}
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
description={
otherOwner && !systemOn
? proxyState?.otherOwner?.owner === "cli"
? "Take over from the CLI to change this."
: "Quit the other Yaak Proxy to use this one."
? "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."
: systemSwitch.description
}
action={
@@ -73,32 +122,74 @@ export function FirstRun() {
color={systemState === "fail" ? "default" : "primary"}
onClick={() => setSystemProxy.mutate({ enabled: true })}
>
{systemState === "fail" ? "Retry" : "Enable"}
{systemState === "fail" ? "Try Again" : "Turn On"}
</Button>
) : null
}
/>
)}
<Step
n={systemSupported ? 2 : 1}
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}
state={trafficState}
title={
encrypting != null
? `Encrypting recorded traffic, ${encrypting}`
? `Encrypting stored traffic, ${encrypting}`
: trafficState === "wait"
? "Waiting for traffic"
: "See traffic"
}
description={
encrypting != null
? "Recording will start once encryption finishes."
? "Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
: trafficState === "fail"
? (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."
? (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."
}
action={
trafficState === "fail" ? (
@@ -123,7 +214,7 @@ export function FirstRun() {
/>
</div>
<div className="flex items-center gap-3 mt-4 text-sm text-text-subtle">
<span>To proxy a single terminal instead, copy the environment.</span>
<span>Only need one terminal or one app? Copy the environment instead.</span>
<Button
size="2xs"
variant="border"
@@ -135,14 +226,12 @@ export function FirstRun() {
</Button>
</div>
<div className="mt-2 text-sm text-text-subtle">
Agents can connect through <InlineCode>yaak mcp</InlineCode>.
Working with an agent? <InlineCode>yaak mcp</InlineCode> gives it the proxy's flows and
actions.
</div>
<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 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>
</div>
</div>
+4 -11
View File
@@ -10,7 +10,6 @@ import {
CsvViewer,
HexdumpViewer,
Icon,
IconTooltip,
ImageViewer,
LARGE_BODY_BYTES,
languageFromContentType,
@@ -239,20 +238,14 @@ 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}. The body is shown as it was received.
{decodeError}. Showing the body as it was on the wire.
</Banner>
)}
{truncated && !hideCutNote && (
<Banner color="notice" className="text-sm mb-2">
<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>
{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.`}
</Banner>
)}
<div className="min-h-0 row-start-3">{viewer}</div>
+17 -33
View File
@@ -15,7 +15,6 @@ import {
parseCookieHeaders,
ResponseCookies,
SplitLayout,
type TabItem,
Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
@@ -23,7 +22,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 { bodyTypeBadge, findHeader } from "../lib/body";
import { findHeader } from "../lib/body";
import {
browserTabMemory,
defaultTabFor,
@@ -33,12 +32,13 @@ 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,6 +54,7 @@ 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";
/**
@@ -149,15 +150,7 @@ 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,
@@ -176,11 +169,8 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
request={request}
fullHeight
forceUpdateKey={forceUpdateKey}
// Info joined as the first tab: an order dragged before it would put it last
tabsStorageKey="proxy_request_tabs.2"
tabsStorageKey="proxy_request_tabs"
bodyLoader={loader}
bodyTab={bodyTab}
leadingTabs={leadingTabs}
extraTabs={extraTabs}
defaultTab={defaultTab}
activeTab={activeTab}
@@ -218,15 +208,6 @@ 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 (
@@ -263,11 +244,14 @@ 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[]>(
() =>
messagesLabel == null
() => [
{
tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" },
content: <FlowTimeline flow={flow} />,
},
{ tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> },
...(messagesLabel == null
? []
: [
{
@@ -285,13 +269,16 @@ 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);
@@ -312,7 +299,6 @@ 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 ? (
@@ -323,9 +309,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
)}
>
<Icon icon="scissors" size="xs" />
{body.data == null
? "Body truncated"
: `First ${formatSize(body.data.storedSize)} recorded`}
{body.data == null ? "Body cut" : `First ${formatSize(body.data.storedSize)} kept`}
</span>
) : undefined
}
+3 -4
View File
@@ -2,10 +2,9 @@ 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
@@ -36,8 +35,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
<Keys keys={["⌘", "⇧", "K"]} />
</div>
</EmptyState>
) : flow.tunnelled || isRejection(flow) ? (
<EncryptedCard key={flow.id} flow={flow} />
) : flow.tunnelled ? (
<NotDecrypted flow={flow} />
) : (
<FlowCard flow={flow} listBeside={sideBySide} />
)}
+7 -62
View File
@@ -1,26 +1,16 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
CONTROL_TOOLTIP_DELAY,
DetailsBanner,
formatMillis,
Icon,
type IconProps,
KeyValueRow,
KeyValueRows,
Tooltip,
} from "@yaakapp-internal/ui";
import { DetailsBanner, formatMillis, KeyValueRow, KeyValueRows } from "@yaakapp-internal/ui";
import { format } from "date-fns";
import { Fragment, type HTMLAttributes, type ReactElement, type ReactNode, useMemo } from "react";
import type { HTMLAttributes, ReactElement, ReactNode } 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. Then the timeline: each phase of the exchange and how long it took.
* of the answer.
*/
export function FlowInfo({ flow }: { flow: Flow }) {
const folder = folderOf(flow);
@@ -65,8 +55,8 @@ export function FlowInfo({ flow }: { flow: Flow }) {
"Started",
format(utcTimestamp(flow.startedAt), "MMM d, yyyy, h:mm:ss.SSS a"),
),
row("headers", "Latency", ms(flow.elapsedHeadersMs)),
row("total", "Time", ms(flow.elapsedMs)),
row("headers", "Headers", ms(flow.elapsedHeadersMs)),
row("total", "Total", ms(flow.elapsedMs)),
]}
</Section>
<Section title="Size">
@@ -75,62 +65,17 @@ export function FlowInfo({ flow }: { flow: Flow }) {
row("response_size", "Response", formatSize(flow.responseSize)),
]}
</Section>
<Section title="Flow">
<Section title="Record">
{[
row("number", "Number", `#${flow.number}`, { copy: true }),
row("id", "ID", flow.id, { copy: true }),
row("saved", "Yaak Request ID", flow.savedRequestId, { copy: true }),
row("saved", "Saved As", 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>>;
+17 -176
View File
@@ -55,7 +55,6 @@ 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,
@@ -854,9 +853,6 @@ 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 (
@@ -889,16 +885,10 @@ 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}
selected={selected}
span={(spanReason && c.id === "type") || (spanHost && c.id === "host")}
/>
<FlowCell column={c.id} flow={flow} span={spanReason && c.id === "type"} />
</div>
);
})}
@@ -906,46 +896,27 @@ 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: rejected, failed, cancelled, encrypted, 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: failed, cancelled, not decrypted, 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 (rejected) states.push("Certificate rejected by the app");
else if (flow.error != null) states.push(`Failed: ${flow.error}`);
if (flow.error != null) states.push(`Failed: ${flow.error}`);
if (flow.cancelled) states.push("Cancelled by the app");
if (flow.tunnelled) states.push(encryptedReason(flow));
if (flow.tunnelled) states.push("Not decrypted");
if (open) states.push("Streaming");
if (flow.responseTruncated) states.push("Body truncated at the capture limit");
if (flow.responseTruncated) states.push("Body cut at the capture limit");
if (flow.savedRequestId != null) states.push("Saved to Yaak");
const mark = (
<span className="flex items-center justify-center">
{rejected ? (
<Icon icon="shield_off" size="sm" color="warning" />
) : flow.error != null ? (
{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-subtle" />
<Icon icon="lock" size="sm" className="text-text-subtlest" />
) : open ? (
<span aria-hidden className="w-[7px] h-[7px] rounded-full bg-info ring-3 ring-info/20" />
) : flow.responseTruncated ? (
@@ -987,141 +958,7 @@ function isOpenStream(flow: Flow): boolean {
return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled;
}
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;
function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) {
const failed = flow.error != null;
const noResponse = (failed || flow.cancelled) && flow.status == null;
switch (column) {
@@ -1168,6 +1005,10 @@ function FlowCell(props: CellProps) {
);
}
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 (
@@ -1193,7 +1034,7 @@ function FlowCell(props: CellProps) {
);
}
case "status":
return flow.cancelled && flow.status == null ? (
return (flow.cancelled || flow.tunnelled) && flow.status == null ? (
<span className={classNames(cell, mono, "text-text-subtlest")}>--</span>
) : (
<HttpStatusTagRaw status={flow.status ?? 0} short className={classNames(cell, mono)} />
@@ -1263,7 +1104,7 @@ function FlowCell(props: CellProps) {
</span>
);
case "query": {
const query = urlParts(flow.url).query ?? "";
const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? "");
return (
<Truncated text={query}>
<span className={classNames(cell, "text-text-subtle")}>{query}</span>
@@ -1369,9 +1210,9 @@ function useCellActions(): (flow: Flow, column: ColumnId) => DropdownItem[] {
onSelect: async () => {
try {
await navigator.clipboard.writeText(value);
showToast({ message: "Copied to clipboard" });
showToast({ message: "Copied value" });
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
showToast({ message: `Failed to copy value: ${String(err)}`, color: "danger" });
}
},
});
+13 -17
View File
@@ -6,13 +6,7 @@ import { appliedFilterAtom } from "../lib/atoms";
import { applyFilter } from "../lib/navigation";
import { rpc } from "../lib/rpc";
import { activeQuery, widerQueries } from "../lib/query";
import {
hideEncryptedAtom,
hideSystemAtom,
pausedAtAtom,
proxyStateAtom,
searchWindowAtom,
} from "../lib/store";
import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store";
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
export function FlowListEmpty() {
@@ -31,17 +25,18 @@ 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
? "Retry from the status menu to start the proxy."
? "The proxy is not running. Retry from the menu by the state."
: !recording
? "Traffic passes through without being recorded."
: `Traffic sent to port ${port ?? "--"} will be recorded.`}
? "Traffic passes through without being stored."
: `Recording on port ${port ?? "--"}. Flows appear as apps send requests.`}
</div>
</EmptyStateText>
);
}
/**
* The list's last row while recording is paused, where the next flow would appear: since when.
* The list's last row while recording is paused, where the next flow would appear: since when,
* and that nothing is stored.
*/
export function PausedRow({ style }: { style?: React.CSSProperties }) {
const pausedAt = useAtomValue(pausedAtAtom);
@@ -55,7 +50,8 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
>
<Icon icon="circle_pause" size="sm" className="text-text-subtlest" />
<span className="truncate">
{pausedAt == null ? "Recording is paused." : `Recording was paused at ${timeOf(pausedAt)}.`}
{pausedAt == null ? "Paused." : `Paused at ${timeOf(pausedAt)}.`} Traffic passes through
without being stored.
</span>
<Hotkey action="recording_toggle" variant="text" className="ml-auto" />
</div>
@@ -68,7 +64,6 @@ 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 }[]>([]);
@@ -78,7 +73,7 @@ function NoResults({ text }: { text: string }) {
const candidates = widerQueries(text);
void Promise.all(
candidates.map(async (candidate) => {
const query = activeQuery(candidate, hideSystem, hideEncrypted);
const query = activeQuery(candidate, hideSystem);
const res = await rpc("list_flows", { ...query, limit: 0 });
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
}),
@@ -92,7 +87,7 @@ function NoResults({ text }: { text: string }) {
return () => {
current = false;
};
}, [text, hideSystem, hideEncrypted]);
}, [text, hideSystem]);
return (
<EmptyStateText
@@ -100,10 +95,11 @@ 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">
{wider.length === 0 ? "No results" : "No results, but found matches for "}
No flows match.
{wider.length > 0 && " Found matches for "}
{wider.map((w, i) => (
<span key={w.text}>
{i > 0 && " or "}
{i > 0 && (i === wider.length - 1 ? ", or " : ", ")}
<button type="button" className="hover:underline" onClick={() => setText(w.text)}>
<InlineCode>{w.text === "" ? "all traffic" : w.text}</InlineCode>
</button>{" "}
+3 -12
View File
@@ -46,7 +46,6 @@ import {
resetWidth,
resetWidths,
resizeColumn,
resizeDistance,
toggleColumn,
unavailableColumns,
} from "../lib/columns";
@@ -159,7 +158,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)) },
@@ -179,7 +178,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: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
{ type: "separator" },
{
label: "Move Left",
@@ -265,10 +264,7 @@ export const FlowListHeader = memo(function FlowListHeader({
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(dx) => {
const by = resizeDistance(c, dx, rootFontSize());
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + by));
}}
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
/>
))}
@@ -283,11 +279,6 @@ 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[];
+1 -3
View File
@@ -120,9 +120,7 @@ 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.toLowerCase()}s`
}
emptyMessage={open ? `Waiting for the first ${noun.toLowerCase()}` : `No ${noun}s`}
splitLayoutStorageKey="proxy_flow_messages"
columns={columns}
loadPayload={loadPayload}
@@ -4,14 +4,15 @@ 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 four are all this renders.
// The package's index reaches for the window as it loads; these three 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>,
IconTooltip: ({ content }: { content: string }) => <span data-tooltip={content} />,
CONTROL_TOOLTIP_DELAY: 0,
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
const data = (truncated = false): FlowBodyData => ({
@@ -44,13 +45,10 @@ describe("FlowRequestBody", () => {
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
});
test("a truncated body says how much is shown, and why behind its info icon", () => {
test("a cut body says how much was kept", () => {
const html = render({ data: data(true), isLoading: false, error: null });
expect(html).toContain("EDITOR");
expect(html).toContain('data-banner="notice"');
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."',
);
expect(html).toContain("kept");
});
});
+12 -8
View File
@@ -1,5 +1,11 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, EmptyStateText, IconTooltip, LoadingIcon } from "@yaakapp-internal/ui";
import {
Banner,
CONTROL_TOOLTIP_DELAY,
EmptyStateText,
LoadingIcon,
Tooltip,
} from "@yaakapp-internal/ui";
import type { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes";
import type { ReactNode } from "react";
import type { FlowBodyData } from "../lib/flowViews";
@@ -36,13 +42,11 @@ 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">
<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>
<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>
</Banner>
<div className="min-h-0">{viewer}</div>
</div>
@@ -0,0 +1,71 @@
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="Approve Helper"
description="Turn on Yaak Proxy in System Settings > General > Login Items."
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."
className="w-[34rem]"
>
<div className="grid gap-3 pb-4">
-174
View File
@@ -1,174 +0,0 @@
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 recorded traffic,{" "}
Encrypting stored 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 */}
@@ -0,0 +1,97 @@
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>
);
}
+2 -3
View File
@@ -31,7 +31,6 @@ 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() {
@@ -167,7 +166,6 @@ 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>
@@ -192,9 +190,10 @@ export function ProxyLayout() {
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
const locked = useAtomValue(lockedAtom);
const noFlows = useAtomValue(noFlowsAtom) || locked != null;
const { systemProxy } = useProxySetup();
const { caStatus, systemProxy } = useProxySetup();
const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom);
const incomplete = setupIncomplete({
trusted: caStatus.data?.trusted ?? null,
systemProxySupported: systemProxy.data?.supported ?? null,
systemProxyEverOn,
});
+65 -99
View File
@@ -1,5 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import {
Button,
Checkbox,
@@ -13,13 +13,10 @@ 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";
@@ -30,10 +27,9 @@ 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 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.
* 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.
*/
export function ProxyStatus() {
const dropdownRef = useRef<DropdownRef>(null);
@@ -42,21 +38,14 @@ export function ProxyStatus() {
const setup = useProxySetup();
const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
const { caStatus, systemProxy, setSystemProxy } = setup;
const { caStatus, trust, 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 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);
const untrusted = caStatus.data != null && !caStatus.data.trusted;
// The same status the header's tint takes its colour from
const status = proxyStatusOf(proxyState);
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
@@ -65,20 +54,24 @@ export function ProxyStatus() {
status === "encrypting" ? (
<MenuRow
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
title={`Encrypting recorded traffic, ${encrypting}`}
description="Recording will start once encryption finishes."
title={`Encrypting stored traffic, ${encrypting}`}
description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
/>
) : status === "cli" && other != null ? (
<MenuRow
icon={<StateDot state="cli" />}
title="Running from the CLI"
description={`The proxy was started by the CLI at ${timeOf(other.startedAt)}.`}
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
/>
) : status === "recording" || status === "paused" ? (
<MenuRow
icon={<StateDot state={status} />}
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
description={recording ? null : "Traffic passes through without being recorded."}
description={
recording
? "Every flow is stored as it passes."
: "Traffic passes through untouched, and nothing is stored."
}
/>
) : (
<MenuRow
@@ -86,7 +79,7 @@ export function ProxyStatus() {
title="Not running"
description={
<span className="text-danger">
{retry.error?.message ?? proxyState?.startError ?? "The proxy failed to start."}
{retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."}
</span>
}
action={
@@ -115,7 +108,7 @@ export function ProxyStatus() {
const address = port == null ? null : `127.0.0.1:${port}`;
const items: DropdownItem[] = [
{
label: <span className="font-mono text-editor">{address}</span>,
label: <span className="font-mono">{address}</span>,
hidden: address == null,
keepOpenOnSelect: true,
rightSlot: copied ? (
@@ -152,7 +145,9 @@ export function ProxyStatus() {
{systemSwitch.description}
</span>
{helperNotice && (
<span className="block text-notice">macOS will ask for a password each time.</span>
<span className="block text-notice">
macOS asks for your password each time until the helper is approved in Settings.
</span>
)}
</>
}
@@ -171,7 +166,44 @@ export function ProxyStatus() {
/>
),
},
{ type: "content", label: <DecryptEverythingRow broken={broken} /> },
{
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>
}
/>
),
},
];
return (
@@ -203,7 +235,7 @@ export function ProxyStatus() {
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
{broken && (
{untrusted && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
@@ -213,73 +245,6 @@ 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.
@@ -300,6 +265,10 @@ 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) => {
@@ -315,7 +284,7 @@ export function useProxySetup() {
useEffect(() => {
if (on && !everOn) setEverOn(true);
}, [on, everOn, setEverOn]);
return { caStatus, systemProxy, setSystemProxy };
return { caStatus, trust, systemProxy, setSystemProxy };
}
/** The system proxy switch's state, from where it points and the change in flight */
@@ -344,7 +313,7 @@ export function useCopyEnvironment() {
try {
await navigator.clipboard.writeText(environmentExports(port, caPath));
showToast({
message: "Copied the environment. Paste it into a terminal.",
message: "Copied the environment. Paste it into a terminal to proxy what runs there.",
});
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
@@ -423,7 +392,6 @@ function MenuRow({
}: {
icon: ReactNode;
title: string;
/** Under the title; null for a title that says it all */
description: ReactNode;
action?: ReactNode;
}) {
@@ -432,9 +400,7 @@ function MenuRow({
<span className="h-5 flex items-center justify-center">{icon}</span>
<div>
<div className="text-sm text-text">{title}</div>
{description != null && (
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
)}
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
</div>
<span className="self-center">{action}</span>
</div>
+24 -20
View File
@@ -40,8 +40,8 @@ export function SaveDialog() {
open
onClose={close}
size="dynamic"
title="Save as Yaak Request"
description="A new request will be created in Yaak."
title="Save as Yaak request"
description="A new request in Yaak with this flow's method, URL, headers and body."
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}`}
{folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second.
</>
),
});
@@ -113,7 +113,11 @@ function SaveForm({
};
if (workspaces.length === 0) {
return <Banner color="notice">Create a workspace in Yaak first.</Banner>;
return (
<Banner color="notice">
Yaak has no workspaces yet. Create one in Yaak, then save here.
</Banner>
);
}
if (p == null) {
@@ -205,10 +209,13 @@ 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,
@@ -236,19 +243,15 @@ 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">
The flow can be exported as a file instead.
</span>
}
leftSlot={<span className="text-xs text-text-subtlest">Or keep the flow as a file</span>}
actions={[
{
label: "Export as HAR",
label: "Export HAR",
onClick: async () => {
try {
if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) {
onClose();
showToast({ message: "Exported 1 flow as HAR" });
showToast({ message: "Exported the flow as HAR" });
}
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
@@ -259,10 +262,11 @@ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
]}
/>
<div className="pt-6 pb-2">
<div className="text-lg font-semibold mb-1.5">Yaak Required</div>
<div className="text-lg font-semibold mb-1.5">Saving needs Yaak</div>
<div className="text-sm text-text-subtle leading-relaxed">
This flow will be saved as a request in one of Yaak's workspaces, but Yaak has not been
opened on this computer.
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.
</div>
</div>
</Dialog>
@@ -304,18 +308,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 the request.";
return "Yaak sets it when it sends";
case "hop_by_hop":
return "It was only meant for the proxy.";
return "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 the request.";
return "Yaak sets these itself when it sends";
}
return "Yaak sets these, or they were meant for the proxy.";
return "Yaak sets these itself, or they only meant something to 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 searches", disabled: true }]
? [{ label: "No recent filters", disabled: true }]
: [
...history.map((filter): DropdownItem => ({
label: (
@@ -311,7 +311,7 @@ function FilterHistory({
<IconButton
size="xs"
icon="search"
title="Recent searches"
title="Recent filters"
tooltip
iconColor="secondary"
disabled={disabled}
+6 -14
View File
@@ -16,14 +16,13 @@ import {
type SettingsTitlebar,
Toasts,
} from "@yaakapp-internal/ui";
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { atom, useAtom, useAtomValue, useSetAtom } 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,
@@ -126,16 +125,9 @@ 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, hasUpdater ? { checking } : null),
[values, app, systemAppearance, hasUpdater, checking],
() => proxySettingsView(values, app, systemAppearance),
[values, app, systemAppearance],
);
const showToast = useShowToast();
const callbacks = useMemo<SettingsCallbacks>(() => {
@@ -144,14 +136,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: () => void checkForUpdatesNow(store),
onCheckForUpdates: () => undefined,
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, store]);
}, [change, showToast]);
const props = { view, callbacks };
return (
@@ -235,7 +227,7 @@ function useAppInfo() {
? "Reveal in Finder"
: os === "windows"
? "Show in Explorer"
: "Show in File Manager",
: "Show in Folder",
}),
[data, os],
);
+31 -32
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 { DecryptionSettings } from "./DecryptListEditor";
import { DecryptListEditor } from "./DecryptListEditor";
/**
* The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what
@@ -59,30 +59,31 @@ export function SettingsProxy() {
const elsewhere = proxyState?.otherOwner != null;
return (
<SettingsPage
title="Proxy"
description="Configure the port, decryption, recorded traffic, and terminal environment."
>
<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>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<PortRow disabled={elsewhere} />
<SettingRowSelect<HttpVersion>
name="httpVersion"
title="HTTP version"
description="The change applies to new connections."
description="For new connections"
value={proxyState?.httpVersion ?? "auto"}
disabled={elsewhere}
onChange={(httpVersion) => setHttpVersion.mutate({ httpVersion })}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Auto", value: "auto" },
{ label: "HTTP/1.1 only", value: "http1Only" },
]}
/>
{systemProxy.data?.supported === true && (
<>
<SettingRowBoolean
title="Override system proxy at launch"
description="Traffic from every app will be recorded."
title="Override system proxy"
description="At launch"
checked={proxyState?.systemProxyAtLaunch ?? true}
disabled={setAtLaunch.isPending}
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
@@ -93,7 +94,7 @@ export function SettingsProxy() {
</SettingsSection>
<SettingsSection title="Decryption">
<DecryptionSettings />
<DecryptListEditor className="py-4" />
</SettingsSection>
<SettingsSection title="Traffic">
@@ -107,7 +108,7 @@ export function SettingsProxy() {
<TerminalRow />
</SettingsSection>
</SettingsList>
</SettingsPage>
</VStack>
);
}
@@ -151,7 +152,7 @@ function PortRow({ disabled }: { disabled: boolean }) {
setPort.mutate({ port });
};
return (
<SettingRow title="Port" description="Changing the port restarts the proxy.">
<SettingRow title="Port" description="Restarts the proxy">
<PlainInput
hideLabel
size="sm"
@@ -188,10 +189,7 @@ function HelperRow({ status }: { status: SystemProxyStatus }) {
unregister.error != null ? (
<span className="text-danger">{unregister.error.message}</span>
) : (
<span className="inline-flex items-center gap-1.5">
{row.description}
<IconTooltip iconSize="sm" content={row.tooltip} />
</span>
`${capitalize(row.title.replace(/^Helper: /, ""))}. ${row.description}`
)
}
>
@@ -207,7 +205,7 @@ function HelperRow({ status }: { status: SystemProxyStatus }) {
? "Install"
: row.action === "approve"
? "Approve"
: "Uninstall"}
: "Unregister"}
</Button>
)}
</SettingRow>
@@ -224,7 +222,7 @@ function TrafficRow() {
return (
<SettingRowSelect<TrafficRetention>
name="traffic"
title="Recorded traffic"
title="Captured traffic"
selectClassName="w-56!"
value={proxyState?.traffic ?? "clearOnLaunch"}
disabled={proxyState?.otherOwner != null}
@@ -247,7 +245,7 @@ function KeepTrafficRow() {
});
const keep = proxyState?.keepTrafficBytes ?? null;
return (
<SettingRow title="Size limit" description="The oldest flows will be cleared first.">
<SettingRow title="Size limit" description="Oldest flows dropped past it">
<div className="flex items-center gap-3">
{size.data != null && (
<span className="text-sm text-text-subtle tabular-nums whitespace-nowrap">
@@ -287,24 +285,21 @@ function ResetRow() {
onSuccess: () => {
setConfirming(false);
void queryClient.invalidateQueries();
showToast({ message: "Recorded data reset" });
showToast({ message: "Captured data reset" });
},
onSettled: () => void refreshProxyState(store),
});
return (
<SettingRow
title="Reset recorded data"
description="All traffic and its encryption keys will be deleted."
>
<SettingRow title="Reset captured data" description="Deletes all flows and their keys">
<Button size="xs" variant="border" color="danger" onClick={() => setConfirming(true)}>
Reset
Reset…
</Button>
<Dialog
open={confirming}
onClose={() => setConfirming(false)}
size="sm"
title="Reset Recorded Data"
description="Permanently delete all traffic and its encryption keys?"
title="Reset captured data?"
description="Every captured flow and the keys that encrypt them are deleted."
>
{reset.error != null && <p className="text-danger text-sm">{reset.error.message}</p>}
<DialogFooter
@@ -361,8 +356,8 @@ function TerminalRow() {
className="ml-auto"
title={
os === "macos"
? "A new Terminal window will open with these variables set."
: "Opening a terminal is only supported on macOS."
? "A new Terminal window with these set"
: "Not available on this platform yet"
}
disabled={caPath == null || os !== "macos"}
isLoading={openTerminal.isPending}
@@ -373,7 +368,7 @@ function TerminalRow() {
Open Terminal
</Button>
</div>
<p className="mt-1 text-sm text-text-subtle">Traffic from one terminal will be recorded.</p>
<p className="mt-1 text-sm text-text-subtle">Proxies one terminal instead of the system</p>
{caPath != null && (
<SettingSnippet
className="mt-3"
@@ -384,3 +379,7 @@ function TerminalRow() {
</div>
);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
+26 -64
View File
@@ -10,30 +10,21 @@ import {
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react";
import { useDecrypt } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { appliedFilterAtom, SIDEBAR_TREE_ID } from "../lib/atoms";
import {
blockerOf,
decryptMenuAction,
decryptsEverything,
everythingEdit,
listEdit,
setDecrypted,
} from "../lib/decrypt";
import { blockerOf, decryptMenuAction, isDecrypted, 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 { hideEncryptedAtom, hideSystemAtom, sidebarTreeAtom } from "../lib/store";
import { hideSystemAtom, sidebarTreeAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
@@ -82,12 +73,10 @@ 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 === "encrypted" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "tunnelled" && <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] min-w-[1.125rem]" />
)}
{item.appPath != null && <AppIcon path={item.appPath} className="size-[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}>
@@ -99,11 +88,7 @@ function ItemInner({ item }: { item: SidebarItem }) {
{item.sublabel != null && (
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
)}
{item.encryptedOnly && (
<Tooltip wrap content="Encrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
)}
{item.decrypt != null && <DecryptedMark item={item} />}
<Count item={item} />
</div>
);
@@ -132,12 +117,14 @@ function CloseAtOnce({ onClose }: { onClose: () => void }) {
return null;
}
/** 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" />
/** 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>
);
}
@@ -150,9 +137,9 @@ export function Sidebar() {
[],
);
const [hideSystem, setHideSystem] = useAtom(hideSystemAtom);
const [hideEncrypted, setHideEncrypted] = useAtom(hideEncryptedAtom);
const decryptList = useDecryptList();
const { apply: applyDecrypt } = useDecrypt();
const setDecryptList = useEditDecryptList();
const { mutate: saveDecryptList } = setDecryptList;
const showToast = useShowToast();
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", {
@@ -175,7 +162,7 @@ export function Sidebar() {
onSelect: () => void revealPath(path, fail),
},
{
label: "Copy Path",
label: "Copy path",
leftSlot: <Icon icon="copy" />,
hidden: item.kind !== "folder",
onSelect: () => {
@@ -203,9 +190,9 @@ export function Sidebar() {
onSelect: async () => {
try {
await navigator.clipboard.writeText(copyRowsText(items));
showToast({ message: "Copied to clipboard" });
showToast({ message: "Copied value" });
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
showToast({ message: `Failed to copy value: ${String(err)}`, color: "danger" });
}
},
},
@@ -223,35 +210,15 @@ export function Sidebar() {
return [
{
label: "Show in All Traffic",
leftSlot: checkMark(!hideSystem),
leftSlot: hideSystem ? (
<span aria-hidden className="inline-block w-4 h-4" />
) : (
<Icon icon="check" size="sm" />
),
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) {
@@ -298,10 +265,7 @@ 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);
void applyDecrypt({
edit: listEdit(decryptList, next),
message: `${action.on ? "Decrypting" : "Stopped decrypting"} ${action.subject}`,
});
saveDecryptList(listEdit(decryptList, next));
},
},
];
@@ -309,7 +273,7 @@ export function Sidebar() {
},
[
decryptList,
applyDecrypt,
saveDecryptList,
showToast,
setSavedDialog,
deleteSaved,
@@ -317,8 +281,6 @@ 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.
"Quit the other copy to use this one."
"Another Yaak Proxy window owns the engine. Quit it to use this one."
) : takeOver.error != null ? (
<span className="text-danger">{takeOver.error.message}</span>
) : (
`pid ${other.pid}, since ${since}`
`pid ${other.pid}, since ${since}. Flows still list here, read from the capture database.`
)}
</span>
{cli && (
@@ -44,7 +44,7 @@ export function TakeOverBanner() {
isLoading={takeOver.isPending}
onClick={() => takeOver.mutate({})}
>
Take Over
Take over
</Button>
)}
</div>
+3 -19
View File
@@ -11,9 +11,8 @@ 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), `&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.
* 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.
*/
export function FlowPanesHarness() {
const params = new URLSearchParams(location.search);
@@ -41,28 +40,13 @@ 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(() => {
// `&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 flows = fixtures.map((f, i) => ({ ...f.flow, number: 1401 + i }));
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: Failed to read the encryption key from the keychain: Encryption error: " +
"User canceled the operation";
"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.";
const STATE: GetProxyStateResponse = {
state: "stopped",
+14 -107
View File
@@ -1,5 +1,4 @@
import type {
DecryptListEntry,
Flow,
FlowSortKey,
GetProxyStateResponse,
@@ -7,59 +6,16 @@ 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);
@@ -182,16 +138,11 @@ 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,
@@ -199,15 +150,7 @@ function allFlows(): Flow[] {
}));
}
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> {
function answer(cmd: string, payload: unknown): unknown {
const req = (payload ?? {}) as Record<string, unknown>;
switch (cmd) {
case "get_flows": {
@@ -288,55 +231,14 @@ async function answer(cmd: string, payload: unknown): Promise<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 {
entries: https.list,
stillEncrypted: decrypts ? Number(params.get("still") ?? 3) : 0,
trusted: true,
installed: true,
certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem",
};
}
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: !params.has("firstrun"),
enabled: true,
port: 9090,
services: [],
backend: "helper",
@@ -344,7 +246,13 @@ async function answer(cmd: string, payload: unknown): Promise<unknown> {
supported: true,
};
case "get_decrypt_list":
return { entries: https.list };
return {
entries: [
{ entry: "host:api.stripe.com", builtIn: false },
{ entry: "app:Google Chrome", builtIn: false },
{ entry: "!host:*.apple.com", builtIn: true },
],
};
case "list_actions":
// As the engine lists them on macOS
return {
@@ -362,8 +270,7 @@ async function answer(cmd: string, payload: unknown): Promise<unknown> {
case "list_flows": {
const all = allFlows();
const query = typeof req.query === "string" ? req.query : "";
const hideEncrypted = req.hideEncrypted === true && !query.includes("@is:encrypted");
const matching = all.filter((f) => matches(f, query) && !(hideEncrypted && f.tunnelled));
const matching = all.filter((f) => matches(f, query));
// 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();
@@ -371,7 +278,7 @@ async function answer(cmd: string, payload: unknown): Promise<unknown> {
return {
flows: page,
total: all.length,
matching: query === "" && !hideEncrypted ? null : matching.length,
matching: query === "" ? null : matching.length,
cursor: null,
};
}
-7
View File
@@ -52,12 +52,6 @@ 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,
@@ -215,7 +209,6 @@ export const fixtures: FlowFixture[] = [
requestSize: 3_412,
responseSize: 48_210,
tunnelled: true,
tunnelReason: "not_listed",
},
requestBody: body(""),
responseBody: body(""),
-179
View File
@@ -1,179 +0,0 @@
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,12 +51,6 @@ 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
@@ -1,202 +0,0 @@
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,7 +5,9 @@ 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", {}];
@@ -25,3 +27,19 @@ 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 }),
});
}
+1 -26
View File
@@ -2,7 +2,6 @@ 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,
@@ -231,7 +230,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";
const note = "A WebSocket has no body after its 101 response. Its frames are its messages.";
const body = viewerBody({ data: "", truncated: false, storedSize: 0, note });
expect(body.note).toBe(note);
expect(emptyBodyText("response", body.note)).toBe("No body. See Messages.");
@@ -244,27 +243,3 @@ 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,45 +208,6 @@ 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",
+2 -74
View File
@@ -1,17 +1,11 @@
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,
@@ -26,7 +20,6 @@ import {
hideOtherColumns,
moveColumn,
moveColumnBy,
NUMERIC_FIT_CHARS,
nextSort,
operationOf,
parseColumnPrefs,
@@ -35,7 +28,6 @@ import {
resetWidth,
resetWidths,
resizeColumn,
resizeDistance,
showOperationFor,
shownColumns,
sortFlows,
@@ -43,7 +35,6 @@ import {
toggleSortKey,
urlParts,
} from "./columns";
import { DEFAULT_SETTINGS_VALUES } from "./settings";
function flow(number: number, extra: Partial<Flow> = {}): Flow {
return {
@@ -118,73 +109,10 @@ 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].map(pad), size, time].join(" "),
["24px", ...[46, 20, 50, 240, 500, 120, 46, 50, 72, 64].map(pad)].join(" "),
);
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 });
expect(gridWidth(columns)).toBe("calc(1232px + 10 * 0.75rem)");
});
});
+9 -54
View File
@@ -23,11 +23,6 @@ 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 = (
@@ -37,29 +32,7 @@ const builtin = (
minWidth: number,
align: "left" | "right",
defaultVisible: boolean,
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;
): ColumnDef => ({ id, label, width, minWidth, align, defaultVisible });
export const COLUMNS: ColumnDef[] = [
builtin("number", "#", 46, 30, "left", true),
@@ -70,11 +43,10 @@ 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),
// 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("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),
builtin("started", "Started", 92, 60, "left", false),
builtin("remote", "Remote Address", 150, 60, "left", false),
builtin("version", "HTTP Version", 70, 44, "left", false),
@@ -239,30 +211,16 @@ 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(${columnTrack(c)} + ${CELL_PADDING})`);
const cells = columns.map((c) => `calc(${c.current}px + ${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.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})`;
const px = columns.reduce((sum, c) => sum + c.current, MARK_WIDTH);
return `calc(${px}px + ${columns.length} * ${CELL_PADDING})`;
}
/** Whether the picker may uncheck `id`: any column but the last one shown */
@@ -448,10 +406,7 @@ 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];
// 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);
}
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
}
// Columns no longer there are dropped; those not listed follow the rest in their default order
if (Array.isArray(order)) {
+5 -48
View File
@@ -11,12 +11,8 @@ import {
decryptMenuAction,
decrypts,
decryptsEverything,
editDecrypts,
entryLabel,
entrySubject,
everythingEdit,
inverseEdit,
listSummary,
isDecrypted,
listEdit,
normalizeEntry,
@@ -174,31 +170,15 @@ 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, naming what it changes", () => {
test("offers Decrypt unless every selected row is decrypted", () => {
expect(decryptMenuAction(user("host:a.com"), [a])).toEqual({
label: "Stop Decrypting a.com",
label: "Stop Decrypting",
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"]);
@@ -245,8 +225,8 @@ describe("the Not decrypted detail's buttons", () => {
});
});
describe("Decrypt Everything", () => {
test("adds *, and the switch off takes it out", () => {
describe("the first-run step", () => {
test("Everything adds *, and Pick as they appear takes it out", () => {
expect(decryptsEverything(BUILT_INS)).toBe(false);
expect(decryptsEverything([...user("*"), ...BUILT_INS])).toBe(true);
expect(everythingEdit(true)).toEqual({ add: ["*"], remove: [] });
@@ -323,26 +303,3 @@ 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"] });
});
});
+4 -90
View File
@@ -163,12 +163,6 @@ 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;
@@ -199,52 +193,6 @@ 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 }
@@ -387,44 +335,10 @@ export function setDecrypted(
export function decryptMenuAction(
list: DecryptListEntry[],
subjects: DecryptSubject[],
): { label: string; on: boolean; subject: string } | null {
): { label: "Decrypt" | "Stop Decrypting"; on: boolean } | null {
if (subjects.length === 0) return null;
const on = !subjects.every((s) => isDecrypted(list, s));
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()}` };
return { label: on ? "Decrypt" : "Stop Decrypting", on };
}
function builtIns(list: DecryptListEntry[]): DecryptListEntry[] {
@@ -473,8 +387,8 @@ export function decryptFilterOptions(seen: SeenTargets | null | undefined): Filt
replaceValueOnly: true,
negation: { prefix: "!", info: "Exclude" },
fields: [
{ name: "host", values: seen?.hosts ?? [], info: "Host or *.domain" },
{ name: "app", values: [...apps], info: "App name or bundle ID" },
{ name: "host", values: seen?.hosts ?? [], info: "Host or *.site" },
{ name: "app", values: [...apps], info: "App name or bundle id" },
],
};
}
-127
View File
@@ -1,127 +0,0 @@
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
@@ -1,87 +0,0 @@
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,
};
}
+17 -21
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,7 +33,13 @@ function memory(): TabMemory & { items: Map<string, string> } {
};
}
const RESPONSE_TABS = [RESPONSE_TAB_BODY, RESPONSE_TAB_HEADERS, RESPONSE_TAB_COOKIES];
const RESPONSE_TABS = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
"cookies",
RESPONSE_TAB_TIMELINE,
RESPONSE_TAB_INFO,
];
describe("flowShape", () => {
test("names what the flow has", () => {
@@ -53,8 +59,8 @@ describe("flowShape", () => {
describe("defaultTabFor", () => {
test("follows the table", () => {
const m = memory();
// A response body, the request's Info, as there is no request body
expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_INFO);
// A response body
expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_HEADERS);
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
@@ -63,28 +69,27 @@ 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_INFO);
expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_HEADERS);
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: its timeline is in the request's Info
// No response at all
const failed = { ...ok, status: null, error: "refused", responseSize: 0 };
expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
expect(defaultTabFor("request", failed, [], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_TIMELINE);
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_COOKIES, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_COOKIES);
learnTab("response", ok, RESPONSE_TAB_INFO, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_INFO);
// 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, ["info", "body", "headers"], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("request", ok, ["body", "headers", "info"], m)).toBe(REQUEST_TAB_HEADERS);
});
test("a learned tab the flow doesn't have falls back to the table", () => {
@@ -95,15 +100,6 @@ 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);
});
+6 -7
View File
@@ -1,7 +1,6 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
/** The request half's tabs, named as the shared request pane names them, Info first */
export const REQUEST_TAB_INFO = "info";
/** The request half's tabs, named as the shared request pane names them */
export const REQUEST_TAB_BODY = "body";
export const REQUEST_TAB_PARAMS = "params";
export const REQUEST_TAB_HEADERS = "headers";
@@ -12,6 +11,8 @@ 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";
@@ -32,13 +33,10 @@ export function flowShape(
return "neither";
}
/**
* 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
*/
/** The tab a half opens on for a shape, before anything was learned */
export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody: boolean): string {
if (pane === "request") {
return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_INFO;
return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_HEADERS;
}
switch (shape) {
case "response_body":
@@ -46,6 +44,7 @@ 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
@@ -1,367 +0,0 @@
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
@@ -1,423 +0,0 @@
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."],
};
}
+28 -6
View File
@@ -1,8 +1,12 @@
import { describe, expect, test } from "vite-plus/test";
import { setupIncomplete } from "./firstRun";
import { decryptStep, setupIncomplete } from "./firstRun";
describe("setupIncomplete", () => {
const done = { systemProxySupported: true, systemProxyEverOn: true };
const done = { trusted: true, systemProxySupported: true, systemProxyEverOn: true };
test("is incomplete while the certificate is untrusted", () => {
expect(setupIncomplete({ ...done, trusted: false })).toBe(true);
});
test("is incomplete until the system proxy has been on once", () => {
expect(setupIncomplete({ ...done, systemProxyEverOn: false })).toBe(true);
@@ -13,11 +17,29 @@ describe("setupIncomplete", () => {
expect(
setupIncomplete({ ...done, systemProxySupported: false, systemProxyEverOn: false }),
).toBe(false);
expect(setupIncomplete({ ...done, systemProxySupported: false, trusted: false })).toBe(true);
});
test("waits for the system proxy to be read, so the card does not flash", () => {
expect(setupIncomplete({ ...done, systemProxySupported: null, systemProxyEverOn: false })).toBe(
false,
);
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");
});
});
+29 -6
View File
@@ -1,5 +1,10 @@
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 */
@@ -7,13 +12,31 @@ export interface SetupState {
}
/**
* 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.
* 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.
*/
export function setupIncomplete(s: SetupState): boolean {
if (s.systemProxySupported == null) return false;
if (s.trusted == null || s.systemProxySupported == null) return false;
if (!s.trusted) return true;
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" };
}
+7 -11
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 received, since it failed to decode" : null,
saved.truncated ? "truncated at the capture limit" : null,
saved.raw ? "as it was on the wire, since it failed to decode" : null,
saved.truncated ? "cut at the capture limit" : null,
].filter((n) => n != null);
showToast({
message: `Saved ${formatSize(saved.size)}${notes.length > 0 ? `, ${notes.join(", ")}` : ""}`,
@@ -135,26 +135,22 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
return;
}
await navigator.clipboard.writeText(value);
showToast({ message: "Copied to clipboard" });
showToast({ message: `Copied ${label}` });
} catch (err) {
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
showToast({ message: `Failed to copy ${label}: ${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) {
@@ -173,7 +169,7 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
// A command that sends other bytes than the app did would mislead.
if (body == null) {
showToast({
message: "The body is binary or truncated. Export as HAR instead.",
message: "Body is binary or truncated; export HAR instead",
color: "info",
});
return;
@@ -209,12 +205,12 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
onSelect: () => saveBody(flow, "response"),
},
{
label: "Export as HAR",
label: "Export HAR",
icon: "download",
onSelect: async () => {
try {
const written = await exportHar({ ids: [flow.id] }, "flow.har");
if (written != null) showToast({ message: "Exported 1 flow as HAR" });
if (written != null) showToast({ message: "Exported the flow as HAR" });
} catch (err) {
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
}
+1 -7
View File
@@ -60,12 +60,6 @@ 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,
@@ -225,7 +219,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 app");
expect(view?.error).toBe("Cancelled by the client");
});
});
+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 app" : null),
error: flow.error ?? (flow.cancelled && flow.status == null ? "Cancelled by the client" : null),
remoteAddr: flow.remoteAddr,
version: flow.upstreamHttpVersion ?? flow.clientHttpVersion,
cookieHeaders: {
+4 -42
View File
@@ -8,14 +8,8 @@ vi.mock("@yaakapp-internal/ui", async () => {
return { selectedIdsFamily: () => atom<string[]>([]) };
});
const {
activeQueryAtom,
allTrafficTotalAtom,
flowTotalAtom,
hideEncryptedAtom,
hideSystemAtom,
allTrafficShownAtom,
} = await import("./store");
const { activeQueryAtom, allTrafficTotalAtom, flowTotalAtom, hideSystemAtom, nonSystemTotalAtom } =
await import("./store");
describe("Show in All Traffic, in System's menu", () => {
test("shows System's traffic in All traffic by default", () => {
@@ -26,45 +20,13 @@ 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(allTrafficShownAtom, 3);
store.set(nonSystemTotalAtom, 3);
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
store.set(hideSystemAtom, true);
expect(store.get(activeQueryAtom)).toEqual({
query: "",
hideSystem: true,
hideEncrypted: false,
});
expect(store.get(activeQueryAtom)).toEqual({ query: "", hideSystem: true });
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
@@ -1,83 +0,0 @@
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
@@ -1,107 +0,0 @@
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 CLI (pid 4).",
message: "Traffic is locked: 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).");
expect(lockedLine(lock, "macos")).toBe("The proxy is already running in the yaak 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 reveal /data/app.yaak.proxy: not allowed: opener.reveal_item_in_dir",
"Failed to show /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 File Manager");
expect(revealLabel("linux")).toBe("Show in Folder");
});
});
+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 reveal ${path}: ${String(err)}`);
report(`Failed to show ${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 File Manager";
return "Show in Folder";
}
+2 -11
View File
@@ -55,17 +55,8 @@ 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, hideEncrypted: false });
expect(activeQuery(" stripe ", false)).toEqual({
query: "stripe",
hideSystem: false,
hideEncrypted: false,
});
expect(activeQuery("", false, true)).toEqual({
query: "",
hideSystem: false,
hideEncrypted: true,
});
expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true });
expect(activeQuery(" stripe ", false)).toEqual({ query: "stripe", hideSystem: false });
});
});
+4 -10
View File
@@ -3,23 +3,17 @@ 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 and encrypted connections are
* hidden.
* which the backend parses and runs, and whether System's flows 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,
hideEncrypted = false,
): FlowQuery | null {
if (text.trim() === "" && !hideSystem && !hideEncrypted) return null;
return { query: text.trim(), hideSystem, hideEncrypted };
export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null {
if (text.trim() === "" && !hideSystem) return null;
return { query: text.trim(), hideSystem };
}
/** `@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 supported",
notYet: "Not yet",
};
/** The terms of a search the backend cannot run, as the box underlines them */
-12
View File
@@ -67,18 +67,6 @@ 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", () => {
+4 -9
View File
@@ -4,7 +4,6 @@ import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme/src
import type {
SettingsAppInfo,
SettingsThemeOption,
SettingsUpdatesView,
SettingsValues,
SettingsView,
} from "@yaakapp-internal/ui";
@@ -23,8 +22,7 @@ export const DEFAULT_SETTINGS_VALUES: SettingsValues = {
coloredMethods: false,
useNativeTitlebar: false,
hideWindowControls: false,
// Early access ships on Yaak's beta tags, so the proxy follows beta until it goes stable
updateChannel: "beta",
updateChannel: "stable",
autoupdate: true,
autoDownloadUpdates: true,
checkNotifications: true,
@@ -72,15 +70,13 @@ 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 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.
* 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.
*/
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();
@@ -97,8 +93,7 @@ export function proxySettingsView(
},
fonts: null,
windowControlsSetting: false,
// The proxy fetches no notifications, so its Updates section has no row for them
updates: updates && { ...updates, notificationsSetting: false },
updates: null,
feedbackPrompts: false,
license: null,
// Not acted on in the proxy yet: its editors take no keymap or wrap setting, its list draws
+9 -16
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,20 +215,13 @@ describe("decrypting from the sidebar", () => {
const root = buildSidebarTree([...flows, tunnel], 5);
const items = itemsById(root);
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("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("hosts, sites and apps name what their menu's Decrypt covers", () => {
@@ -338,7 +331,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",
+27 -50
View File
@@ -14,7 +14,7 @@ import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf
export type SidebarKind =
| "all"
| "encrypted"
| "tunnelled"
| "saved"
| "group"
| "app"
@@ -48,11 +48,6 @@ 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;
/**
@@ -76,7 +71,7 @@ export const SAVED_ID = "saved";
export const APPS_ID = "apps";
export const DOMAINS_ID = "domains";
export const SYSTEM_ID = "system";
export const ENCRYPTED_ID = "encrypted";
export const TUNNELLED_ID = "tunnelled";
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
interface PlaceRow {
@@ -201,14 +196,12 @@ 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, encrypted: boolean): Trie {
function child(parent: Trie, row: PlaceRow): Trie {
let node = parent.children.get(row.id);
if (node == null) {
node = {
@@ -231,25 +224,13 @@ function child(parent: Trie, row: PlaceRow, encrypted: boolean): 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[], encrypted: boolean) {
function addPlace(parent: Trie, rows: PlaceRow[]) {
let node = parent;
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);
}
for (const row of rows) node = child(node, row);
}
function toNodes(
@@ -283,8 +264,9 @@ 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. 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.
* 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.
*/
export function buildSidebarTree(
flows: Flow[],
@@ -302,12 +284,12 @@ export function buildSidebarTree(
const domains = group(DOMAINS_ID, "Domains");
const system = { ...group(SYSTEM_ID, "System") };
system.item.query = fieldTerm("is", "system");
let encrypted = 0;
let tunnelled = 0;
for (const flow of flows) {
const place = placeOf(flow);
if (flow.tunnelled) encrypted++;
addPlace(domains, place.rows, flow.tunnelled);
if (flow.tunnelled) tunnelled++;
addPlace(domains, place.rows);
if (place.app == null) continue;
const parent = place.system ? system : apps;
const id = `app:${place.app}`;
@@ -336,22 +318,31 @@ 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, 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);
addPlace(app, place.appRows);
}
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: "" },
@@ -387,20 +378,6 @@ 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;
}
+13 -32
View File
@@ -81,14 +81,9 @@ 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), get(hideEncryptedAtom)),
activeQuery(get(appliedFilterAtom), get(hideSystemAtom)),
);
/** The saved searches, in their order */
@@ -108,16 +103,13 @@ const savedRowsAtom = atom<SavedRow[]>((get) => {
}));
});
/**
* Flows All traffic covers in the database: every one, or all but System's and the encrypted
* connections while either is hidden
*/
/** Flows All traffic covers in the database: every one, or all but System's while hidden */
export const allTrafficTotalAtom = atom((get) =>
get(hideSystemAtom) || get(hideEncryptedAtom) ? get(allTrafficShownAtom) : get(flowTotalAtom),
get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom),
);
/** Flows in the database All traffic shows, counted while System or Encrypted is hidden */
export const allTrafficShownAtom = atom(0);
/** Flows in the database not from System, counted while System is hidden */
export const nonSystemTotalAtom = atom(0);
/**
* The flows the sidebar counts: the window's, taken at most once a second, so a burst of traffic
@@ -458,11 +450,9 @@ export function syncFlows(store: Store) {
win.total === res.total ? win : { ...win, total: 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);
if (store.get(hideSystemAtom)) {
void rpc("list_flows", { hideSystem: true, limit: 0 }).then((res) => {
if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total);
});
}
countSaved();
@@ -495,10 +485,7 @@ export function syncFlows(store: Store) {
return;
}
counting = true;
rpc("count_saved_queries", {
hideSystem: store.get(hideSystemAtom),
hideEncrypted: store.get(hideEncryptedAtom),
})
rpc("count_saved_queries", { hideSystem: store.get(hideSystemAtom) })
.then((counts) =>
store.set(savedCountsAtom, Object.fromEntries(counts.map((c) => [c.id, c.count]))),
)
@@ -656,12 +643,9 @@ 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,
hideEncrypted: query.hideEncrypted,
}).catch((err) => console.warn("Failed to watch the search", err));
void rpc("watch_query", { key, query: query.query, hideSystem: query.hideSystem }).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;
@@ -709,9 +693,6 @@ 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
@@ -791,7 +772,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(allTrafficShownAtom, 0);
set(nonSystemTotalAtom, 0);
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: emptyWindow, buffered: [] }));
set(sortedWindowAtom, (s) =>
s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] },
+18 -23
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: "Traffic from every app will be recorded.",
description: "Routing this Mac's web traffic through Yaak Proxy",
failed: false,
});
expect(systemProxySwitch(off)).toEqual({
checked: false,
description: "Only traffic sent to port 9090 will be recorded.",
description: "Not routing. Only apps pointed at port 9090 are captured",
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 macOS",
description: "Waiting for your password",
failed: false,
});
});
@@ -41,9 +41,9 @@ describe("systemProxySwitch", () => {
});
test("snaps back on a cancelled prompt and says so", () => {
expect(systemProxySwitch({ ...off, error: "The password prompt was cancelled." })).toEqual({
expect(systemProxySwitch({ ...off, error: "Cancelled in the password dialog" })).toEqual({
checked: false,
description: "The password prompt was cancelled.",
description: "Cancelled in the password prompt",
failed: true,
});
expect(systemProxySwitch({ ...off, error: "networksetup failed" }).description).toBe(
@@ -53,32 +53,27 @@ describe("systemProxySwitch", () => {
});
describe("helperRow", () => {
test("offers each next step, and says what the helper is for", () => {
test("offers each next step, and says which way changes go", () => {
expect(helperRow("not_registered", "admin_prompt")).toMatchObject({
description: "The helper is not installed.",
title: "Helper: not installed",
action: "install",
});
expect(helperRow("requires_approval", "admin_prompt")).toMatchObject({
description: "The helper is awaiting approval.",
title: "Helper: awaiting approval",
action: "approve",
});
const enabled = helperRow("enabled", "helper");
expect(enabled).toMatchObject({
description: "The helper is installed.",
action: "unregister",
});
expect(enabled.tooltip).toMatch(/without a password prompt/);
expect(enabled).toMatchObject({ title: "Helper: enabled", action: "unregister" });
expect(enabled.description).toMatch(/No password/);
});
test("says when the password prompt still has settings to put back", () => {
expect(helperRow("enabled", "admin_prompt").tooltip).toBe(
"One more password prompt is needed to restore the system proxy settings.",
);
expect(helperRow("enabled", "admin_prompt").description).toMatch(/password prompt/);
});
test("offers to install when macOS can't find it, rather than blaming a dev build", () => {
expect(helperRow("not_found", "admin_prompt")).toMatchObject({
description: "macOS could not find the helper.",
title: "Helper: not found by macOS",
action: "install",
});
});
@@ -94,13 +89,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 row for every state the backend sends", () => {
test("has a titled 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,
@@ -111,7 +106,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).description).toMatch(/helper.*\.$/);
expect(helperRow(state, backend).title).toMatch(/^Helper: \S/);
}
}
});
@@ -119,15 +114,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({
description: "The helper is from_the_future.",
tooltip: "The helper changes the system proxy without a password prompt.",
title: "Helper: from_the_future",
description: "Changes use the password prompt.",
action: null,
});
});
test("says plainly that a dev build cannot use it", () => {
expect(helperRow("unavailable", "admin_prompt")).toMatchObject({
description: "The helper is not available in dev builds.",
title: "Helper: not in dev builds",
action: null,
});
});
+22 -26
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 macOS";
const description = state.helper ? "Changing the system proxy" : "Waiting for your password";
return { checked: state.pending, description, failed: false };
}
// A failed change leaves the switch where the system proxy actually is.
@@ -37,75 +37,71 @@ export function systemProxySwitch(state: SystemProxyState): SystemProxySwitch {
const cancelled = /cancel/i.test(state.error);
return {
checked,
description: cancelled ? "The password prompt was cancelled." : state.error,
description: cancelled ? "Cancelled in the password prompt" : state.error,
failed: true,
};
}
return {
checked,
description: checked
? "Traffic from every app will be recorded."
: `Only traffic sent to port ${state.port ?? 9090} will be recorded.`,
? "Routing this Mac's web traffic through Yaak Proxy"
: `Not routing. Only apps pointed at port ${state.port ?? 9090} are captured`,
failed: false,
};
}
export interface HelperRow {
/** The helper's state, in one sentence */
title: string;
/** Which way changes go, and why */
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;
}
/** 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 */
/** The privileged helper's row in the state pill menu, kept short */
export function helperRow(helper: HelperState, backend: SystemProxyBackend): HelperRow {
switch (helper) {
case "unavailable":
return {
description: "The helper is not available in dev builds.",
tooltip: HELPER_TOOLTIP,
title: "Helper: not in dev builds",
description: "Changes use the password prompt.",
action: null,
};
case "not_found":
// What macOS reports until the helper is first installed.
return {
description: "macOS could not find the helper.",
tooltip: HELPER_TOOLTIP,
title: "Helper: not found by macOS",
description: "Changes use the password prompt.",
action: "install",
};
case "not_registered":
return {
description: "The helper is not installed.",
tooltip: HELPER_TOOLTIP,
title: "Helper: not installed",
description: "Changes use the password prompt.",
action: "install",
};
case "requires_approval":
return {
description: "The helper is awaiting approval.",
tooltip: HELPER_TOOLTIP,
title: "Helper: awaiting approval",
description: "Changes use the password prompt.",
action: "approve",
};
case "enabled":
return {
description: "The helper is installed.",
tooltip:
title: "Helper: enabled",
description:
backend === "helper"
? HELPER_TOOLTIP
: "One more password prompt is needed to restore the system proxy settings.",
? "No password prompts."
: "One more password prompt puts back your settings.",
action: "unregister",
};
default: {
// Every state is handled above; one this build doesn't know still gets a row, rather
// than taking the whole page down.
// than taking the whole menu down.
const unknown: never = helper;
return {
description: `The helper is ${String(unknown)}.`,
tooltip: HELPER_TOOLTIP,
title: `Helper: ${String(unknown)}`,
description: "Changes use the password prompt.",
action: null,
};
}
-13
View File
@@ -24,19 +24,6 @@ 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
@@ -1,87 +0,0 @@
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
@@ -1,134 +0,0 @@
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 = "other__settings";
tauri.label = "settings";
announceEngineChange();
expect(tauri.sent).toEqual([{ event: "window_changed_engine", payload: { from: "other__settings" } }]);
expect(tauri.sent).toEqual([{ event: "window_changed_engine", payload: { from: "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: "other__settings" });
tauri.handlers.get("window_changed_engine")?.({ from: "settings" });
expect(refreshProxyState).toHaveBeenCalledWith(store);
expect(invalidate).toHaveBeenCalledTimes(1);
+1 -2
View File
@@ -3,7 +3,7 @@ import { openSettingsVia, requestedSettingsTab, windowKind } from "./windows";
describe("windowKind", () => {
test("the Settings window holds the settings", () => {
expect(windowKind("other__settings")).toBe("settings");
expect(windowKind("settings")).toBe("settings");
});
test("a main window holds the app", () => {
@@ -13,7 +13,6 @@ 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");
});
+2 -5
View File
@@ -1,8 +1,5 @@
/**
* 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 Settings window's label, which `cmd_open_settings` gives it */
export const SETTINGS_WINDOW_LABEL = "settings";
/** The event that moves an open Settings window to another tab */
export const SETTINGS_TAB_EVENT = "settings_tab";
-3
View File
@@ -11,7 +11,6 @@ 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";
@@ -23,8 +22,6 @@ 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) {
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more