Compare commits

...
Author SHA1 Message Date
dependabot[bot] 4e2917fac9 chore(deps): bump ts-rs from 11.1.0 to 12.0.1
Bumps [ts-rs](https://github.com/Aleph-Alpha/ts-rs) from 11.1.0 to 12.0.1.
- [Release notes](https://github.com/Aleph-Alpha/ts-rs/releases)
- [Changelog](https://github.com/Aleph-Alpha/ts-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Aleph-Alpha/ts-rs/commits)

---
updated-dependencies:
- dependency-name: ts-rs
  dependency-version: 12.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-11 06:47:58 +00:00
Gregory SchierandClaude Opus 5.5 6e39955f17 Yaak Proxy: release build, dry run and updater (#836)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:45:25 -07:00
Gregory Schier 9cc9f7a5b8 Fix font size 2026-11-10 22:32:37 -07:00
Gregory SchierandClaude Opus 5.5 c0f75bcfc2 Yaak Proxy: square app icons beside long names, and the final uncovered why line
App icons keep their square however a long app name squeezes them, in the
card's buttons, the strip's Choose menu and the sidebar, and a long entry
hint truncates instead of the button's label. A connection nothing on the
list covers reads "Encrypted Content: Enable decryption to see requests
to <host>", with the host as code.

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

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

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

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

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

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

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

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

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

No files matched your search

+190 -1
View File
@@ -2,10 +2,24 @@ name: Release App Artifacts
on:
push:
tags: [v*]
# The Yaak Proxy dry run only: builds, signs and notarizes the proxy for the chosen ref and
# keeps the results as workflow artifacts. It never builds Yaak, releases, tags or publishes.
workflow_dispatch:
inputs:
proxy_dry_run:
description: "Dry-run the Yaak Proxy build (artifacts only; nothing is released)"
type: boolean
required: true
default: true
proxy_version:
description: "Version to stamp into the dry-run build"
type: string
required: true
default: "0.0.1-dryrun"
jobs:
build-artifacts:
if: github.repository == 'mountain-loop/yaak'
if: github.repository == 'mountain-loop/yaak' && github.event_name == 'push'
permissions:
contents: write
@@ -290,3 +304,178 @@ jobs:
path: ${{ runner.temp }}/yaak-release-artifacts/
retention-days: 7
if-no-files-found: warn
# Yaak Proxy, macOS only, on the same tag and version as Yaak. Its own job, sharing nothing
# with Yaak's, so a proxy failure never stops Yaak's artifacts; it fails the run, visibly.
#
# On a v* tag it runs only once the repository variable RELEASE_PROXY is "true": the website's
# download links pick Yaak's assets by suffix and architecture, and its release audit expects
# Yaak's exact asset set and this run's success, so the proxy must not join Yaak's release
# until the website expects it there. The dry run (workflow_dispatch) runs regardless, and
# only keeps workflow artifacts.
build-proxy:
if: >-
github.repository == 'mountain-loop/yaak' && (
(github.event_name == 'push' && vars.RELEASE_PROXY == 'true') ||
(github.event_name == 'workflow_dispatch' && inputs.proxy_dry_run)
)
permissions:
contents: write
name: Build Yaak Proxy
strategy:
fail-fast: false
matrix:
include:
- target: "aarch64-apple-darwin" # for Arm-based Macs (M1 and above).
yaak_arch: "arm64"
lipo_arch: "arm64"
asset_arch: "aarch64"
- target: "x86_64-apple-darwin" # for Intel-based Macs.
yaak_arch: "x64"
lipo_arch: "x86_64"
asset_arch: "x64"
runs-on: macos-latest
timeout-minutes: 240
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
# The tag, as Yaak's version is taken, or the dry run's own
PROXY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.proxy_version || github.ref_name }}
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# A cache of its own, so the proxy's builds never change what Yaak's jobs restore
- uses: Swatinem/rust-cache@v2
with:
shared-key: release-proxy
key: ${{ matrix.target }}
cache-on-failure: true
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: vp install
- name: Set version
run: npm run replace-version -- proxy
env:
YAAK_VERSION: ${{ env.PROXY_VERSION }}
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
rm "$RUNNER_TEMP/certificate.p12"
# Lets codesign use the key unattended; without it the first signature can wait on a
# prompt nobody answers
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null
security list-keychain -d user -s "$KEYCHAIN_PATH"
# Builds the helper for the target and signs it and protoc before bundling (the bundle
# config's before-build step), then signs, notarizes and staples the app and makes the
# updater archive and its signature.
- name: Build, sign and notarize
run: >-
npm run proxy:bundle --
--target ${{ matrix.target }}
--bundles dmg
--config crates-tauri/yaak-app-proxy/tauri.release.conf.json
env:
CI: true
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# The helper only answers an app signed by this team
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Verify the bundle
run: >-
node scripts/verify-proxy-bundle.mjs
"target/${{ matrix.target }}/release/bundle/macos/Yaak Proxy.app"
--signed
--version "$PROXY_VERSION"
--arch ${{ matrix.lipo_arch }}
--team "$YAAK_PROXY_TEAM_ID"
env:
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Named Yaak.Proxy_*, so neither people nor tools take them for Yaak's Yaak_* assets.
# The app refuses an update not named so (ARTIFACT_PREFIX in yaak-app-proxy's updates.rs).
- name: Collect the release assets
run: |
set -euo pipefail
bundle="target/${{ matrix.target }}/release/bundle"
version="${PROXY_VERSION#v}"
out="$RUNNER_TEMP/yaak-proxy-release"
mkdir -p "$out"
dmgs=("$bundle"/dmg/*.dmg)
if [ "${#dmgs[@]}" -ne 1 ] || [ ! -f "${dmgs[0]}" ]; then
echo "Expected one .dmg in $bundle/dmg"; exit 1
fi
cp "${dmgs[0]}" "$out/Yaak.Proxy_${version}_${{ matrix.asset_arch }}.dmg"
cp "$bundle/macos/Yaak Proxy.app.tar.gz" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz"
cp "$bundle/macos/Yaak Proxy.app.tar.gz.sig" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz.sig"
(cd "$out" && shasum -a 256 Yaak.Proxy_* > checksums.txt)
ls -l "$out"
- name: Keep the bundles as workflow artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: proxy-${{ env.PROXY_VERSION }}-${{ matrix.asset_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/yaak-proxy-release/
retention-days: ${{ env.DRY_RUN == 'true' && 14 || 7 }}
if-no-files-found: error
# Yaak's jobs create the draft release; this waits for it rather than making its own,
# which could race theirs into two drafts.
- name: Upload to the release
if: env.DRY_RUN != 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
for attempt in $(seq 1 120); do
if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then break; fi
if [ "$attempt" -eq 120 ]; then
echo "No release for $GITHUB_REF_NAME after two hours; Yaak's jobs make it"; exit 1
fi
sleep 60
done
for asset in "$RUNNER_TEMP"/yaak-proxy-release/Yaak.Proxy_*; do
for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$asset" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
done
Generated
+64 -19
View File
@@ -7208,7 +7208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3a6e4decc9842a6638f85d95c25c6ad95e65367a9ad8845b7cae7241a7d9aa1"
dependencies = [
"serde",
"ts-rs",
"ts-rs 11.1.0",
]
[[package]]
@@ -9923,11 +9923,21 @@ name = "ts-rs"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
dependencies = [
"thiserror 2.0.21",
"ts-rs-macros 11.1.0",
]
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"chrono",
"serde_json",
"thiserror 2.0.21",
"ts-rs-macros",
"ts-rs-macros 12.0.1",
]
[[package]]
@@ -9942,6 +9952,18 @@ dependencies = [
"termcolor",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.30.0"
@@ -11479,7 +11501,7 @@ dependencies = [
"tokio-stream",
"tokio-tungstenite",
"tokio-util",
"ts-rs",
"ts-rs 12.0.1",
"url",
"uuid",
"yaak",
@@ -11507,6 +11529,7 @@ dependencies = [
"yaak-tauri-utils",
"yaak-templates",
"yaak-tls",
"yaak-updater",
"yaak-window",
"yaak-ws",
]
@@ -11522,9 +11545,12 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-updater",
"tokio",
"yaak-mac-window",
"yaak-proxy-lib",
"yaak-rpc",
"yaak-updater",
"yaak-window",
]
@@ -11642,7 +11668,7 @@ dependencies = [
"serde_json",
"tempfile",
"thiserror 2.0.21",
"ts-rs",
"ts-rs 12.0.1",
"uuid",
]
@@ -11666,7 +11692,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
"ts-rs",
"ts-rs 12.0.1",
]
[[package]]
@@ -11682,7 +11708,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"tokio",
"ts-rs",
"ts-rs 12.0.1",
"url",
"yaak-common",
"yaak-models",
@@ -11710,7 +11736,7 @@ dependencies = [
"tokio-stream",
"tonic",
"tonic-reflection",
"ts-rs",
"ts-rs 12.0.1",
"uuid",
"yaak-common",
"yaak-tls",
@@ -11761,7 +11787,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
"ts-rs",
"ts-rs 12.0.1",
"yaak-api",
"yaak-common",
"yaak-models",
@@ -11826,7 +11852,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.21",
"ts-rs",
"ts-rs 12.0.1",
"urlencoding",
"yaak-core",
"yaak-database",
@@ -11871,7 +11897,7 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"tokio-tungstenite",
"ts-rs",
"ts-rs 12.0.1",
"yaak-common",
"yaak-crypto",
"yaak-models",
@@ -11918,7 +11944,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"ts-rs",
"ts-rs 12.0.1",
]
[[package]]
@@ -11948,15 +11974,17 @@ dependencies = [
"r2d2_sqlite",
"reqwest",
"rusqlite",
"rustls",
"sea-query",
"serde",
"serde_json",
"system-configuration",
"tempfile",
"tokio",
"tokio-rustls",
"tonic",
"tonic-reflection",
"ts-rs",
"ts-rs 12.0.1",
"url",
"yaak-common",
"yaak-crypto",
@@ -11980,7 +12008,7 @@ dependencies = [
"serde",
"serde-wasm-bindgen",
"serde_json",
"ts-rs",
"ts-rs 12.0.1",
"wasm-bindgen",
]
@@ -11991,7 +12019,7 @@ dependencies = [
"log 0.4.34",
"serde",
"serde_json",
"ts-rs",
"ts-rs 12.0.1",
]
[[package]]
@@ -11999,7 +12027,7 @@ name = "yaak-rpc-schema"
version = "0.0.0"
dependencies = [
"serde",
"ts-rs",
"ts-rs 12.0.1",
"yaak-git",
"yaak-grpc",
"yaak-models",
@@ -12015,7 +12043,7 @@ name = "yaak-sse"
version = "0.1.0"
dependencies = [
"serde",
"ts-rs",
"ts-rs 12.0.1",
]
[[package]]
@@ -12034,7 +12062,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"tokio",
"ts-rs",
"ts-rs 12.0.1",
"yaak-models",
]
@@ -12070,7 +12098,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"tokio",
"ts-rs",
"ts-rs 12.0.1",
"wasm-bindgen",
]
@@ -12091,6 +12119,23 @@ dependencies = [
"yasna 0.6.0",
]
[[package]]
name = "yaak-updater"
version = "0.1.0"
dependencies = [
"log 0.4.34",
"md5",
"nanoid",
"serde",
"serde_json",
"tauri",
"tauri-plugin-dialog",
"tauri-plugin-updater",
"thiserror 2.0.21",
"tokio",
"ts-rs 12.0.1",
]
[[package]]
name = "yaak-wasm"
version = "0.1.0"
@@ -12129,7 +12174,7 @@ dependencies = [
"tokio",
"tower",
"tower-http 0.7.1",
"ts-rs",
"ts-rs 12.0.1",
"url",
"uuid",
"yaak-http",
+3 -1
View File
@@ -44,6 +44,7 @@ members = [
"crates-tauri/yaak-proxy-helper",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-updater",
"crates-tauri/yaak-window",
]
@@ -76,7 +77,7 @@ tauri-plugin-dialog = { version = "2.7.1", default-features = false }
tauri-plugin-shell = "2.3.5"
thiserror = "2.0.17"
tokio = "1.48.0"
ts-rs = "11.1.0"
ts-rs = "12.0.1"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
@@ -116,6 +117,7 @@ yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-updater = { path = "crates-tauri/yaak-updater" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
@@ -9,8 +9,6 @@ import {
isYesterday,
} from "date-fns";
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
@@ -27,12 +25,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
onPinnedResponseId,
}: Props) {
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
const { value: dismissedMovedActions } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", movedActionsBannerId],
fallback: false,
});
const latestResponseId = responses[0]?.id ?? "n/a";
const responseHistoryItems: DropdownItem[] = [];
let lastHistoryGroup: string | null = null;
@@ -123,20 +115,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
hidden: latestResponseId === activeResponse.id,
disabled: responses.length === 0,
},
{
type: "content",
hidden: dismissedMovedActions === true,
label: (
<DismissibleBanner
id={movedActionsBannerId}
color="info"
size="xs"
className="max-w-72"
>
<p>Copy and save actions moved to the Response tab menu.</p>
</DismissibleBanner>
),
},
{
type: "separator",
label: "Recent",
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
@@ -14,12 +14,7 @@ import {
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link";
import {
SettingCommand,
SettingSnippet,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
import { DeprecatedPluginBanner } from "./SettingsPlugins";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
@@ -56,13 +51,10 @@ export function SettingsAgents() {
const cli = cliSetup(client);
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak.
</p>
</div>
<SettingsPage
title="Agents"
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
>
<SettingsList>
<SettingsSection title={null}>
<div className="pb-4 border-b border-border-subtle">
@@ -134,6 +126,6 @@ export function SettingsAgents() {
/>
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import type { ClientCertificate } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm";
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && (
<SettingsPage
title="Client Certificates"
description="Add and manage TLS certificates on a per domain basis"
actions={
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
}
banner={
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
}
>
{certificates.length > 0 ? (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
/>
))}
</VStack>
)}
</VStack>
) : null}
</SettingsPage>
);
}
@@ -1,8 +1,8 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import {
Heading,
HStack,
Icon,
SettingsPage,
Table,
TableBody,
TableCell,
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
}
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
<SettingsPage
title="Keyboard Shortcuts"
description="Click the menu button to add, remove, or reset keyboard shortcuts."
>
<VStack space={3}>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
<TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
if (arraysEqual(keys, defaultHotkeys[action])) {
// Remove from settings if it matches default (use default)
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
} else {
// Store the keys (including empty array to disable)
newHotkeys[action] = keys;
}
await patchModel(settings, { hotkeys: newHotkeys });
}}
onReset={async () => {
const newHotkeys = { ...settings.hotkeys };
delete newHotkeys[action];
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
await patchModel(settings, { hotkeys: newHotkeys });
}}
/>
))}
</TableBody>
</Table>
</VStack>
</SettingsPage>
);
}
@@ -1,6 +1,6 @@
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import {
@@ -26,15 +26,11 @@ export function SettingsProxy() {
};
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsPage
title="Proxy"
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
>
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
@@ -160,7 +156,7 @@ export function SettingsProxy() {
</>
)}
</SettingsList>
</VStack>
</SettingsPage>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import deepEqual from "@gilbarbara/deep-equal";
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
import type { UpdateInfo } from "@yaakapp-internal/tauri-updater";
import type { Atom } from "jotai";
import { atom } from "jotai";
import { selectAtom } from "jotai/utils";
+35 -123
View File
@@ -6,16 +6,19 @@ import type {
ShowToastRequest,
} from "@yaakapp-internal/plugins";
import { updateAllPlugins } from "@yaakapp-internal/plugins";
import type {
PluginUpdateNotification,
UpdateInfo,
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { PluginUpdateNotification, YaakNotification } from "@yaakapp-internal/tauri-client";
import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater";
import {
ButtonInfiniteLoading,
HStack,
Icon,
showUpdateAvailableToast as showUpdateAvailableToastIn,
showUpdateInstalledToast as showUpdateInstalledToastIn,
showUpdateStateToast as showUpdateStateToastIn,
VStack,
} from "@yaakapp-internal/ui";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
// Listen for toasts
import { platform } from "@yaakapp-internal/platform";
@@ -136,6 +139,11 @@ export function initGlobalListeners() {
void showUpdateAvailableToast(payload);
});
// A check asked for while an update is already in hand says where it stands
platform.listen<UpdateState>("update_state", (state) => {
showUpdateStateToastIn(jotaiStore, { appName: "Yaak", state });
});
platform.listen<YaakNotification>("notification", (payload) => {
console.log("Got notification event", payload);
showNotificationToast(payload);
@@ -149,131 +157,35 @@ export function initGlobalListeners() {
}
function showUpdateInstalledToast(version: string) {
const UPDATE_TOAST_ID = "update-info";
showToast({
id: UPDATE_TOAST_ID,
color: "primary",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">Start using the new version now?</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-20"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => rpc("cmd_restart", {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
showUpdateInstalledToastIn(jotaiStore, {
appName: "Yaak",
version,
onRelaunch: () => rpc("cmd_restart", {}),
});
}
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded, install } = updateInfo;
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
const whatsNewButton = (
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
);
if (install !== "integrated") {
// Nothing to reply to here; the backend only told us so we can say how to update
const flatpak = install === "flatpak";
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{flatpak ? (
<>
Update with <InlineCode>flatpak update</InlineCode> or your software center.
</>
) : (
"Download the new version to upgrade."
)}
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
{!flatpak && (
<Button
size="xs"
color="info"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl("https://yaak.app/download");
}}
>
Download
</Button>
)}
{whatsNewButton}
</HStack>
),
});
return;
// Nothing to reply to otherwise; the backend only told us so we can say how to update
if (install === "integrated") {
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
}
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? "Do you want to install" : "Download and install"} the update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-40"
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
onClick={async () => {
await platform.emit(replyEventId, {
type: "action",
action: "install",
} satisfies UpdateResponse);
}}
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
{whatsNewButton}
</HStack>
),
showUpdateAvailableToastIn(jotaiStore, {
appName: "Yaak",
update: updateInfo,
whatsNewUrl: `https://yaak.app/changelog/${version}`,
downloadUrl: "https://yaak.app/download",
onOpenUrl: (url) => platform.openUrl(url),
onInstall: () =>
platform.emit(replyEventId, {
type: "action",
action: "install",
} satisfies UpdateResponse),
});
}
+7 -2
View File
@@ -42,8 +42,13 @@ export function AppIcon({ path, className = "size-4" }: { path: string; classNam
}, [path]);
return url == null ? (
<span aria-hidden className={classNames(className, "inline-block shrink-0")} />
<span aria-hidden className={classNames(className, "inline-block shrink-0 aspect-square")} />
) : (
<img alt="" src={url} className={classNames(className, "shrink-0")} />
// Square whatever squeezes it, beside text that truncates
<img
alt=""
src={url}
className={classNames(className, "shrink-0 aspect-square object-contain")}
/>
);
}
+181 -34
View File
@@ -1,56 +1,205 @@
import { Prec } from "@codemirror/state";
import { type EditorView, keymap } from "@codemirror/view";
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui";
import {
Button,
CONTROL_TOOLTIP_DELAY,
Icon,
IconButton,
Input,
SettingRow,
SettingRowBoolean,
Tooltip,
} from "@yaakapp-internal/ui";
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
import classNames from "classnames";
import { useMemo, useRef, useState } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt";
import {
decryptFilterOptions,
decryptsEverything,
entryLabel,
entrySubject,
EVERYTHING,
everythingEdit,
normalizeEntry,
} from "../lib/decrypt";
import { revealPath } from "../lib/opener";
import { useShowToast } from "../lib/toast";
/**
* What HTTPS is decrypted: one row per entry, the user's in order and then the built-in
* exclusions, each removable, and an input that adds one, suggesting `host:`, `app:` and `!`,
* then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings' Proxy
* tab.
* Settings' Decryption section: the certificate, Decrypt everything as a switch over `*`, then
* the person's own hosts and apps in order, each removable, an input that adds one (suggesting
* `host:`, `app:` and `!`, then the hosts and apps seen), and the built-in exclusions folded
* into one row. Every change goes through Decrypt, so the first that decrypts something trusts
* the certificate first.
*/
export function DecryptListEditor({ className }: { className?: string }) {
export function DecryptionSettings() {
const list = useDecryptList();
const save = useEditDecryptList();
const { apply } = useDecrypt();
const [busy, setBusy] = useState(false);
// Read when the input opens its suggestions, so new traffic shows without rebuilding it.
const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" });
const seenRef = useRef<SeenTargets | undefined>(seen.data);
seenRef.current = seen.data;
const everything = decryptsEverything(list);
const own = list.filter((e) => !e.builtIn && e.entry !== EVERYTHING);
const builtIns = list.filter((e) => e.builtIn);
// Each change replaces the whole list, so one waits for the last to land rather than undo it.
const remove = (entry: string) => {
if (save.isPending) return;
save.mutate({ add: [], remove: [entry] });
// One change at a time, each applied to the list as it is when it lands.
const change = async (
edit: { add: string[]; remove: string[] },
message: string,
sub?: string,
) => {
if (busy) return;
setBusy(true);
try {
await apply({ edit, message, sub });
} finally {
setBusy(false);
}
};
const remove = (entry: string) =>
void change(
{ add: [], remove: [entry] },
entry.startsWith("!")
? `Stopped excluding ${entrySubject(entry.slice(1))}`
: `Stopped decrypting ${entrySubject(entry)}`,
);
return (
<div className={classNames(className, "grid gap-2")}>
<EntryInput
seenRef={seenRef}
list={list}
disabled={save.isPending}
onAdd={(entry) => save.mutate({ add: [entry], remove: [] })}
<>
<CertificateRow />
<SettingRowBoolean
title="Decrypt everything"
description="Except built-in exclusions"
checked={everything}
disabled={busy}
onChange={(on) =>
void change(
everythingEdit(on),
on ? "Decrypting everything" : "Stopped decrypting everything",
on ? "Except Apple services and apps that pin" : undefined,
)
}
/>
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
{list.length === 0 && (
<div className="px-3 py-2 text-sm text-text-subtle">Nothing decrypted</div>
<div className="py-4 grid gap-2">
<div className="flex items-center text-text">
Hosts and apps
<span className="ml-auto text-sm text-text-subtlest">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">Nothing listed</div>
)}
{own.map((e) => (
<EntryRow key={e.entry} entry={e} disabled={busy} onRemove={() => remove(e.entry)} />
))}
{builtIns.length > 0 && <BuiltIns entries={builtIns} disabled={busy} onRemove={remove} />}
</div>
</div>
</>
);
}
/** The certificate: trusted, with Reveal for devices and tools with their own stores, or Trust */
function CertificateRow() {
const caStatus = useRpcQuery("ca_status", {});
const trust = useTrustCertificate();
const trusting = useTrusting();
const showToast = useShowToast();
const status = caStatus.data;
if (status == null)
return (
<SettingRow title="Certificate" description="Yaak Proxy CA">
{null}
</SettingRow>
);
return (
<SettingRow title="Certificate" description="Yaak Proxy CA">
<div className="flex items-center gap-2">
{status.trusted ? (
<span className="flex items-center gap-1 text-sm text-success">
<Icon icon="badge_check" size="sm" />
Trusted
</span>
) : (
<span className="text-sm text-text-subtle">Not trusted</span>
)}
{list.map((e) => (
{!status.trusted && canTrustHere() ? (
<Button size="2xs" color="primary" isLoading={trusting} onClick={() => trust.mutate({})}>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
) : (
<Button
size="2xs"
variant="border"
onClick={() =>
void revealPath(status.certPath, (message) =>
showToast({ id: "reveal-ca", color: "danger", message }),
)
}
>
Reveal
</Button>
)}
</div>
</SettingRow>
);
}
/** The built-in exclusions, folded into one row that opens to each, with its kind */
function BuiltIns({
entries,
disabled,
onRemove,
}: {
entries: DecryptListEntry[];
disabled: boolean;
onRemove: (entry: string) => void;
}) {
const [open, setOpen] = useState(false);
const kinds = new Set(entries.map((e) => e.kind));
const summary = [kinds.has("apple") ? "Apple" : null, kinds.has("pinned") ? "pinned apps" : null]
.filter((k) => k != null)
.join(", ");
return (
<>
<button
type="button"
className="w-full flex items-center gap-2 h-xs pl-3 pr-2 text-sm text-text-subtle hover:bg-surface-highlight/50"
onClick={() => setOpen(!open)}
>
<Icon icon={open ? "chevron_down" : "chevron_right"} size="sm" className="shrink-0" />
{entries.length} built-in exclusions
<span className="ml-auto text-xs text-text-subtlest">{summary}</span>
</button>
{open &&
entries.map((e) => (
<EntryRow
key={e.entry}
entry={e}
disabled={save.isPending}
onRemove={() => remove(e.entry)}
disabled={disabled}
onRemove={() => onRemove(e.entry)}
/>
))}
</div>
</div>
</>
);
}
@@ -81,12 +230,10 @@ function EntryRow({
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
{entryLabel(entry.entry)}
</span>
{entry.builtIn && (
<Tooltip wrap content="Ships with Yaak Proxy" delay={CONTROL_TOOLTIP_DELAY}>
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
built-in
</span>
</Tooltip>
{entry.builtIn && entry.kind != null && (
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
{entry.kind === "apple" ? "Apple" : "Pinned"}
</span>
)}
<IconButton
size="xs"
@@ -160,7 +307,7 @@ function EntryInput({
size="sm"
stateKey={null}
language={null}
placeholder="Add host, app or *"
placeholder="Add host or app"
extensions={extensions}
onChange={() => setError(null)}
/>
@@ -0,0 +1,315 @@
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} />
{model.whySub != null && <span className="text-text-subtle"> · {model.whySub}</span>}
<Tooltip wrap content={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.fact != null && (
<div className="text-sm text-text-subtle -mt-1 mb-1">
<LineText line={model.fact} />
</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;
}
+19 -110
View File
@@ -1,18 +1,8 @@
import {
Button,
CONTROL_TOOLTIP_DELAY,
Icon,
InlineCode,
LoadingIcon,
Tooltip,
} from "@yaakapp-internal/ui";
import { Button, Icon, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { ReactNode } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { everythingEdit } from "../lib/decrypt";
import { decryptStep } from "../lib/firstRun";
import { encryptingPercent } from "../lib/encrypting";
import { proxyStateAtom } from "../lib/store";
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
@@ -20,49 +10,35 @@ import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./Proxy
type StepState = "todo" | "next" | "wait" | "done" | "fail";
/**
* The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
* choose what to decrypt, then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
* environment beside it serves someone who wants one terminal proxied and nothing system-wide.
* The list's empty state until traffic arrives: turn on the system proxy, then see traffic. The
* certificate is trusted at the first Decrypt, not here, as nothing needs it before. Not
* dismissable and nothing remembered; the first flow replaces it. Copy environment beside it
* serves someone who wants one terminal proxied and nothing system-wide.
*/
export function FirstRun() {
const proxyState = useAtomValue(proxyStateAtom);
const setup = useProxySetup();
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const { systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
const copyEnvironment = useCopyEnvironment();
const decryptList = useDecryptList();
const saveDecryptList = useEditDecryptList();
const running = proxyState?.state === "running";
// Changed only by the process that owns the engine, so not from here while the CLI does.
const otherOwner = proxyState?.otherOwner != null;
const recording = proxyState?.recording !== false;
const trusted = caStatus.data?.trusted === true;
const systemSupported = systemProxy.data?.supported !== false;
const systemOn = systemProxy.data?.enabled === true;
const trustState: StepState = trusted
? "done"
: trust.isPending
? "wait"
: trust.error != null
? "fail"
: "next";
const systemState: StepState = setSystemProxy.isPending
? "wait"
: systemOn
? "done"
: systemSwitch.failed
? "fail"
: trusted
? "next"
: "todo";
const decrypt = decryptStep(decryptList, trusted);
const everything = decrypt.choice === "everything";
const setEverything = (on: boolean) => saveDecryptList.mutate(everythingEdit(on));
const ready = trusted && (systemOn || !systemSupported);
: "next";
const ready = systemOn || !systemSupported;
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
const trafficState: StepState =
encrypting != null ? "wait" : !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
@@ -72,39 +48,14 @@ export function FirstRun() {
<div className="w-full max-w-[40rem]">
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
<div className="text-text-subtle text-sm leading-relaxed mb-4">
{systemSupported ? "Four" : "Three"} steps, once. Then every app on this Mac sends its
traffic here.
{systemSupported
? "Two steps, once. Then every app on this Mac sends its traffic here."
: "Traffic appears here as apps send it."}
</div>
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
<Step
n={1}
state={trustState}
title={trusted ? "Certificate trusted" : "Trust the certificate"}
description={
trusted
? "Yaak Proxy CA, in your login keychain."
: trustState === "wait"
? "Waiting for your password in the macOS prompt."
: trustState === "fail"
? (trust.error?.message ?? "The certificate was not trusted.")
: "So HTTPS reads as plain text. macOS asks for your login password once."
}
action={
trustState === "next" || trustState === "fail" ? (
<Button
size="2xs"
variant={trustState === "fail" ? "border" : "solid"}
color={trustState === "fail" ? "default" : "primary"}
onClick={() => trust.mutate({})}
>
{trustState === "fail" ? "Try Again" : "Trust Certificate"}
</Button>
) : null
}
/>
{systemSupported && (
<Step
n={2}
n={1}
state={systemState}
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
description={
@@ -129,49 +80,7 @@ export function FirstRun() {
/>
)}
<Step
n={systemSupported ? 3 : 2}
state={decrypt.state}
title="What to decrypt"
description={
everything ? (
<Tooltip
wrap
content="Except the built-in exclusions"
delay={CONTROL_TOOLTIP_DELAY}
>
<span>Everything</span>
</Tooltip>
) : (
<Tooltip wrap content="From a flow or the sidebar" delay={CONTROL_TOOLTIP_DELAY}>
<span>Pick as they appear</span>
</Tooltip>
)
}
action={
everything ? (
<Button
size="2xs"
variant="border"
isLoading={saveDecryptList.isPending}
onClick={() => setEverything(false)}
>
Pick As They Appear
</Button>
) : (
<Button
size="2xs"
variant="border"
color={decrypt.state === "next" ? "primary" : "default"}
isLoading={saveDecryptList.isPending}
onClick={() => setEverything(true)}
>
Everything
</Button>
)
}
/>
<Step
n={systemSupported ? 4 : 3}
n={systemSupported ? 2 : 1}
state={trafficState}
title={
encrypting != null
@@ -184,12 +93,12 @@ export function FirstRun() {
encrypting != null
? "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 has not started.")
: !recording
? "Recording is paused, so traffic passes through without being stored."
: trafficState === "wait"
? "Open a website or send a request, and its flow appears here."
: "Flows appear here as apps send requests."
? (retry.error?.message ?? proxyState?.startError ?? "The proxy 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" ? (
+30 -16
View File
@@ -15,6 +15,7 @@ import {
parseCookieHeaders,
ResponseCookies,
SplitLayout,
type TabItem,
Tooltip,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
@@ -22,7 +23,7 @@ import { useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useMemo } from "react";
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
import { placeTabsAtom } from "../lib/atoms";
import { findHeader } from "../lib/body";
import { bodyTypeBadge, findHeader } from "../lib/body";
import {
browserTabMemory,
defaultTabFor,
@@ -32,13 +33,12 @@ import {
REQUEST_TAB_BODY,
REQUEST_TAB_COOKIES,
REQUEST_TAB_HEADERS,
REQUEST_TAB_INFO,
REQUEST_TAB_PARAMS,
RESPONSE_TAB_BODY,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_INFO,
RESPONSE_TAB_MESSAGES,
RESPONSE_TAB_TIMELINE,
} from "../lib/defaultTab";
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
import {
@@ -54,7 +54,6 @@ import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
import { FlowInfo } from "./FlowInfo";
import { FlowRequestBody } from "./FlowRequestBody";
import { FlowMessages } from "./FlowMessages";
import { FlowTimeline } from "./FlowTimeline";
import { ProxyEditorHost } from "./ProxyEditorHost";
/**
@@ -150,7 +149,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
],
[cookies, sentCount],
);
const leadingTabs = useMemo<ExtraPaneTab[]>(
() => [{ tab: { value: REQUEST_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> }],
[flow],
);
const contentType = findHeader(flow.requestHeaders, "content-type");
const badge = bodyTypeBadge(contentType, flow.requestSize, flow.graphqlOperation != null);
const bodyTab = useMemo(() => bodyTabItem(REQUEST_TAB_BODY, badge), [badge]);
const available = [
REQUEST_TAB_INFO,
REQUEST_TAB_BODY,
REQUEST_TAB_PARAMS,
REQUEST_TAB_HEADERS,
@@ -169,8 +176,11 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
request={request}
fullHeight
forceUpdateKey={forceUpdateKey}
tabsStorageKey="proxy_request_tabs"
// Info joined as the first tab: an order dragged before it would put it last
tabsStorageKey="proxy_request_tabs.2"
bodyLoader={loader}
bodyTab={bodyTab}
leadingTabs={leadingTabs}
extraTabs={extraTabs}
defaultTab={defaultTab}
activeTab={activeTab}
@@ -208,6 +218,15 @@ function FlowRequestHalf({ flow }: { flow: Flow }) {
);
}
/** A half's body tab: Body, with the body's type beside it as the Headers count is, if any */
function bodyTabItem(value: string, badge: string | null): TabItem {
return {
value,
label: "Body",
rightSlot: badge == null ? null : <CountBadge count={badge} />,
};
}
/** The method and URL, at the status line's height so the two header lines align */
function UrlLine({ flow }: { flow: Flow }) {
return (
@@ -244,14 +263,11 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
const flowActions = useFlowActions();
const messagesLabel = messagesTabLabel(flow);
const badge = bodyTypeBadge(flow.contentType, flow.responseSize);
const bodyTab = useMemo(() => bodyTabItem(RESPONSE_TAB_BODY, badge), [badge]);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: { value: RESPONSE_TAB_TIMELINE, label: "Timeline" },
content: <FlowTimeline flow={flow} />,
},
{ tab: { value: RESPONSE_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> },
...(messagesLabel == null
() =>
messagesLabel == null
? []
: [
{
@@ -269,16 +285,13 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
},
content: <FlowMessages flow={flow} />,
},
]),
],
],
[flow, messagesLabel],
);
const available = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_TIMELINE,
RESPONSE_TAB_INFO,
...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]),
];
const { activeTab, defaultTab, onTabChange } = useShapeTab("response", flow, available);
@@ -299,6 +312,7 @@ function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }
activeTab={activeTab}
onTabChange={onTabChange}
extraTabs={extraTabs}
bodyTab={bodyTab}
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
statusLineCenter={
flow.responseTruncated ? (
+4 -3
View File
@@ -2,9 +2,10 @@ import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { selectedFlowAtom } from "../lib/atoms";
import { isRejection } from "../lib/encryptedCard";
import { EncryptedCard } from "./EncryptedCard";
import { EmptyState } from "./FlowBodyViewer";
import { FlowCard } from "./FlowCard";
import { NotDecrypted } from "./NotDecrypted";
/**
* The detail pane: the selected flow's card, or what the pane holds before one is selected. The
@@ -35,8 +36,8 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
<Keys keys={["⌘", "⇧", "K"]} />
</div>
</EmptyState>
) : flow.tunnelled ? (
<NotDecrypted flow={flow} />
) : flow.tunnelled || isRejection(flow) ? (
<EncryptedCard key={flow.id} flow={flow} />
) : (
<FlowCard flow={flow} listBeside={sideBySide} />
)}
+58 -3
View File
@@ -1,16 +1,26 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { DetailsBanner, formatMillis, KeyValueRow, KeyValueRows } from "@yaakapp-internal/ui";
import {
CONTROL_TOOLTIP_DELAY,
DetailsBanner,
formatMillis,
Icon,
type IconProps,
KeyValueRow,
KeyValueRows,
Tooltip,
} from "@yaakapp-internal/ui";
import { format } from "date-fns";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { Fragment, type HTMLAttributes, type ReactElement, type ReactNode, useMemo } from "react";
import { utcTimestamp } from "../lib/flowViews";
import { flowPhases, type PhaseKind } from "../lib/phases";
import { folderOf } from "../lib/search";
import { AppIcon } from "./AppIcon";
/**
* Where a flow came from and how it travelled: in a proxy you didn't send the request, so the
* connection on each leg, the app and its process, timing, sizes and the flow's record are part
* of the answer.
* of the answer. Then the timeline: each phase of the exchange and how long it took.
*/
export function FlowInfo({ flow }: { flow: Flow }) {
const folder = folderOf(flow);
@@ -72,10 +82,55 @@ export function FlowInfo({ flow }: { flow: Flow }) {
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>>;
+173 -14
View File
@@ -55,6 +55,7 @@ import { compareFlows } from "../lib/flowWindow";
import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow";
import { applyFilter, goToPlace } from "../lib/navigation";
import { asOneTerm } from "../lib/query";
import { connectionOf } from "../lib/encryptedCard";
import { folderOf } from "../lib/search";
import {
columnPrefsAtom,
@@ -853,6 +854,9 @@ const FlowRow = memo(function FlowRow({
// A failure's reason, or Cancelled, takes the type and size cells together, side by side.
const typeAt = ids.indexOf("type");
const spanReason = noResponse && typeAt !== -1 && ids[typeAt + 1] === "size";
// An encrypted connection has no path, so its host takes the room when Path is beside it.
const hostAt = ids.indexOf("host");
const spanHost = flow.tunnelled && hostAt !== -1 && ids[hostAt + 1] === "path";
const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt;
return (
@@ -885,10 +889,16 @@ const FlowRow = memo(function FlowRow({
<FlowMark flow={flow} />
{columns.map((c) => {
if (spanReason && c.id === "size") return null;
if (spanHost && c.id === "path") return null;
return (
// Names the cell's column for its menu, without a box of its own in the row's grid
<div key={c.id} data-column={c.id} className="contents">
<FlowCell column={c.id} flow={flow} span={spanReason && c.id === "type"} />
<FlowCell
column={c.id}
flow={flow}
selected={selected}
span={(spanReason && c.id === "type") || (spanHost && c.id === "host")}
/>
</div>
);
})}
@@ -896,27 +906,46 @@ const FlowRow = memo(function FlowRow({
);
});
/** Why an encrypted row is, for its mark's tooltip */
function encryptedReason(flow: Flow): string {
switch (flow.tunnelReason) {
case "excluded":
return flow.tunnelExcludedBuiltIn || flow.tunnelExcludedBy == null
? "Encrypted: excluded by default"
: `Encrypted: excluded by ${flow.tunnelExcludedBy}`;
case "rejected":
return "Encrypted: passed through after a client rejected the certificate";
default:
return "Encrypted: not on the decrypt list";
}
}
/**
* One mark per row, by a fixed priority: failed, cancelled, not decrypted, streaming, cut, saved.
* Its tooltip lists every state the flow has. Agent reads join it once the engine records them.
* One mark per row, by a fixed priority: rejected, failed, cancelled, encrypted, streaming, cut,
* saved. Its tooltip lists every state the flow has. Agent reads join it once the engine records
* them.
*/
function FlowMark({ flow }: { flow: Flow }) {
const open = isOpenStream(flow);
const rejected = flow.tlsRejected != null;
const states: string[] = [];
if (flow.error != null) states.push(`Failed: ${flow.error}`);
if (rejected) states.push("Rejected the proxy's certificate");
else if (flow.error != null) states.push(`Failed: ${flow.error}`);
if (flow.cancelled) states.push("Cancelled by the app");
if (flow.tunnelled) states.push("Not decrypted");
if (flow.tunnelled) states.push(encryptedReason(flow));
if (open) states.push("Streaming");
if (flow.responseTruncated) states.push("Body cut at the capture limit");
if (flow.savedRequestId != null) states.push("Saved to Yaak");
const mark = (
<span className="flex items-center justify-center">
{flow.error != null ? (
{rejected ? (
<Icon icon="shield_off" size="sm" color="warning" />
) : flow.error != null ? (
<Icon icon="circle_alert" size="sm" color="danger" />
) : flow.cancelled ? (
<Icon icon="circle_off" size="sm" className="text-text-subtlest" />
) : flow.tunnelled ? (
<Icon icon="lock" size="sm" className="text-text-subtlest" />
<Icon icon="lock" size="sm" className="text-text-subtle" />
) : open ? (
<span aria-hidden className="w-[7px] h-[7px] rounded-full bg-info ring-3 ring-info/20" />
) : flow.responseTruncated ? (
@@ -958,7 +987,141 @@ function isOpenStream(flow: Flow): boolean {
return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled;
}
function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) {
interface CellProps {
column: ColumnId;
flow: Flow;
/** Takes the next cell's room too */
span: boolean;
selected: boolean;
}
/**
* The text of an encrypted or refused row: told apart by colour alone, the faintest that stays
* readable on the row (subtle, 4.5:1 on the surface in both default themes), and the full text
* colour while selected, as subtle falls under that on the dark theme's selection. No opacity,
* so the app icon and the mark show as on any row.
*/
function quietText(selected: boolean): string {
return selected ? "text-text" : "text-text-subtle";
}
/** A row's number, in the quiet colour */
function quietNumber(flow: Flow, selected: boolean): ReactElement {
return <span className={classNames(cell, mono, quietText(selected))}>{flow.number || ""}</span>;
}
/**
* An encrypted connection's cell: quiet, and only what is known, so its host, how much it
* carried and for how long; nothing read from inside it, so no method, path, status, type,
* query or field. Null for the cells that show as on any row, the app icon among them.
*/
function encryptedCell({ column, flow, span, selected }: CellProps): ReactElement | null {
const quiet = classNames(cell, mono, quietText(selected));
switch (column) {
case "app":
case "started":
return null;
case "number":
return quietNumber(flow, selected);
case "host": {
const { label } = connectionOf(flow);
return (
<Truncated text={label}>
<span
className={classNames(cell, "rtl text-left", span && "col-span-2", quietText(selected))}
>
<bdi>{label}</bdi>
</span>
</Truncated>
);
}
case "type":
// A tunnel that failed to reach the server keeps the failure look, across Type and Size
if (flow.error == null) return <span className={cell} />;
return (
<Tooltip wrap content={flow.error} delay={CONTROL_TOOLTIP_DELAY}>
<span className={classNames(cell, "text-danger text-xs", span && "col-span-2")}>
{flow.error}
</span>
</Tooltip>
);
case "size":
return (
<span className={classNames(quiet, "text-right")}>
{isOpenTunnel(flow) ? "" : formatSize(flow.responseSize)}
</span>
);
case "requestSize":
return (
<span className={classNames(quiet, "text-right")}>
{isOpenTunnel(flow) ? "" : formatSize(flow.requestSize)}
</span>
);
case "time": {
const ms = isOpenTunnel(flow)
? Date.now() - Date.parse(utcTimestamp(flow.startedAt))
: flow.elapsedMs;
return (
<span className={classNames(quiet, "text-right")}>
{ms == null || !Number.isFinite(ms) ? "" : formatMillis(Math.max(0, ms))}
</span>
);
}
default:
return <span className={cell} />;
}
}
/**
* A refused handshake's cell: its host, number and time in the quiet colour, the refusal across
* Type and Size in warning, and nothing for the request it never made. Null for the cells that
* show as on any row, the app icon among them.
*/
function rejectedCell({ column, flow, span, selected }: CellProps): ReactElement | null {
switch (column) {
case "method":
case "path":
case "status":
case "query":
case "size":
return <span className={cell} />;
case "number":
return quietNumber(flow, selected);
case "host": {
const host = hostOf(flow.url);
return (
<Truncated text={host}>
<span className={classNames(cell, "rtl text-left", quietText(selected))}>
<bdi>{host}</bdi>
</span>
</Truncated>
);
}
case "time":
return (
<span className={classNames(cell, mono, "text-right", quietText(selected))}>
{flow.elapsedMs == null ? "" : formatMillis(flow.elapsedMs)}
</span>
);
case "type":
return (
<span className={classNames(cell, "text-warning text-xs", span && "col-span-2")}>
Rejected certificate
</span>
);
default:
return null;
}
}
function FlowCell(props: CellProps) {
const { column, flow, span } = props;
const special = flow.tunnelled
? encryptedCell(props)
: flow.tlsRejected != null
? rejectedCell(props)
: null;
if (special != null) return special;
const failed = flow.error != null;
const noResponse = (failed || flow.cancelled) && flow.status == null;
switch (column) {
@@ -1005,10 +1168,6 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
);
}
case "path": {
// A tunnel's only path is its host; what it carried is unread.
if (flow.tunnelled) {
return <span className={classNames(cell, "text-text-subtlest")}>Not decrypted</span>;
}
const path = pathOf(flow.url);
if (flow.streamKind == null) {
return (
@@ -1034,7 +1193,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
);
}
case "status":
return (flow.cancelled || flow.tunnelled) && flow.status == null ? (
return flow.cancelled && flow.status == null ? (
<span className={classNames(cell, mono, "text-text-subtlest")}>--</span>
) : (
<HttpStatusTagRaw status={flow.status ?? 0} short className={classNames(cell, mono)} />
@@ -1104,7 +1263,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
</span>
);
case "query": {
const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? "");
const query = urlParts(flow.url).query ?? "";
return (
<Truncated text={query}>
<span className={classNames(cell, "text-text-subtle")}>{query}</span>
+10 -3
View File
@@ -6,7 +6,13 @@ import { appliedFilterAtom } from "../lib/atoms";
import { applyFilter } from "../lib/navigation";
import { rpc } from "../lib/rpc";
import { activeQuery, widerQueries } from "../lib/query";
import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store";
import {
hideEncryptedAtom,
hideSystemAtom,
pausedAtAtom,
proxyStateAtom,
searchWindowAtom,
} from "../lib/store";
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
export function FlowListEmpty() {
@@ -64,6 +70,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
*/
function NoResults({ text }: { text: string }) {
const hideSystem = useAtomValue(hideSystemAtom);
const hideEncrypted = useAtomValue(hideEncryptedAtom);
const store = useStore();
const setText = (wider: string) => applyFilter(store, wider);
const [wider, setWider] = useState<{ text: string; count: number }[]>([]);
@@ -73,7 +80,7 @@ function NoResults({ text }: { text: string }) {
const candidates = widerQueries(text);
void Promise.all(
candidates.map(async (candidate) => {
const query = activeQuery(candidate, hideSystem);
const query = activeQuery(candidate, hideSystem, hideEncrypted);
const res = await rpc("list_flows", { ...query, limit: 0 });
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
}),
@@ -87,7 +94,7 @@ function NoResults({ text }: { text: string }) {
return () => {
current = false;
};
}, [text, hideSystem]);
}, [text, hideSystem, hideEncrypted]);
return (
<EmptyStateText
+10 -1
View File
@@ -46,6 +46,7 @@ import {
resetWidth,
resetWidths,
resizeColumn,
resizeDistance,
toggleColumn,
unavailableColumns,
} from "../lib/columns";
@@ -264,7 +265,10 @@ export const FlowListHeader = memo(function FlowListHeader({
onResizeStart={() => {
startWidth.current = c.current;
}}
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
onResizeMove={(dx) => {
const by = resizeDistance(c, dx, rootFontSize());
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + by));
}}
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
/>
))}
@@ -279,6 +283,11 @@ export const FlowListHeader = memo(function FlowListHeader({
);
});
/** The interface font size now, which numeric columns' widths grow with */
function rootFontSize(): number {
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
}
interface HeaderCellProps {
column: Column;
sort: FlowSortKey[];
@@ -1,71 +0,0 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
import {
CONTROL_TOOLTIP_DELAY,
EventViewer,
EventViewerRow,
formatMillis,
Icon,
type IconProps,
Tooltip,
} from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases";
const ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
start: { icon: "send_horizontal", color: "secondary" },
step: { icon: "arrow_right", color: "secondary" },
end: { icon: "check", color: "success" },
error: { icon: "alert_triangle", color: "danger" },
cancelled: { icon: "circle_off", color: "secondary" },
};
/** Yaak's event list with the flow's phases as rows, each saying how long it took */
export function FlowTimeline({ flow }: { flow: Flow }) {
const phases = useMemo(() => flowPhases(flow), [flow]);
return (
<EventViewer<FlowPhase>
events={phases}
getEventKey={(p) => p.key}
splitLayoutStorageKey="proxy_flow_timeline"
enableKeyboardNav={false}
emptyMessage="No timings recorded"
renderRow={({ event, isActive, onClick }) => {
const { icon, color } = ICONS[event.kind];
return (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon icon={icon} color={color} size="sm" />}
content={
event.detail == null ? (
<span>{event.label}</span>
) : (
<Tooltip
wrap
content={event.detail}
delay={CONTROL_TOOLTIP_DELAY}
>
<span>
{event.label}
<span className="text-text-subtlest">{` ${event.detail}`}</span>
</span>
</Tooltip>
)
}
columns="auto minmax(0,1fr) 6rem 6rem"
cells={{
trailing: [
<span key="took" className="tabular-nums">
{event.kind === "start" ? "" : `+${formatMillis(event.tookMs)}`}
</span>,
<span key="at" className="tabular-nums opacity-50">
{formatMillis(event.atMs)}
</span>,
],
}}
/>
);
}}
/>
);
}
+174
View File
@@ -0,0 +1,174 @@
import {
Button,
CONTROL_TOOLTIP_DELAY,
Dropdown,
type DropdownItem,
Icon,
IconButton,
Tooltip,
} from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useLocalStorage } from "react-use";
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { everythingEdit, listEdit, setDecrypted } from "../lib/decrypt";
import { chooseOptions, connectionCount, stripState } from "../lib/httpsStrip";
import { sidebarFlowsAtom } from "../lib/store";
import { AppIcon } from "./AppIcon";
import { SETTINGS_TAB_PROXY, useOpenSettings } from "./Settings";
const STRIP =
"flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0";
/**
* Above the list while nothing is decrypted: says once that HTTPS is encrypted, and offers
* Decrypt Everything, or Choose… for apps and sites one at a time. Goes once anything is on
* the list, or when dismissed; comes back, with Trust, while the list decrypts something the
* certificate isn't trusted for.
*/
export function HttpsStrip() {
const list = useDecryptList();
const flows = useAtomValue(sidebarFlowsAtom);
const caStatus = useRpcQuery("ca_status", {});
const [dismissed, setDismissed] = useLocalStorage("https_strip_dismissed", false);
// Kept while Choose… is open, so checking a row there leaves the menu where it is.
const [choosing, setChoosing] = useState(false);
const { apply } = useDecrypt();
const trusting = useTrusting();
const trust = useTrustCertificate();
const openSettings = useOpenSettings();
const encrypted = useMemo(() => flows.filter((f) => f.tunnelled).length, [flows]);
const state = stripState({
list,
encrypted,
dismissed: dismissed ?? false,
trusted: caStatus.data?.trusted ?? null,
canTrust: canTrustHere(),
});
const options = useMemo(() => chooseOptions(flows, list), [flows, list]);
if (state === "hidden" && !choosing) return null;
if (state === "broken") {
return (
<div className={STRIP}>
<Icon icon="shield_off" size="sm" color="warning" className="shrink-0" />
<span className="text-text shrink-0">Certificate not trusted</span>
<span className="truncate">Required to decrypt</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: "Sites", 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: "Edit List in 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, except Apple services and apps that pin"
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: "Except Apple services and apps that pin",
})
}
>
{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="Not now"
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,97 +0,0 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { Button, CONTROL_TOOLTIP_DELAY, formatMillis, Icon, Tooltip } from "@yaakapp-internal/ui";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt";
import { sidebarPath } from "../lib/search";
import { EmptyState } from "./FlowBodyViewer";
/**
* The detail of HTTPS passed through undecrypted: its sizes and time, and buttons that add its
* host or its app to the decrypt list, from the next connection on.
*/
export function NotDecrypted({ flow }: { flow: Flow }) {
const list = useDecryptList();
const save = useEditDecryptList();
const host = sidebarPath(flow.url).split("/")[0] ?? flow.url;
const hostSubject: DecryptSubject = { kind: "host", host };
const appSubject: DecryptSubject | null =
flow.clientName == null
? null
: { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId };
const app = flow.clientName;
const actions = detailActions(
list,
host,
app == null ? null : { name: app, bundleId: flow.clientBundleId },
);
const open = flow.elapsedMs == null && flow.error == null;
const decrypt = (subject: DecryptSubject) =>
save.mutate(listEdit(list, setDecrypted(list, [subject], true)));
const facts = [
`${formatSize(Number(flow.requestSize))} sent`,
`${formatSize(Number(flow.responseSize))} received`,
open ? "open" : flow.elapsedMs == null ? null : formatMillis(flow.elapsedMs),
].filter((f) => f != null);
return (
<EmptyState>
<div className="grid justify-items-center gap-2">
<div className="flex items-center gap-1.5 text-text-subtle">
<Icon icon="lock" size="sm" />
Not decrypted
</div>
<div className="font-mono text-xs">{facts.join(" · ")}</div>
{flow.error != null && <div className="text-danger">{flow.error}</div>}
{actions.listed ? (
<Tooltip
wrap
content="Still tunnelled? The app may pin its certificate"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>Decrypting new connections</div>
</Tooltip>
) : actions.blocked != null ? (
<Tooltip
wrap
content="Remove it from the decrypt list to decrypt"
delay={CONTROL_TOOLTIP_DELAY}
>
<div>
Excluded by <span className="font-mono text-xs">{actions.blocked}</span>
</div>
</Tooltip>
) : (
<div className="flex gap-2 mt-1">
{actions.host && (
<Button
size="xs"
variant="border"
title={`Decrypt new connections to ${host}`}
tooltip
disabled={save.isPending}
onClick={() => decrypt(hostSubject)}
>
Decrypt Host
</Button>
)}
{actions.app && appSubject != null && (
<Button
size="xs"
variant="border"
title={`Decrypt new connections from ${app}`}
tooltip
disabled={save.isPending}
onClick={() => decrypt(appSubject)}
>
Decrypt App
</Button>
)}
</div>
)}
</div>
</EmptyState>
);
}
+3 -2
View File
@@ -31,6 +31,7 @@ import { SavedSearchDialog } from "./SavedSearchDialog";
import { SearchBar } from "./SearchBar";
import { SettingsDialogFallback, useOpenSettings } from "./Settings";
import { Sidebar } from "./Sidebar";
import { HttpsStrip } from "./HttpsStrip";
import { TakeOverBanner } from "./TakeOverBanner";
export function ProxyLayout() {
@@ -166,6 +167,7 @@ export function ProxyLayout() {
>
<div className="h-full flex flex-col min-h-0">
<TakeOverBanner />
<HttpsStrip />
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
<FlowsOrFirstRun flowLayout={flowLayout} />
</div>
@@ -190,10 +192,9 @@ export function ProxyLayout() {
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
const locked = useAtomValue(lockedAtom);
const noFlows = useAtomValue(noFlowsAtom) || locked != null;
const { caStatus, systemProxy } = useProxySetup();
const { systemProxy } = useProxySetup();
const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom);
const incomplete = setupIncomplete({
trusted: caStatus.data?.trusted ?? null,
systemProxySupported: systemProxy.data?.supported ?? null,
systemProxyEverOn,
});
+88 -51
View File
@@ -1,5 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
import {
Button,
Checkbox,
@@ -13,10 +13,13 @@ import classNames from "classnames";
import { useAtom, useAtomValue } from "jotai";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { useEffect, useRef, useState } from "react";
import { canTrustHere, useDecrypt, useTrustCertificate } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcEvent } from "../hooks/useRpcEvent";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperRegisteredAtAtom } from "../lib/atoms";
import { decryptsAnything, decryptsEverything, everythingEdit, listSummary } from "../lib/decrypt";
import { encryptingPercent } from "../lib/encrypting";
import { environmentExports } from "../lib/env";
import { proxyStatusOf } from "../lib/proxyStatus";
@@ -27,9 +30,10 @@ import { useShowToast } from "../lib/toast";
/**
* The header's state pill and its menu: the listening address, which copies itself, the state,
* Pause or Resume Recording, Override system proxy, and the certificate. The engine runs for as
* long as the app does, so the pill shows whether flows are being stored. A certificate that is not trusted steps out of the
* menu beside the pill until it is. Everything set once and left alone lives in Settings.
* Pause or Resume Recording, Override system proxy, and Decrypt everything. The engine runs for
* as long as the app does, so the pill shows whether flows are being stored. A certificate that
* is not trusted while something is listed to decrypt steps out of the menu beside the pill
* until it is. Everything set once and left alone lives in Settings.
*/
export function ProxyStatus() {
const dropdownRef = useRef<DropdownRef>(null);
@@ -38,14 +42,21 @@ export function ProxyStatus() {
const setup = useProxySetup();
const execute = useRpcMutation("execute_action");
const retry = useRpcMutation("take_over");
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
const { caStatus, systemProxy, setSystemProxy } = setup;
const systemSwitch = useSystemProxySwitch(setup);
const running = proxyState?.state === "running";
const recording = proxyState?.recording !== false;
const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null;
const untrusted = caStatus.data != null && !caStatus.data.trusted;
const decryptList = useDecryptList();
// Something is listed to decrypt but the certificate isn't trusted, so it can't be.
// Only where the app can trust it itself: elsewhere the status always reads untrusted.
const broken =
canTrustHere() &&
caStatus.data != null &&
!caStatus.data.trusted &&
decryptsAnything(decryptList);
// The same status the header's tint takes its colour from
const status = proxyStatusOf(proxyState);
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
@@ -108,7 +119,7 @@ export function ProxyStatus() {
const address = port == null ? null : `127.0.0.1:${port}`;
const items: DropdownItem[] = [
{
label: <span className="font-mono">{address}</span>,
label: <span className="font-mono text-editor">{address}</span>,
hidden: address == null,
keepOpenOnSelect: true,
rightSlot: copied ? (
@@ -166,44 +177,7 @@ export function ProxyStatus() {
/>
),
},
{
type: "content",
label: caStatus.data?.trusted ? (
<MenuRow
icon={<Icon icon="shield_check" color="success" size="sm" />}
title="Certificate trusted"
description="Yaak Proxy CA, in your login keychain."
/>
) : (
<MenuRow
icon={<Icon icon="shield_off" color="warning" size="sm" />}
title="Certificate not trusted"
description={
trust.error != null ? (
<span className="text-danger">{trust.error.message}</span>
) : trust.isPending ? (
"Waiting for your password in the macOS prompt."
) : (
"So HTTPS reads as plain text. macOS asks for your login password once."
)
}
action={
<Button
size="2xs"
variant="border"
color="primary"
isLoading={trust.isPending}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trust.error != null ? "Try again" : "Trust certificate"}
</Button>
}
/>
),
},
{ type: "content", label: <DecryptEverythingRow broken={broken} /> },
];
return (
@@ -235,7 +209,7 @@ export function ProxyStatus() {
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
</Pill>
</Dropdown>
{untrusted && (
{broken && (
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
<Icon icon="shield_off" size="xs" />
Certificate not trusted
@@ -245,6 +219,73 @@ export function ProxyStatus() {
);
}
/**
* Decrypt everything, `*` on the list, as a switch: what else is listed when off, and Trust
* while something is listed and the certificate isn't trusted.
*/
function DecryptEverythingRow({ broken }: { broken: boolean }) {
const list = useDecryptList();
const { apply, trusting } = useDecrypt();
const trust = useTrustCertificate();
const on = decryptsEverything(list);
if (broken) {
return (
<MenuRow
icon={<Icon icon="shield_off" size="sm" color="warning" />}
title="Decrypt everything"
description={<span className="text-warning">Certificate not trusted</span>}
action={
<Button
size="2xs"
color="primary"
isLoading={trusting}
onClick={(e) => {
e.stopPropagation();
trust.mutate({});
}}
>
{trusting ? "Waiting for macOS" : "Trust"}
</Button>
}
/>
);
}
return (
<MenuRow
icon={<Icon icon="lock_open" size="sm" color="secondary" />}
title="Decrypt everything"
description={
trusting
? "Waiting for macOS"
: on
? "Except Apple services and apps that pin"
: (listSummary(list) ?? "Nothing listed")
}
action={
<KeepMenuOpen>
<Checkbox
title="Decrypt everything"
hideLabel
checked={on}
disabled={trusting}
onChange={(checked) =>
void apply(
checked
? {
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Except Apple services and apps that pin",
}
: { edit: everythingEdit(false), message: "Stopped decrypting everything" },
)
}
/>
</KeepMenuOpen>
}
/>
);
}
/**
* The certificate and the system proxy, read once and shared by the header and the first-run
* card, with the actions that change them.
@@ -265,10 +306,6 @@ export function useProxySetup() {
// The user comes back from System Settings, maybe having just allowed the helper.
const { refetch: refetchSystemProxy } = systemProxy;
useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]);
const trust = useRpcMutation("ca_trust", {
mutationKey: ["ca_trust"],
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
});
const setSystemProxy = useRpcMutation("system_proxy_set", {
mutationKey: ["system_proxy_set"],
onSuccess: (status) => {
@@ -284,7 +321,7 @@ export function useProxySetup() {
useEffect(() => {
if (on && !everOn) setEverOn(true);
}, [on, everOn, setEverOn]);
return { caStatus, trust, systemProxy, setSystemProxy };
return { caStatus, systemProxy, setSystemProxy };
}
/** The system proxy switch's state, from where it points and the change in flight */
+13 -5
View File
@@ -16,13 +16,14 @@ import {
type SettingsTitlebar,
Toasts,
} from "@yaakapp-internal/ui";
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { settingsValuesAtom } from "../lib/interfaceSettings";
import { openLink, revealPath } from "../lib/opener";
import { proxySettingsView } from "../lib/settings";
import { closeWindow, command, currentWindowLabel, getOsType, subscribe } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { checkForUpdatesNow, updateCheckingAtom, updaterEnabled } from "../lib/updates";
import {
openSettingsVia,
requestedSettingsTab,
@@ -125,9 +126,16 @@ export function SettingsContent({
const [values, change] = useAtom(settingsValuesAtom);
const app = useAppInfo();
const systemAppearance = useSystemAppearance();
const store = useStore();
const checking = useAtomValue(updateCheckingAtom);
const { data: hasUpdater = false } = useQuery({
queryKey: ["updater_enabled"],
queryFn: updaterEnabled,
retry: false,
});
const view = useMemo(
() => proxySettingsView(values, app, systemAppearance),
[values, app, systemAppearance],
() => proxySettingsView(values, app, systemAppearance, hasUpdater ? { checking } : null),
[values, app, systemAppearance, hasUpdater, checking],
);
const showToast = useShowToast();
const callbacks = useMemo<SettingsCallbacks>(() => {
@@ -136,14 +144,14 @@ export function SettingsContent({
onChange: (patch) => change(patch),
// The proxy draws its own title bar always; the row isn't shown
onApplyNativeTitlebar: (useNativeTitlebar) => change({ useNativeTitlebar }),
onCheckForUpdates: () => undefined,
onCheckForUpdates: () => void checkForUpdatesNow(store),
onActivateLicense: async () => undefined,
onDeactivateLicense: () => undefined,
onOpenUrl: (url) => void openLink(url, fail),
onRevealPath: (path) => void revealPath(path, fail),
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
};
}, [change, showToast]);
}, [change, showToast, store]);
const props = { view, callbacks };
return (
+5 -10
View File
@@ -5,7 +5,6 @@ import {
Button,
Dialog,
DialogFooter,
Heading,
Icon,
RadioDropdown,
SettingRow,
@@ -15,8 +14,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 +40,7 @@ import { helperRow } from "../lib/systemProxy";
import { proxyStateAtom, refreshProxyState } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { DecryptListEditor } from "./DecryptListEditor";
import { DecryptionSettings } from "./DecryptListEditor";
/**
* The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what
@@ -59,11 +58,7 @@ export function SettingsProxy() {
const elsewhere = proxyState?.otherOwner != null;
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">Listening, decryption, traffic and terminals</p>
</div>
<SettingsPage title="Proxy" description="Listening, decryption, traffic and terminals">
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<PortRow disabled={elsewhere} />
@@ -94,7 +89,7 @@ export function SettingsProxy() {
</SettingsSection>
<SettingsSection title="Decryption">
<DecryptListEditor className="py-4" />
<DecryptionSettings />
</SettingsSection>
<SettingsSection title="Traffic">
@@ -108,7 +103,7 @@ export function SettingsProxy() {
<TerminalRow />
</SettingsSection>
</SettingsList>
</VStack>
</SettingsPage>
);
}
+61 -23
View File
@@ -10,21 +10,30 @@ import {
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react";
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
import { useDecrypt } from "../hooks/useDecrypt";
import { useDecryptList } from "../hooks/useDecryptList";
import { useRpcMutation } from "../hooks/useRpcMutation";
import { appliedFilterAtom, SIDEBAR_TREE_ID } from "../lib/atoms";
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
import {
blockerOf,
decryptMenuAction,
decryptsEverything,
everythingEdit,
listEdit,
setDecrypted,
} from "../lib/decrypt";
import { revealLabel, revealPath } from "../lib/opener";
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
import {
copyRowsText,
ENCRYPTED_ID,
filterWithRows,
type SidebarItem,
SYSTEM_ID,
valueRows,
} from "../lib/sidebarTree";
import { applyFilter, noteSidebarInput, noteSidebarMenu } from "../lib/navigation";
import { hideSystemAtom, sidebarTreeAtom } from "../lib/store";
import { hideEncryptedAtom, hideSystemAtom, sidebarTreeAtom } from "../lib/store";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { AppIcon } from "./AppIcon";
@@ -73,10 +82,12 @@ function ItemInner({ item }: { item: SidebarItem }) {
return (
<div className="flex items-center gap-2 w-full min-w-0">
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "encrypted" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
{item.kind === "saved" && <Icon icon="bookmark" size="sm" className="text-text-subtle" />}
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
{item.appPath != null && <AppIcon path={item.appPath} className="size-[1.125rem]" />}
{item.appPath != null && (
<AppIcon path={item.appPath} className="size-[1.125rem] min-w-[1.125rem]" />
)}
{item.kind === "folder" && item.path != null ? (
// Named by its last part; the whole path on hover
<Tooltip wrap content={item.path} delay={CONTROL_TOOLTIP_DELAY}>
@@ -88,7 +99,11 @@ function ItemInner({ item }: { item: SidebarItem }) {
{item.sublabel != null && (
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
)}
{item.decrypt != null && <DecryptedMark item={item} />}
{item.encryptedOnly && (
<Tooltip wrap content="Encrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
)}
<Count item={item} />
</div>
);
@@ -117,14 +132,12 @@ function CloseAtOnce({ onClose }: { onClose: () => void }) {
return null;
}
/** A small open lock on a host, site or app the decrypt list decrypts */
function DecryptedMark({ item }: { item: SidebarItem }) {
const list = useDecryptList();
if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null;
return (
<Tooltip wrap content="Decrypted" delay={CONTROL_TOOLTIP_DELAY}>
<Icon icon="lock_open" size="xs" className="shrink-0 text-text-subtlest" />
</Tooltip>
/** A checkable menu item's mark: a check when on, the same room when off */
function checkMark(on: boolean) {
return on ? (
<Icon icon="check" size="sm" />
) : (
<span aria-hidden className="inline-block w-4 h-4" />
);
}
@@ -137,9 +150,9 @@ export function Sidebar() {
[],
);
const [hideSystem, setHideSystem] = useAtom(hideSystemAtom);
const [hideEncrypted, setHideEncrypted] = useAtom(hideEncryptedAtom);
const decryptList = useDecryptList();
const setDecryptList = useEditDecryptList();
const { mutate: saveDecryptList } = setDecryptList;
const { apply: applyDecrypt } = useDecrypt();
const showToast = useShowToast();
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", {
@@ -210,15 +223,35 @@ export function Sidebar() {
return [
{
label: "Show in All Traffic",
leftSlot: hideSystem ? (
<span aria-hidden className="inline-block w-4 h-4" />
) : (
<Icon icon="check" size="sm" />
),
leftSlot: checkMark(!hideSystem),
onSelect: () => setHideSystem(!hideSystem),
},
];
}
if (items.length === 1 && items[0]?.id === ENCRYPTED_ID) {
return withSeparators([
[
{
label: "Decrypt Everything",
leftSlot: <Icon icon="lock_open" />,
hidden: decryptsEverything(decryptList),
onSelect: () =>
void applyDecrypt({
edit: everythingEdit(true),
message: "Decrypting everything",
sub: "Except Apple services and apps that pin",
}),
},
],
[
{
label: "Show in All Traffic",
leftSlot: checkMark(!hideEncrypted),
onSelect: () => setHideEncrypted(!hideEncrypted),
},
],
]);
}
const paths = items.length === 1 ? pathItems(items[0]) : [];
const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }]));
if (saved.length > 0) {
@@ -265,7 +298,10 @@ export function Sidebar() {
// Only what this changes, applied to the list as it is then, so a second change
// made before this one lands keeps it.
const next = setDecrypted(decryptList, subjects, action.on);
saveDecryptList(listEdit(decryptList, next));
void applyDecrypt({
edit: listEdit(decryptList, next),
message: `${action.on ? "Decrypting" : "Stopped decrypting"} ${action.subject}`,
});
},
},
];
@@ -273,7 +309,7 @@ export function Sidebar() {
},
[
decryptList,
saveDecryptList,
applyDecrypt,
showToast,
setSavedDialog,
deleteSaved,
@@ -281,6 +317,8 @@ export function Sidebar() {
filterItems,
hideSystem,
setHideSystem,
hideEncrypted,
setHideEncrypted,
],
);
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
+19 -3
View File
@@ -11,8 +11,9 @@ import { type FlowFixture, fixtures } from "./fixtures";
/**
* Dev only: captured flows in the detail card, over fixtures. Open
* `/harness.html?flow=0&theme=dark`; `&list` puts the flow list beside the card (its halves
* stacked), `&below` puts it above (side by side), and `&tab=info` opens the response half on a
* tab, as though it were chosen by hand for flows of that shape.
* stacked), `&below` puts it above (side by side), `&tab=info` opens the response half on a
* tab, as though it were chosen by hand for flows of that shape, and `&numbers` gives the flows
* the widest sizes and times.
*/
export function FlowPanesHarness() {
const params = new URLSearchParams(location.search);
@@ -40,13 +41,28 @@ export function FlowPanesHarness() {
);
}
const NUMBERS = [
{ responseSize: 999_949, requestSize: 1000, elapsedMs: 3_599_000, elapsedHeadersMs: 999 },
{ responseSize: 124_700, requestSize: 999_949, elapsedMs: 999, elapsedHeadersMs: 3_599_000 },
{ responseSize: 20_200, requestSize: 812, elapsedMs: 9_949, elapsedHeadersMs: 59_499 },
{ responseSize: 999_949_000, requestSize: 41_800, elapsedMs: 59_999_000, elapsedHeadersMs: 600 },
{ responseSize: 1000, requestSize: 7_600_000, elapsedMs: 234, elapsedHeadersMs: 5_900 },
{ responseSize: 9_000, requestSize: 999_949_000_000, elapsedMs: 400_000, elapsedHeadersMs: 12 },
];
function FixtureCard({ fixture }: { fixture: FlowFixture }) {
const params = new URLSearchParams(location.search);
const store = useStore();
const setWindow = useSetAtom(flowWindowAtom);
const [ready, setReady] = useState(false);
useLayoutEffect(() => {
const flows = fixtures.map((f, i) => ({ ...f.flow, number: 1401 + i }));
// `&numbers`: sizes and times of every unit, the widest of each among them
const numbers = params.has("numbers");
const flows = fixtures.map((f, i) => ({
...f.flow,
number: 1401 + i,
...(numbers && f.flow.elapsedMs != null && NUMBERS[i % NUMBERS.length]),
}));
const tab = params.get("tab");
if (tab != null) {
browserTabMemory()?.setItem(learnedTabKey("response", flowShape(fixture.flow)), tab);
+107 -14
View File
@@ -1,4 +1,5 @@
import type {
DecryptListEntry,
Flow,
FlowSortKey,
GetProxyStateResponse,
@@ -6,16 +7,59 @@ import type {
} from "@yaakapp-internal/proxy-lib";
import { sortFlows } from "../lib/columns";
import { appIconPng, fixtures } from "./fixtures";
import { BUILT_IN_EXCLUSIONS, httpsFlows } from "./httpsFixtures";
const params = new URLSearchParams(location.search);
/**
* The decrypt list and the certificate as the harness holds them, from `?list=` (`none`,
* `host`, `everything`, `apps`) and `?ca=untrusted`. `?trust=cancel` cancels the password
* prompt; `?still=n` is how many open connections each change leaves encrypted.
*/
const https = {
list: initialList(params.get("list")),
trusted: params.get("ca") !== "untrusted",
passthrough: params.get("scene") === "mixed" ? ["sync.acme.dev:443"] : ([] as string[]),
};
function initialList(which: string | null): DecryptListEntry[] {
const own = (...entries: string[]) => entries.map((entry) => ({ entry, builtIn: false }));
switch (which) {
case "none":
return BUILT_IN_EXCLUSIONS;
case "host":
return [...own("host:www.google.com"), ...BUILT_IN_EXCLUSIONS];
case "everything":
return [...own("*"), ...BUILT_IN_EXCLUSIONS];
case "apps":
return [...own("app:Dia", "app:Google Chrome", "app:Acme Sync"), ...BUILT_IN_EXCLUSIONS];
case "excluded":
return [...own("*", "!host:*.slack.com"), ...BUILT_IN_EXCLUSIONS];
default:
return [
{ entry: "host:api.stripe.com", builtIn: false },
{ entry: "app:Google Chrome", builtIn: false },
{ entry: "!host:*.apple.com", builtIn: true },
];
}
}
/**
* Dev only: answers the engine's RPCs the harness pages need, from fixtures, so the card and
* settings render outside the app as they do inside it.
*/
export function installFixtureBackend() {
// `?firstrun`: setup not done yet, so the first-run card shows
if (params.has("firstrun")) localStorage.setItem("system_proxy_ever_on", "false");
else if (params.has("window")) localStorage.setItem("system_proxy_ever_on", "true");
const internals = (window as unknown as { __TAURI_INTERNALS__: Record<string, unknown> })
.__TAURI_INTERNALS__;
// For checking the window as traffic arrives: `__harnessEmit("model_write", payload)`
(window as unknown as { __harnessEmit: typeof emit }).__harnessEmit = emit;
// Unlistening, which the app does as its components unmount
(
window as unknown as { __TAURI_EVENT_PLUGIN_INTERNALS__: unknown }
).__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener: () => {} };
internals.transformCallback = (callback: (event: unknown) => void) => {
const id = callbacks.size + 1;
callbacks.set(id, callback);
@@ -138,11 +182,16 @@ function matches(flow: Flow, query: string): boolean {
if (field === "host") return host.toLowerCase().includes(value.replaceAll("*", ""));
if (field === "method") return flow.method.toLowerCase() === value;
if (field === "status") return String(flow.status ?? "").startsWith(value.replace(/x+$/, ""));
if (field === "is" && value === "encrypted") return flow.tunnelled;
if (field === "is" && value === "rejected") return flow.tlsRejected != null;
return field == null ? text.includes(term) : true;
});
}
function allFlows(): Flow[] {
if (params.has("firstrun")) return [];
const scene = httpsFlows(params.get("scene"));
if (scene != null) return scene.map((f, i) => ({ ...f, number: i + 1 }));
const many = Number(new URLSearchParams(location.search).get("many") ?? 0);
return (many > 0 ? manyFlows(many) : fixtures.map((f) => f.flow)).map((f, i) => ({
...f,
@@ -150,7 +199,15 @@ function allFlows(): Flow[] {
}));
}
function answer(cmd: string, payload: unknown): unknown {
function caStatus() {
return {
trusted: https.trusted,
installed: https.trusted,
certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem",
};
}
async function answer(cmd: string, payload: unknown): Promise<unknown> {
const req = (payload ?? {}) as Record<string, unknown>;
switch (cmd) {
case "get_flows": {
@@ -231,14 +288,55 @@ function answer(cmd: string, payload: unknown): unknown {
case "get_storage_size":
return { bytes: 412 * 1024 * 1024 };
case "ca_status":
return caStatus();
case "ca_trust": {
// A moment for the prompt, so the controls that asked show it
await new Promise((r) => setTimeout(r, Number(params.get("prompt") ?? 900)));
if (params.get("trust") === "cancel") return { ...caStatus(), cancelled: true };
https.trusted = true;
return { ...caStatus(), cancelled: false };
}
case "edit_decrypt_list": {
const { add = [], remove = [] } = req as { add?: string[]; remove?: string[] };
const own = https.list.filter((e) => !e.builtIn && !remove.includes(e.entry));
for (const entry of add) {
if (!own.some((e) => e.entry === entry)) own.push({ entry, builtIn: false });
}
const builtIns = BUILT_IN_EXCLUSIONS.filter(
(e) => https.list.some((l) => l.entry === e.entry) && !remove.includes(e.entry),
);
https.list = [...own, ...builtIns];
emit("decrypt_list_changed", { list: { entries: https.list } });
const decrypts = add.some((e) => !e.startsWith("!")) || remove.some((e) => e.startsWith("!"));
return {
trusted: true,
installed: true,
certPath: "/Users/you/Library/Application Support/app.yaak.proxy/ca.pem",
entries: https.list,
stillEncrypted: decrypts ? Number(params.get("still") ?? 3) : 0,
};
}
case "list_passthrough":
return { addresses: https.passthrough };
case "forget_passthrough": {
const address = String(req.address);
const forgotten = https.passthrough.includes(address);
https.passthrough = https.passthrough.filter((a) => a !== address);
emit("passthrough_changed", { addresses: https.passthrough });
return { forgotten, addresses: https.passthrough };
}
case "reconnect_tunnels":
return { closed: 1 };
case "list_reconnectable_tunnels":
return Array.from({ length: Number(params.get("still") ?? 3) }, (_, i) => ({
id: `harness_${i}`,
host: "www.google.com",
port: 443,
appName: "Dia",
appBundleId: null,
flowId: null,
reason: "not_listed",
}));
case "system_proxy_status":
return {
enabled: true,
enabled: !params.has("firstrun"),
port: 9090,
services: [],
backend: "helper",
@@ -246,13 +344,7 @@ function answer(cmd: string, payload: unknown): unknown {
supported: true,
};
case "get_decrypt_list":
return {
entries: [
{ entry: "host:api.stripe.com", builtIn: false },
{ entry: "app:Google Chrome", builtIn: false },
{ entry: "!host:*.apple.com", builtIn: true },
],
};
return { entries: https.list };
case "list_actions":
// As the engine lists them on macOS
return {
@@ -270,7 +362,8 @@ function answer(cmd: string, payload: unknown): unknown {
case "list_flows": {
const all = allFlows();
const query = typeof req.query === "string" ? req.query : "";
const matching = all.filter((f) => matches(f, query));
const hideEncrypted = req.hideEncrypted === true && !query.includes("@is:encrypted");
const matching = all.filter((f) => matches(f, query) && !(hideEncrypted && f.tunnelled));
// In a sort's order, as the database lists it, all in one page
const sort = (req.sort ?? []) as FlowSortKey[];
const flows = sort.length > 0 ? sortFlows(matching, sort) : matching.reverse();
@@ -278,7 +371,7 @@ function answer(cmd: string, payload: unknown): unknown {
return {
flows: page,
total: all.length,
matching: query === "" ? null : matching.length,
matching: query === "" && !hideEncrypted ? null : matching.length,
cursor: null,
};
}
+7
View File
@@ -52,6 +52,12 @@ const base: Flow = {
grpcMessage: null,
graphqlOperation: null,
tunnelled: false,
tunnelReason: null,
tunnelExcludedBy: null,
tunnelExcludedBuiltIn: false,
tunnelRejectedBy: null,
reconnectRequested: false,
tlsRejected: null,
agentRead: false,
clientAddr: "127.0.0.1:54321",
elapsedConnectMs: 18,
@@ -209,6 +215,7 @@ export const fixtures: FlowFixture[] = [
requestSize: 3_412,
responseSize: 48_210,
tunnelled: true,
tunnelReason: "not_listed",
},
requestBody: body(""),
responseBody: body(""),
+179
View File
@@ -0,0 +1,179 @@
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
import { fixtures } from "./fixtures";
/**
* Dev only: traffic for HTTPS decryption's states, by `?scene=`: `encrypted`, the first minute
* with nothing decrypted, and `mixed`, some decrypted, a refused handshake and the tunnel passed
* through after it.
*/
export type HttpsScene = "encrypted" | "mixed";
export const BUILT_IN_EXCLUSIONS: DecryptListEntry[] = [
{ entry: "!host:*.apple.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.icloud.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.mzstatic.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.1password.com", builtIn: true, kind: "pinned" },
{ entry: "!host:*.dropbox.com", builtIn: true, kind: "pinned" },
];
const template = fixtures[0]?.flow as Flow;
const start = Date.parse("2026-11-09T09:00:00Z");
const apps = {
dia: {
clientName: "Dia",
clientBundleId: "company.thebrowser.dia",
clientPath: "/Applications/Dia.app",
},
chrome: {
clientName: "Google Chrome",
clientBundleId: "com.google.Chrome",
clientPath: "/Applications/Google Chrome.app",
},
slack: {
clientName: "Slack",
clientBundleId: "com.tinyspeck.slackmacgap",
clientPath: "/Applications/Slack.app",
},
onePassword: {
clientName: "1Password",
clientBundleId: "com.1password.1password",
clientPath: "/Applications/1Password.app",
},
headless: {
clientName: "chrome-headless-shell",
clientBundleId: null,
clientPath:
"/Users/you/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell",
},
acme: {
clientName: "Acme Sync",
clientBundleId: "dev.acme.sync",
clientPath: "/Applications/Acme Sync.app",
},
cloudd: {
clientName: "cloudd",
clientBundleId: null,
clientPath: "/System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd",
},
} as const;
type App = (typeof apps)[keyof typeof apps];
function tunnel(i: number, host: string, app: App, over: Partial<Flow> = {}): Flow {
return {
...template,
...app,
id: `fl_https_${i}`,
method: "CONNECT",
url: `${host}:443`,
startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""),
status: null,
contentType: null,
requestHeaders: [],
responseHeaders: [],
elapsedHeadersMs: null,
elapsedConnectMs: null,
elapsedTlsMs: null,
elapsedRequestMs: null,
elapsedMs: 120 + ((i * 97) % 900),
clientHttpVersion: "HTTP/1.1",
upstreamHttpVersion: null,
upstreamTlsVersion: null,
upstreamTlsCipher: null,
requestSize: 600 + ((i * 331) % 3000),
responseSize: 1_200 + ((i * 7919) % 120_000),
tunnelled: true,
tunnelReason: "not_listed",
...over,
// An open tunnel has been open a little while, counting up from now
...(over.elapsedMs === null
? { startedAt: new Date(Date.now() - 22_000 - i * 1000).toISOString().replace("Z", "") }
: {}),
};
}
function decrypted(i: number, source: number, url: string, app: App): Flow {
const flow = fixtures[source]?.flow ?? template;
return {
...flow,
...app,
// The fixture's id first, so the backend finds its bodies
id: `${flow.id}~https_${i}`,
url,
startedAt: new Date(start + i * 1100).toISOString().replace("Z", ""),
};
}
const builtInExcluded = {
tunnelReason: "excluded" as const,
tunnelExcludedBy: "!host:*.1password.com",
tunnelExcludedBuiltIn: true,
};
/** The first minute: every HTTPS connection encrypted, two still open */
function encryptedScene(): Flow[] {
return [
tunnel(1, "clientstream.launchdarkly.com", apps.dia, { elapsedMs: null }),
tunnel(2, "browser-intake-datadoghq.com", apps.dia),
tunnel(3, "api.typefully.com", apps.dia),
tunnel(4, "js.stripe.com", apps.dia),
tunnel(5, "edgeapi.slack.com", apps.slack),
tunnel(6, "wss-primary.slack.com", apps.slack, { elapsedMs: null }),
tunnel(7, "gateway.icloud.com", apps.cloudd, {
tunnelReason: "excluded",
tunnelExcludedBy: "!host:*.icloud.com",
tunnelExcludedBuiltIn: true,
}),
tunnel(8, "my.1password.com", apps.onePassword, builtInExcluded),
tunnel(9, "www.google.com", apps.dia),
tunnel(10, "fonts.gstatic.com", apps.dia),
tunnel(11, "clients2.google.com", apps.chrome),
tunnel(12, "home.taila714c.ts.net:8317", apps.dia, { url: "home.taila714c.ts.net:8317" }),
tunnel(13, "optimizationguide-pa.googleapis.com", apps.headless),
];
}
/**
* Some decrypted, a refused handshake from an app that pins, and the tunnel passed through
* after it
*/
function mixedScene(): Flow[] {
return [
tunnel(1, "my.1password.com", apps.onePassword, builtInExcluded),
decrypted(2, 0, "https://api.stripe.com/v1/payment_intents", apps.dia),
decrypted(3, 5, "https://api.github.com/graphql", apps.chrome),
tunnel(4, "edgeapi.slack.com", apps.slack),
decrypted(5, 2, "https://cdn.example.com/avatars/42.png?size=2", apps.dia),
tunnel(6, "gateway.icloud.com", apps.cloudd, {
tunnelReason: "excluded",
tunnelExcludedBy: "!host:*.icloud.com",
tunnelExcludedBuiltIn: true,
}),
{
...tunnel(7, "sync.acme.dev", apps.acme),
method: "",
url: "https://sync.acme.dev",
tunnelled: false,
tunnelReason: null,
tlsRejected: "bad_certificate",
error: "Rejected the proxy's certificate",
elapsedMs: 6,
requestSize: 517,
responseSize: 0,
},
tunnel(8, "sync.acme.dev", apps.acme, {
tunnelReason: "rejected",
tunnelRejectedBy: "fl_https_7",
elapsedMs: null,
}),
decrypted(9, 0, "https://api.typefully.com/v1/drafts", apps.dia),
tunnel(10, "www.google.com", apps.dia, { elapsedMs: null }),
];
}
export function httpsFlows(scene: string | null): Flow[] | null {
if (scene === "encrypted") return encryptedScene();
if (scene === "mixed") return mixedScene();
return null;
}
@@ -51,6 +51,12 @@ const flowBase: Flow = {
grpcMessage: null,
graphqlOperation: null,
tunnelled: false,
tunnelReason: null,
tunnelExcludedBy: null,
tunnelExcludedBuiltIn: false,
tunnelRejectedBy: null,
reconnectRequested: false,
tlsRejected: null,
agentRead: false,
clientAddr: null,
elapsedConnectMs: null,
+202
View File
@@ -0,0 +1,202 @@
import { useIsMutating, useQueryClient } from "@tanstack/react-query";
import type { CaStatus, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
import { Button } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { inverseEdit } from "../lib/decrypt";
import {
applyChoice,
choiceToast,
type DecryptChoice,
type ListEdit,
reconnectEntries,
} from "../lib/decryptChoice";
import { rpc } from "../lib/rpc";
import { getOsType } from "../lib/tauri";
import { useShowToast } from "../lib/toast";
import { announceEngineChange } from "../lib/windowSync";
import { useRpcEvent } from "./useRpcEvent";
import { useRpcMutation } from "./useRpcMutation";
import { useRpcQuery } from "./useRpcQuery";
const CA_KEY = ["ca_status", {}];
const LIST_KEY = ["get_decrypt_list", {}];
const PASSTHROUGH_KEY = ["list_passthrough", {}];
const TRUST_KEY = ["ca_trust"];
/** The app trusts its certificate itself only on macOS; elsewhere it is installed by hand */
export function canTrustHere(): boolean {
return getOsType() === "macos";
}
/** Whether macOS's password prompt is up, from any control that asked for it */
export function useTrusting(): boolean {
return useIsMutating({ mutationKey: TRUST_KEY }) > 0;
}
/** Asks macOS to trust the certificate, the status read from its answer */
export function useTrustCertificate() {
const queryClient = useQueryClient();
const showToast = useShowToast();
return useRpcMutation("ca_trust", {
mutationKey: TRUST_KEY,
onSuccess: ({ trusted, installed, certPath }) =>
queryClient.setQueryData<CaStatus>(CA_KEY, { trusted, installed, certPath }),
// A cancelled prompt isn't an error, and says nothing; a failure says why.
onError: (err) =>
showToast({
id: "decrypt",
color: "danger",
message: `Certificate not trusted: ${err.message}`,
}),
});
}
/**
* Every Decrypt and Stop Decrypting in the app: trusts the certificate first when the change can
* decrypt something and it isn't yet (macOS's password prompt), then writes the change, then a
* toast with Undo, and with Reconnect while open connections the list now decrypts stay
* encrypted. A cancelled prompt changes nothing and says nothing.
*/
export function useDecrypt() {
const queryClient = useQueryClient();
const showToast = useShowToast();
const trusting = useTrusting();
const { mutateAsync: trust } = useTrustCertificate();
const run = useCallback(
async (edit: ListEdit) => {
try {
return await applyChoice(
{
canTrust: canTrustHere(),
readTrusted: async () => {
const status = await rpc("ca_status", {});
queryClient.setQueryData<CaStatus>(CA_KEY, status);
return status.trusted;
},
trust: () => trust({}),
save: async (edit) => {
try {
return await rpc("edit_decrypt_list", edit);
} finally {
// Read again rather than taken from the answer, which can land after a newer
// change's event and would put the older list back.
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
announceEngineChange();
}
},
},
edit,
);
} catch (err) {
showToast({
id: "decrypt",
color: "danger",
message: err instanceof Error ? err.message : String(err),
});
return null;
}
},
[queryClient, showToast, trust],
);
const reconnect = useCallback(
(entries: string[]) => {
for (const entry of entries) {
rpc("reconnect_tunnels", { entry }).catch((err) =>
showToast({ id: "reconnect", color: "danger", message: String(err) }),
);
}
},
[showToast],
);
/** Applies a choice; true once it is written */
const apply = useCallback(
async (choice: DecryptChoice): Promise<boolean> => {
const result = await run(choice.edit);
if (result == null || result.status === "cancelled") return false;
if (result.status === "untrusted") {
showToast({
id: "decrypt",
color: "warning",
message: "Certificate not trusted",
});
return false;
}
// Counted within what Reconnect closes, not every open connection the list now decrypts
const scoped =
result.stillEncrypted > 0 ? await countReconnectable(reconnectEntries(choice.edit)) : 0;
const toast = choiceToast(choice, scoped);
showToast({
id: "decrypt",
message: (
<div>
<div>{toast.message}</div>
{toast.sub != null && (
<div className="text-xs text-text-subtle mt-0.5">{toast.sub}</div>
)}
</div>
),
action: ({ hide }) => (
<div className="flex gap-2">
<Button
size="xs"
variant="border"
onClick={() => {
hide();
void run(inverseEdit(choice.edit));
}}
>
Undo
</Button>
{toast.reconnect.length > 0 && (
<Button
size="xs"
variant="border"
onClick={() => {
hide();
reconnect(toast.reconnect);
}}
>
Reconnect
</Button>
)}
</div>
),
});
return true;
},
[run, reconnect, showToast],
);
return { apply, trusting };
}
/** Open tunnels the entries name that the list now decrypts, each counted once */
async function countReconnectable(entries: string[]): Promise<number> {
const ids = new Set<string>();
for (const entry of entries) {
try {
for (const t of await rpc("list_reconnectable_tunnels", { entry })) ids.add(t.id);
} catch (err) {
console.warn("Failed to list reconnectable tunnels", err);
}
}
return ids.size;
}
/**
* The addresses passed through after clients rejected the certificate, kept current as the
* engine adds and forgets them. Null until read.
*/
export function usePassthrough(): string[] | null {
const queryClient = useQueryClient();
const onChanged = useCallback(
(payload: PassthroughResponse) =>
queryClient.setQueryData<PassthroughResponse>(PASSTHROUGH_KEY, payload),
[queryClient],
);
useRpcEvent("passthrough_changed", onChanged);
return useRpcQuery("list_passthrough", {}).data?.addresses ?? null;
}
-18
View File
@@ -5,9 +5,7 @@ import type {
DecryptListEntry,
} from "@yaakapp-internal/proxy-lib";
import { useCallback } from "react";
import { useShowToast } from "../lib/toast";
import { useRpcEvent } from "./useRpcEvent";
import { useRpcMutation } from "./useRpcMutation";
import { useRpcQuery } from "./useRpcQuery";
const KEY = ["get_decrypt_list", {}];
@@ -27,19 +25,3 @@ export function useDecryptList(): DecryptListEntry[] {
useRpcEvent("decrypt_list_changed", onChanged);
return useRpcQuery("get_decrypt_list", {}).data?.entries ?? EMPTY;
}
/**
* Adds and removes entries, `edit_decrypt_list` applying them to the list as it is when they
* land, so two changes made in quick succession both stick. Everything that reads the list
* then reads it again. A failure, such as an entry that does not parse, is a toast.
*/
export function useEditDecryptList() {
const queryClient = useQueryClient();
const showToast = useShowToast();
return useRpcMutation("edit_decrypt_list", {
// Read again rather than taken from the answer, which can land after a newer change's
// event and would put the older list back.
onSuccess: () => queryClient.invalidateQueries({ queryKey: KEY }),
onError: (e) => showToast({ id: "decrypt-list", color: "danger", message: e.message }),
});
}
+25
View File
@@ -2,6 +2,7 @@ import { QueryClient, QueryObserver } from "@tanstack/react-query";
import type { Flow } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test, vi } from "vite-plus/test";
import {
bodyTypeBadge,
bodyAsText,
bodyFileName,
bodyImageType,
@@ -243,3 +244,27 @@ describe("a body that isn't there", () => {
expect(emptyBodyText("request", body.note)).toBe("No request body");
});
});
describe("bodyTypeBadge", () => {
test("names a body by its type, as short as the Type column", () => {
expect(bodyTypeBadge("application/json; charset=utf-8", 10)).toBe("JSON");
expect(bodyTypeBadge("application/problem+json", 10)).toBe("JSON");
expect(bodyTypeBadge("text/xml", 10)).toBe("XML");
expect(bodyTypeBadge("text/html", 10)).toBe("HTML");
expect(bodyTypeBadge("text/csv", 10)).toBe("CSV");
expect(bodyTypeBadge("application/x-www-form-urlencoded", 10)).toBe("Form");
expect(bodyTypeBadge("multipart/form-data; boundary=x", 10)).toBe("Multipart");
expect(bodyTypeBadge("image/png", 10)).toBe("Image");
expect(bodyTypeBadge("image/svg+xml", 10)).toBe("SVG");
expect(bodyTypeBadge("text/plain", 10)).toBe("Text");
expect(bodyTypeBadge("application/grpc", 10)).toBe("Protobuf");
expect(bodyTypeBadge("application/octet-stream", 10)).toBe("Binary");
expect(bodyTypeBadge(null, 10)).toBe("Binary");
});
test("GraphQL when the request is an operation, and nothing without a body", () => {
expect(bodyTypeBadge("application/json", 10, true)).toBe("GraphQL");
expect(bodyTypeBadge("application/json", 0)).toBeNull();
expect(bodyTypeBadge(null, 0, true)).toBeNull();
});
});
+39
View File
@@ -208,6 +208,45 @@ export function shortContentType(contentType: string | null): string | null {
return suffix.replace(/^x-/, "");
}
/**
* The short name a body tab's badge gives a body's type, as the Type column and the Pretty and
* Raw switch read it: JSON, XML, HTML, CSV, Form, Multipart, GraphQL, Image, Binary and so on.
* Null when there is no body.
*/
export function bodyTypeBadge(
contentType: string | null,
size: number,
graphql = false,
): string | null {
if (size <= 0) return null;
if (graphql) return "GraphQL";
const essence = mimeEssence(contentType);
if (essence == null) return "Binary";
const [type = "", subtype = ""] = essence.split("/");
const suffix = subtype.split("+").pop() ?? subtype;
if (type === "multipart") return "Multipart";
if (essence === "application/x-www-form-urlencoded") return "Form";
if (essence === "application/graphql" || suffix === "graphql") return "GraphQL";
if (essence === "image/svg+xml") return "SVG";
if (type === "image") return "Image";
if (type === "audio") return "Audio";
if (type === "video") return "Video";
if (type === "font") return "Font";
if (suffix === "json" || suffix === "x-ndjson" || suffix === "ndjson") return "JSON";
if (suffix === "xml") return "XML";
if (suffix === "html" || suffix === "xhtml") return "HTML";
if (suffix === "csv") return "CSV";
if (suffix === "tab-separated-values") return "TSV";
if (suffix === "javascript" || suffix === "ecmascript") return "JS";
if (suffix === "css") return "CSS";
if (suffix === "markdown") return "Markdown";
if (suffix === "event-stream") return "SSE";
if (suffix === "pdf") return "PDF";
if (/grpc|protobuf|proto/.test(suffix)) return "Protobuf";
if (type === "text") return "Text";
return "Binary";
}
/** Extensions for media types whose subtype isn't one */
const EXTENSIONS: Record<string, string> = {
"application/javascript": "js",
+74 -2
View File
@@ -1,11 +1,17 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { Flow, FlowSortKey } from "@yaakapp-internal/proxy-lib";
// The formatter alone: the package index reaches for the window as it loads
import { formatMillis } from "../../../packages/ui/src/components/tags/HttpResponseDurationTag";
import { describe, expect, test } from "vite-plus/test";
import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtures/custom_column_sort.json";
import {
addCustomColumn,
addSortKey,
allColumns,
BASE_FONT_SIZE,
CHAR_REM,
cellFilterTerm,
columnDef,
columnShifts,
type ColumnPrefs,
customPath,
@@ -20,6 +26,7 @@ import {
hideOtherColumns,
moveColumn,
moveColumnBy,
NUMERIC_FIT_CHARS,
nextSort,
operationOf,
parseColumnPrefs,
@@ -28,6 +35,7 @@ import {
resetWidth,
resetWidths,
resizeColumn,
resizeDistance,
showOperationFor,
shownColumns,
sortFlows,
@@ -35,6 +43,7 @@ import {
toggleSortKey,
urlParts,
} from "./columns";
import { DEFAULT_SETTINGS_VALUES } from "./settings";
function flow(number: number, extra: Partial<Flow> = {}): Flow {
return {
@@ -109,10 +118,73 @@ describe("shownColumns", () => {
prefs = resizeColumn(prefs, "path", 500);
const columns = shownColumns(prefs);
const pad = (w: number) => `calc(${w}px + 0.75rem)`;
// Size and Time, in pixels at the default interface size of 15, grow with it
const size = "calc(4.8rem + 0.75rem)";
const time = `calc(${64 / 15}rem + 0.75rem)`;
expect(gridTemplate(columns)).toBe(
["24px", ...[46, 20, 50, 240, 500, 120, 46, 50, 72, 64].map(pad)].join(" "),
["24px", ...[46, 20, 50, 240, 500, 120, 46, 50].map(pad), size, time].join(" "),
);
expect(gridWidth(columns)).toBe("calc(1232px + 10 * 0.75rem)");
expect(gridWidth(columns)).toBe(`calc(1096px + 4.8rem + ${64 / 15}rem + 10 * 0.75rem)`);
// Resized, too, and at its least width it holds its widest value: eight characters of 0.5rem
const resized = shownColumns(resizeColumn(prefs, "size", 10));
expect(gridTemplate(resized)).toContain(" calc(4rem + 0.75rem)");
});
});
describe("numeric column widths", () => {
// Every format the cells show: each unit's widest, and around each rounding edge
const sizes = [0, 9, 999, 1000, 1001, 1049, 99_949, 99_951, 999_949, 999_999, 1_000_001];
const giga = 999_949_000_000;
const durations = [0, 999, 1000, 9_949, 9_999, 59_499, 59_999, 60_000, 3_599_499, 59_999_999];
const numeric = ["size", "requestSize", "time", "latency"] as const;
test("the formats' widest values are as many characters as each numeric column holds", () => {
const widest = (values: string[]) => Math.max(...values.map((v) => v.length));
const sizeChars = widest([...sizes, giga].map(formatSize));
const timeChars = widest(durations.map(formatMillis));
expect(sizeChars).toBe(8);
expect(timeChars).toBe(8);
for (const id of numeric) {
const def = columnDef(id);
const chars = id === "size" || id === "requestSize" ? sizeChars : timeChars;
expect(NUMERIC_FIT_CHARS).toBeGreaterThanOrEqual(chars);
expect(def.scales).toBe(true);
// In pixels at the default interface size, as the row's font sets them
const fit = NUMERIC_FIT_CHARS * CHAR_REM * BASE_FONT_SIZE;
expect(def.minWidth).toBeGreaterThanOrEqual(fit);
expect(def.width).toBeGreaterThanOrEqual(fit);
}
});
test("the room for a character fits JetBrains Mono at text-xs", () => {
// A monospace advance of 0.6em, at 0.8rem
expect(CHAR_REM).toBeGreaterThanOrEqual(0.6 * 0.8);
expect(BASE_FONT_SIZE).toBe(DEFAULT_SETTINGS_VALUES.interfaceFontSize);
});
test("defaults fit each label sorted, with the sort's arrow and place", () => {
// Measured in the harness at the default interface size, sorted by four columns
const header = { size: 46, requestSize: 96, time: 50, latency: 67 };
for (const id of numeric) expect(columnDef(id).width).toBeGreaterThanOrEqual(header[id]);
});
test("a resize moves a numeric column's edge with the pointer at any interface size", () => {
const size = columnDef("size");
// At 30, twice the default, a 20px drag is 10 of the pixels its width is kept in
expect(resizeDistance(size, 20, 30)).toBe(10);
expect(resizeDistance(size, -12, 12)).toBe(-15);
expect(resizeDistance(columnDef("host"), 20, 30)).toBe(20);
});
test("a stored width narrower than the least is raised, and a wider one kept", () => {
const parsed = parseColumnPrefs({ widths: { size: 44, time: 50, requestSize: 120, host: 20 } });
expect(parsed.widths).toEqual({ size: 60, time: 60, requestSize: 120, host: 60 });
expect(shownColumns(parsed).find((c) => c.id === "size")?.current).toBe(60);
expect(resizeColumn(defaultColumnPrefs, "latency", 10).widths).toEqual({ latency: 60 });
// Reset, the defaults are back
const reset = shownColumns(resetWidths(parsed)).find((c) => c.id === "size");
expect(reset?.current).toBe(72);
expect(resetWidth(parsed, "time").widths).toEqual({ size: 60, requestSize: 120, host: 60 });
});
});
+54 -9
View File
@@ -23,6 +23,11 @@ export interface ColumnDef {
defaultVisible: boolean;
/** The header or query parameter a custom column shows: `header.x-request-id` */
path?: string;
/**
* A numeric column's: its width, resized or not, grows with the interface size, as its values
* do, so the least width always holds the widest value
*/
scales?: boolean;
}
const builtin = (
@@ -32,7 +37,29 @@ const builtin = (
minWidth: number,
align: "left" | "right",
defaultVisible: boolean,
): ColumnDef => ({ id, label, width, minWidth, align, defaultVisible });
scales?: boolean,
): ColumnDef => ({ id, label, width, minWidth, align, defaultVisible, scales });
/**
* The widest size and duration a numeric cell shows, in characters: `999.9 KB` and `999m 59s`,
* from `formatSize` and `formatMillis`
*/
export const NUMERIC_FIT_CHARS = 8;
/**
* Room for one character of a numeric cell, `font-mono text-xs`: 0.8rem, and a monospace advance
* of 0.6em, JetBrains Mono's and most others', with a little over for wider editor fonts
*/
export const CHAR_REM = 0.5;
/** The default interface font size, `DEFAULT_SETTINGS_VALUES.interfaceFontSize`, widths are at */
export const BASE_FONT_SIZE = 15;
/**
* A numeric column's least width, in pixels at the default interface size, which grow with it:
* its widest value
*/
const NUMERIC_MIN = NUMERIC_FIT_CHARS * CHAR_REM * BASE_FONT_SIZE;
export const COLUMNS: ColumnDef[] = [
builtin("number", "#", 46, 30, "left", true),
@@ -43,10 +70,11 @@ export const COLUMNS: ColumnDef[] = [
builtin("query", "Query", 120, 40, "left", true),
builtin("status", "Status", 46, 34, "left", true),
builtin("type", "Type", 50, 34, "left", true),
builtin("requestSize", "Request Size", 80, 44, "right", false),
builtin("size", "Size", 72, 44, "right", true),
builtin("time", "Time", 64, 44, "right", true),
builtin("latency", "Latency", 64, 44, "right", false),
// Each default fits its label sorted, with the sort's arrow and place, and its widest value
builtin("requestSize", "Request Size", 96, NUMERIC_MIN, "right", false, true),
builtin("size", "Size", 72, NUMERIC_MIN, "right", true, true),
builtin("time", "Time", 64, NUMERIC_MIN, "right", true, true),
builtin("latency", "Latency", 68, NUMERIC_MIN, "right", false, true),
builtin("started", "Started", 92, 60, "left", false),
builtin("remote", "Remote Address", 150, 60, "left", false),
builtin("version", "HTTP Version", 70, 44, "left", false),
@@ -211,16 +239,30 @@ export function columnWidth(prefs: ColumnPrefs, id: ColumnId): number {
return Math.max(def.minWidth, Math.round(prefs.widths[id] ?? def.width));
}
/** A column's content width, as CSS: a numeric column's grows with the interface size */
function columnTrack(c: Column): string {
return c.scales ? `${c.current / BASE_FONT_SIZE}rem` : `${c.current}px`;
}
/**
* How far a resize moved, in the pixels a column's width is kept in: a numeric column's are at
* the default interface size, `rootFontSize` being the size now
*/
export function resizeDistance(c: ColumnDef, dx: number, rootFontSize: number): number {
return c.scales && rootFontSize > 0 ? (dx * BASE_FONT_SIZE) / rootFontSize : dx;
}
/** The grid template for the header and every row: the mark, then each column and its padding */
export function gridTemplate(columns: Column[]): string {
const cells = columns.map((c) => `calc(${c.current}px + ${CELL_PADDING})`);
const cells = columns.map((c) => `calc(${columnTrack(c)} + ${CELL_PADDING})`);
return [`${MARK_WIDTH}px`, ...cells].join(" ");
}
/** The width of a row's grid, as CSS: the list scrolls sideways when it is wider than the list */
export function gridWidth(columns: Column[]): string {
const px = columns.reduce((sum, c) => sum + c.current, MARK_WIDTH);
return `calc(${px}px + ${columns.length} * ${CELL_PADDING})`;
const px = columns.filter((c) => !c.scales).reduce((sum, c) => sum + c.current, MARK_WIDTH);
const scaled = columns.filter((c) => c.scales).map((c) => ` + ${columnTrack(c)}`);
return `calc(${px}px${scaled.join("")} + ${columns.length} * ${CELL_PADDING})`;
}
/** Whether the picker may uncheck `id`: any column but the last one shown */
@@ -406,7 +448,10 @@ export function parseColumnPrefs(value: unknown): ColumnPrefs {
const v = (visible as Record<string, unknown> | undefined)?.[def.id];
if (typeof v === "boolean") prefs.visible[def.id] = v;
const w = (widths as Record<string, unknown> | undefined)?.[def.id];
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
// Narrower than the column's least width, as a numeric column's was before it grew, rises to it
if (typeof w === "number" && Number.isFinite(w) && w > 0) {
prefs.widths[def.id] = Math.max(def.minWidth, w);
}
}
// Columns no longer there are dropped; those not listed follow the rest in their default order
if (Array.isArray(order)) {
+48 -5
View File
@@ -11,8 +11,12 @@ import {
decryptMenuAction,
decrypts,
decryptsEverything,
editDecrypts,
entryLabel,
entrySubject,
everythingEdit,
inverseEdit,
listSummary,
isDecrypted,
listEdit,
normalizeEntry,
@@ -170,15 +174,31 @@ describe("the sidebar menu", () => {
const a = { kind: "host", host: "a.com" } as const;
const b = { kind: "host", host: "b.com" } as const;
test("offers Decrypt unless every selected row is decrypted", () => {
test("offers Decrypt unless every selected row is decrypted, naming what it changes", () => {
expect(decryptMenuAction(user("host:a.com"), [a])).toEqual({
label: "Stop Decrypting",
label: "Stop Decrypting a.com",
on: false,
subject: "a.com",
});
expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({
label: "Decrypt 2 Hosts",
on: true,
subject: "2 hosts",
});
expect(decryptMenuAction(user("host:a.com"), [a, b])).toEqual({ label: "Decrypt", on: true });
expect(decryptMenuAction([], [])).toBeNull();
});
test("names sites, apps and mixed rows", () => {
const site = { kind: "site", site: "google.com" } as const;
const dia = { kind: "app", name: "Dia", bundleId: null } as const;
expect(decryptMenuAction([], [site])?.label).toBe("Decrypt *.google.com");
expect(decryptMenuAction([], [dia])?.label).toBe("Decrypt Dia");
expect(decryptMenuAction([], [site, { kind: "site", site: "x.com" }])?.label).toBe(
"Decrypt 2 Sites",
);
expect(decryptMenuAction([], [dia, a, site])?.label).toBe("Decrypt 3 Selected");
});
test("applies to every selected row, leaving those already so", () => {
const next = setDecrypted(user("host:a.com"), [a, b], true).map((e) => e.entry);
expect(next).toEqual(["host:a.com", "host:b.com"]);
@@ -225,8 +245,8 @@ describe("the Not decrypted detail's buttons", () => {
});
});
describe("the first-run step", () => {
test("Everything adds *, and Pick as they appear takes it out", () => {
describe("Decrypt Everything", () => {
test("adds *, and the switch off takes it out", () => {
expect(decryptsEverything(BUILT_INS)).toBe(false);
expect(decryptsEverything([...user("*"), ...BUILT_INS])).toBe(true);
expect(everythingEdit(true)).toEqual({ add: ["*"], remove: [] });
@@ -303,3 +323,26 @@ describe("the list's autocomplete", () => {
});
});
});
describe("what a change says", () => {
test("the pill counts the person's own positive entries", () => {
expect(listSummary([...user("app:Dia", "app:Slack", "host:*.google.com"), ...BUILT_INS])).toBe(
"2 apps, 1 host",
);
expect(listSummary([...user("*", "!host:a.com"), ...BUILT_INS])).toBeNull();
});
test("names an entry's subject", () => {
expect(entrySubject("host:www.google.com")).toBe("www.google.com");
expect(entrySubject("host:*.google.com")).toBe("*.google.com");
expect(entrySubject("app:Dia")).toBe("Dia");
expect(entrySubject("*")).toBe("Everything");
});
test("an edit can decrypt when it adds a positive entry or drops an exclusion", () => {
expect(editDecrypts({ add: ["host:a.com"], remove: [] })).toBe(true);
expect(editDecrypts({ add: [], remove: ["!host:a.com"] })).toBe(true);
expect(editDecrypts({ add: ["!app:Dia"], remove: ["host:a.com"] })).toBe(false);
expect(inverseEdit({ add: ["a"], remove: ["b"] })).toEqual({ add: ["b"], remove: ["a"] });
});
});
+88 -2
View File
@@ -163,6 +163,12 @@ function matches(e: ParsedEntry, q: DecryptQuery): boolean {
}
}
/** Whether one entry, as stored, names `q`, whichever way it points */
export function entryMatches(entry: string, q: DecryptQuery): boolean {
const parsed = parseEntry(entry);
return parsed != null && matches(parsed, q);
}
function specificity(e: ParsedEntry): number {
if (e.kind === "everything") return 0;
if (e.kind === "app") return Number.POSITIVE_INFINITY;
@@ -193,6 +199,52 @@ export function decryptsAnything(list: DecryptListEntry[]): boolean {
return list.some((e) => !e.entry.trim().startsWith("!"));
}
/** What a list entry is called in a button: its host or pattern, its app, or Everything */
export function entrySubject(entry: string): string {
const parsed = parseEntry(entry);
if (parsed == null) return entry;
if (parsed.kind === "everything") return "Everything";
if (parsed.kind === "app") return parsed.pattern;
return formatEntry({ ...parsed, negated: false }).slice("host:".length);
}
/**
* The person's own positive entries, counted for the pill: `2 apps, 1 host`, sites counted as
* hosts, `*` left out as the switch beside it shows it. Null when there are none.
*/
export function listSummary(list: DecryptListEntry[]): string | null {
let apps = 0;
let hosts = 0;
for (const e of list) {
if (e.builtIn) continue;
const parsed = parseEntry(e.entry);
if (parsed == null || parsed.negated) continue;
if (parsed.kind === "app") apps++;
if (parsed.kind === "host") hosts++;
}
const parts = [
apps > 0 ? `${apps} ${apps === 1 ? "app" : "apps"}` : null,
hosts > 0 ? `${hosts} ${hosts === 1 ? "host" : "hosts"}` : null,
].filter((p) => p != null);
return parts.length === 0 ? null : parts.join(", ");
}
/** Whether an edit can make something decrypt: it adds a positive entry or drops an exclusion */
export function editDecrypts(edit: { add: string[]; remove: string[] }): boolean {
return (
edit.add.some((e) => !e.trim().startsWith("!")) ||
edit.remove.some((e) => e.trim().startsWith("!"))
);
}
/** The edit that puts back what `edit` changed, for Undo */
export function inverseEdit(edit: { add: string[]; remove: string[] }): {
add: string[];
remove: string[];
} {
return { add: edit.remove, remove: edit.add };
}
/** What a sidebar row or the detail's buttons decrypt: a host, a site, or an app */
export type DecryptSubject =
| { kind: "host"; host: string }
@@ -335,10 +387,44 @@ export function setDecrypted(
export function decryptMenuAction(
list: DecryptListEntry[],
subjects: DecryptSubject[],
): { label: "Decrypt" | "Stop Decrypting"; on: boolean } | null {
): { label: string; on: boolean; subject: string } | null {
if (subjects.length === 0) return null;
const on = !subjects.every((s) => isDecrypted(list, s));
return { label: on ? "Decrypt" : "Stop Decrypting", on };
const subject = subjectsName(subjects);
return {
label: `${on ? "Decrypt" : "Stop Decrypting"} ${subject.title}`,
on,
subject: subject.text,
};
}
/** What a subject is called: its host, `*.site`, or its app */
export function subjectName(subject: DecryptSubject): string {
switch (subject.kind) {
case "host":
return subject.host;
case "site":
return `*.${subject.site}`;
case "app":
return subject.name;
}
}
/**
* Several subjects named together: one by its name, several of a kind counted, `3 Sites` in a
* menu and `3 sites` 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" ? "Sites" : "Hosts";
return { title: `${n} ${noun}`, text: `${n} ${noun.toLowerCase()}` };
}
function builtIns(list: DecryptListEntry[]): DecryptListEntry[] {
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, test, vi } from "vite-plus/test";
import { applyChoice, type ChoiceDeps, choiceToast, reconnectEntries } from "./decryptChoice";
function deps(over: Partial<ChoiceDeps> = {}) {
const calls: string[] = [];
const d: ChoiceDeps = {
canTrust: true,
readTrusted: vi.fn(async () => {
calls.push("ca_status");
return false;
}),
trust: vi.fn(async () => {
calls.push("ca_trust");
return { cancelled: false, trusted: true };
}),
save: vi.fn(async () => {
calls.push("edit_decrypt_list");
return { stillEncrypted: 2 };
}),
...over,
};
return { d, calls };
}
const decrypt = { add: ["host:www.google.com"], remove: [] };
describe("trust at the first Decrypt", () => {
test("trusts first while untrusted, then writes the change", async () => {
const { d, calls } = deps();
expect(await applyChoice(d, decrypt)).toEqual({ status: "applied", stillEncrypted: 2 });
expect(calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]);
});
test("a cancelled prompt writes nothing", async () => {
const { d, calls } = deps({
trust: async () => {
calls.push("ca_trust");
return { cancelled: true, trusted: false };
},
});
expect(await applyChoice(d, decrypt)).toEqual({ status: "cancelled" });
expect(calls).toEqual(["ca_status", "ca_trust"]);
});
test("a prompt answered without trust writes nothing either", async () => {
const { d } = deps({ trust: async () => ({ cancelled: false, trusted: false }) });
expect(await applyChoice(d, decrypt)).toEqual({ status: "untrusted" });
expect(d.save).not.toHaveBeenCalled();
});
test("a failed prompt fails the choice, nothing written", async () => {
const { d } = deps({
trust: async () => {
throw new Error("Not supported");
},
});
await expect(applyChoice(d, decrypt)).rejects.toThrow("Not supported");
expect(d.save).not.toHaveBeenCalled();
});
test("asks nothing once trusted", async () => {
const { d, calls } = deps({
readTrusted: async () => {
calls.push("ca_status");
return true;
},
});
await applyChoice(d, decrypt);
expect(calls).toEqual(["ca_status", "edit_decrypt_list"]);
});
test("stopping never asks, and dropping an exclusion does", async () => {
const stop = deps();
await applyChoice(stop.d, { add: ["!app:Dia"], remove: ["host:www.google.com"] });
expect(stop.calls).toEqual(["edit_decrypt_list"]);
const unexclude = deps();
await applyChoice(unexclude.d, { add: [], remove: ["!host:*.chase.com"] });
expect(unexclude.calls).toEqual(["ca_status", "ca_trust", "edit_decrypt_list"]);
});
test("where the app can't trust the certificate itself, the entry is written at once", async () => {
const { d, calls } = deps({ canTrust: false });
await applyChoice(d, decrypt);
expect(calls).toEqual(["edit_decrypt_list"]);
});
});
describe("the toast after a choice", () => {
const choice = { edit: decrypt, message: "Decrypting www.google.com" };
test("says what was chosen, and nothing to reconnect when nothing open stays encrypted", () => {
expect(choiceToast(choice, 0)).toEqual({
message: "Decrypting www.google.com",
sub: null,
reconnect: [],
});
expect(
choiceToast(
{ edit: { add: ["*"], remove: [] }, message: "Decrypting everything", sub: "Except" },
0,
).sub,
).toBe("Except");
});
test("offers Reconnect for the entry when open connections stay encrypted", () => {
expect(choiceToast(choice, 3)).toEqual({
message: "Decrypting www.google.com",
sub: "3 open connections stay encrypted",
reconnect: ["host:www.google.com"],
});
expect(choiceToast(choice, 1).sub).toBe("1 open connection stays encrypted");
});
test("Reconnect for an exclusion taken off names the exclusion, as the engine reads it", () => {
const stopExcluding = { edit: { add: [], remove: ["!host:*.chase.com"] }, message: "x" };
expect(choiceToast(stopExcluding, 1).reconnect).toEqual(["!host:*.chase.com"]);
});
});
describe("what Reconnect covers", () => {
test("the entries a change added, and the exclusions it took off, never what it stopped", () => {
expect(
reconnectEntries({ add: ["host:a.com", "!app:Dia"], remove: ["!host:*.b.com", "app:Slack"] }),
).toEqual(["host:a.com", "!host:*.b.com"]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { editDecrypts } from "./decrypt";
/** A change to the decrypt list, as `edit_decrypt_list` takes it */
export interface ListEdit {
add: string[];
remove: string[];
}
/** A Decrypt or Stop Decrypting chosen somewhere in the app, with what its toast says */
export interface DecryptChoice {
edit: ListEdit;
/** "Decrypting www.google.com" */
message: string;
/** Under it, when nothing more pressing is */
sub?: string;
}
/** What applying a choice needs from the app: the certificate's state and the engine */
export interface ChoiceDeps {
/** The app can trust the certificate itself, as on macOS; elsewhere the entry is written at once */
canTrust: boolean;
/** Reads whether the certificate is trusted now */
readTrusted: () => Promise<boolean>;
/** macOS's password prompt */
trust: () => Promise<{ cancelled: boolean; trusted: boolean }>;
save: (edit: ListEdit) => Promise<{ stillEncrypted: number }>;
}
export type ChoiceResult =
| { status: "applied"; stillEncrypted: number }
/** The password prompt was cancelled: nothing changed, and nothing to say */
| { status: "cancelled" }
/** The prompt was answered but the certificate is still not trusted */
| { status: "untrusted" };
/**
* Trust at the first Decrypt: a change that can make something decrypt first trusts the
* certificate while it isn't, then writes the change only once that succeeded. A change that
* only stops decrypting never asks.
*/
export async function applyChoice(deps: ChoiceDeps, edit: ListEdit): Promise<ChoiceResult> {
if (deps.canTrust && editDecrypts(edit) && !(await deps.readTrusted())) {
const result = await deps.trust();
if (result.cancelled) return { status: "cancelled" };
if (!result.trusted) return { status: "untrusted" };
}
const saved = await deps.save(edit);
return { status: "applied", stillEncrypted: saved.stillEncrypted };
}
/**
* The entries whose open connections a change can make decryptable, which Reconnect closes and
* the toast counts: those it added, and exclusions it took off
*/
export function reconnectEntries(edit: ListEdit): string[] {
return [
...edit.add.filter((e) => !e.trim().startsWith("!")),
...edit.remove.filter((e) => e.trim().startsWith("!")),
];
}
/** The toast after a choice was applied */
export interface ChoiceToast {
message: string;
sub: string | null;
/**
* The entries Reconnect closes the open connections of, one `reconnect_tunnels` each: those
* the change added, and exclusions it took off. Empty when nothing open stays encrypted.
*/
reconnect: string[];
}
/**
* Says what was chosen, with Undo; when open connections the list now decrypts stay encrypted,
* how many, with Reconnect for them. Nothing reconnects without that click.
*/
export function choiceToast(choice: DecryptChoice, stillEncrypted: number): ChoiceToast {
if (stillEncrypted <= 0)
return { message: choice.message, sub: choice.sub ?? null, reconnect: [] };
const n = stillEncrypted.toLocaleString();
const reconnect = reconnectEntries(choice.edit);
return {
message: choice.message,
sub: `${n} open ${stillEncrypted === 1 ? "connection stays" : "connections stay"} encrypted`,
reconnect,
};
}
+21 -17
View File
@@ -5,11 +5,11 @@ import {
learnTab,
REQUEST_TAB_BODY,
REQUEST_TAB_HEADERS,
REQUEST_TAB_INFO,
RESPONSE_TAB_BODY,
RESPONSE_TAB_COOKIES,
RESPONSE_TAB_HEADERS,
RESPONSE_TAB_INFO,
RESPONSE_TAB_MESSAGES,
RESPONSE_TAB_TIMELINE,
type TabMemory,
} from "./defaultTab";
@@ -33,13 +33,7 @@ function memory(): TabMemory & { items: Map<string, string> } {
};
}
const RESPONSE_TABS = [
RESPONSE_TAB_BODY,
RESPONSE_TAB_HEADERS,
"cookies",
RESPONSE_TAB_TIMELINE,
RESPONSE_TAB_INFO,
];
const RESPONSE_TABS = [RESPONSE_TAB_BODY, RESPONSE_TAB_HEADERS, RESPONSE_TAB_COOKIES];
describe("flowShape", () => {
test("names what the flow has", () => {
@@ -59,8 +53,8 @@ describe("flowShape", () => {
describe("defaultTabFor", () => {
test("follows the table", () => {
const m = memory();
// A response body
expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_HEADERS);
// A response body, the request's Info, as there is no request body
expect(defaultTabFor("request", ok, [], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("request", { ...ok, requestSize: 9 }, [], m)).toBe(REQUEST_TAB_BODY);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY);
// A request body, no response body
@@ -69,27 +63,28 @@ describe("defaultTabFor", () => {
expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
// Neither
const empty = { ...ok, responseSize: 0 };
expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_HEADERS);
expect(defaultTabFor("request", empty, [], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("response", empty, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
// A stream
const ws = { ...ok, streamKind: "websocket" as const };
expect(defaultTabFor("response", ws, [...RESPONSE_TABS, RESPONSE_TAB_MESSAGES], m)).toBe(
RESPONSE_TAB_MESSAGES,
);
// No response at all
// No response at all: its timeline is in the request's Info
const failed = { ...ok, status: null, error: "refused", responseSize: 0 };
expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_TIMELINE);
expect(defaultTabFor("response", failed, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
expect(defaultTabFor("request", failed, [], m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("request", { ...failed, requestSize: 4 }, [], m)).toBe(REQUEST_TAB_BODY);
});
test("a tab chosen by hand becomes the default for the same shape and pane", () => {
const m = memory();
learnTab("response", ok, RESPONSE_TAB_INFO, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_INFO);
learnTab("response", ok, RESPONSE_TAB_COOKIES, m);
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_COOKIES);
// Not for other shapes, nor the other pane
const post = { ...ok, requestSize: 30, responseSize: 0 };
expect(defaultTabFor("response", post, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_HEADERS);
expect(defaultTabFor("request", ok, ["body", "headers", "info"], m)).toBe(REQUEST_TAB_HEADERS);
expect(defaultTabFor("request", ok, ["info", "body", "headers"], m)).toBe(REQUEST_TAB_INFO);
});
test("a learned tab the flow doesn't have falls back to the table", () => {
@@ -100,6 +95,15 @@ describe("defaultTabFor", () => {
expect(defaultTabFor("response", ok, RESPONSE_TABS, m)).toBe(RESPONSE_TAB_BODY);
});
test("the request half opens on Info unless it has a body, and learns like the response", () => {
const m = memory();
const tabs = [REQUEST_TAB_INFO, REQUEST_TAB_BODY, REQUEST_TAB_HEADERS];
expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_INFO);
expect(defaultTabFor("request", { ...ok, requestSize: 5 }, tabs, m)).toBe(REQUEST_TAB_BODY);
learnTab("request", ok, REQUEST_TAB_HEADERS, m);
expect(defaultTabFor("request", ok, tabs, m)).toBe(REQUEST_TAB_HEADERS);
});
test("works without storage", () => {
expect(defaultTabFor("response", ok, RESPONSE_TABS, null)).toBe(RESPONSE_TAB_BODY);
});
+7 -6
View File
@@ -1,6 +1,7 @@
import type { Flow } from "@yaakapp-internal/proxy-lib";
/** The request half's tabs, named as the shared request pane names them */
/** The request half's tabs, named as the shared request pane names them, Info first */
export const REQUEST_TAB_INFO = "info";
export const REQUEST_TAB_BODY = "body";
export const REQUEST_TAB_PARAMS = "params";
export const REQUEST_TAB_HEADERS = "headers";
@@ -11,8 +12,6 @@ export const REQUEST_TAB_COOKIES = "cookies";
export const RESPONSE_TAB_BODY = "body";
export const RESPONSE_TAB_HEADERS = "headers";
export const RESPONSE_TAB_COOKIES = "cookies";
export const RESPONSE_TAB_TIMELINE = "timeline";
export const RESPONSE_TAB_INFO = "info";
export const RESPONSE_TAB_MESSAGES = "messages";
export type FlowPane = "request" | "response";
@@ -33,10 +32,13 @@ export function flowShape(
return "neither";
}
/** The tab a half opens on for a shape, before anything was learned */
/**
* The tab a half opens on for a shape, before anything was learned: the request half on its
* body when it has one, else on Info; the response half on what it has
*/
export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody: boolean): string {
if (pane === "request") {
return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_HEADERS;
return hasRequestBody ? REQUEST_TAB_BODY : REQUEST_TAB_INFO;
}
switch (shape) {
case "response_body":
@@ -44,7 +46,6 @@ export function shapeDefaultTab(pane: FlowPane, shape: FlowShape, hasRequestBody
case "stream":
return RESPONSE_TAB_MESSAGES;
case "no_response":
return RESPONSE_TAB_TIMELINE;
case "request_body":
case "neither":
return RESPONSE_TAB_HEADERS;
+353
View File
@@ -0,0 +1,353 @@
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("Nothing is decrypted yet");
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([]);
expect(c.fact).toBeNull();
});
test("asks for the password once while the certificate isn't trusted, where it can be", () => {
expect(card({ trusted: false }).foot).toEqual(["Asks for your 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), pinned.whySub]).toEqual([
"Excluded by default",
"pins its certificate",
]);
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 refuse it"]);
const apple = card({
flow: {
...tunnel,
url: "gateway.icloud.apple.com:443",
clientName: null,
clientBundleId: null,
tunnelReason: "excluded",
tunnelExcludedBy: "!host:*.apple.com",
tunnelExcludedBuiltIn: true,
},
});
expect(apple.whySub).toBe("Apple service");
expect(apple.foot).toEqual(["macOS may refuse it"]);
});
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("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("Not on the decrypt list");
expect(closed.fact && lineText(closed.fact)).toBe(
"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(everything.fact && lineText(everything.fact)).toBe(
"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, Try Again 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.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]);
expect(summary(c)).toEqual([
["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }],
["Try Again", "next connection", null],
]);
expect(c.actions[0]?.primary).toBe(true);
expect(c.foot).toEqual(["Passed through until relaunch"]);
});
test("once no longer passed through, Try Again 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(["No longer passed through"]);
});
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.whySub]).toEqual([
"python3 rejected the certificate",
"uses its own trust store",
]);
expect(c.actions.map((a) => [a.kind, a.label])).toEqual([
["copy_environment", "Copy Environment"],
["stop", "Stop Decrypting python3"],
["try_again", "Try Again"],
]);
expect(c.foot).toEqual(["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("A client 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("Certificate not trusted");
expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]);
expect(c.foot).toEqual(["Passed through until 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();
});
});
+426
View File
@@ -0,0 +1,426 @@
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 line */
why: Line;
/** Quieter, after it */
whySub: string | null;
/** A fact about the connection since the list changed: when it opened */
fact: Line | 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 { why, whySub } = whyLine(flow, list, c, decryptsNow);
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,
whySub,
fact: ["Opened before ", what, " was decrypted"],
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 refuse it"
: `${c.app.name} may refuse it`,
);
}
} 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: "Except Apple services and apps that pin" },
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("Asks for your password once");
return {
rejected: false,
why,
whySub,
fact: null,
actions,
foot: actions.length === 0 ? [] : foot,
};
}
/**
* The why line, from the reason the engine recorded as the tunnel opened. One that nothing on
* the list covers names its host; once the list covers it, it opened before that.
*/
function whyLine(
flow: EncryptedCardInput["flow"],
list: DecryptListEntry[],
c: Connection,
decryptsNow: boolean,
): { why: Line; whySub: string | null } {
if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) {
if (flow.tunnelExcludedBuiltIn) {
const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind;
const whySub =
kind === "apple" ? "Apple service" : kind === "pinned" ? "pins its certificate" : null;
return { why: ["Excluded by default"], whySub };
}
return { why: ["Excluded by ", { code: flow.tunnelExcludedBy }], whySub: null };
}
return {
why: !decryptsAnything(list)
? ["Nothing is decrypted yet"]
: decryptsNow
? ["Not on the decrypt list"]
: [
{ bold: "Encrypted Content:" },
" Enable decryption to see requests to ",
{ code: c.label },
],
whySub: null,
};
}
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: "Try Again", hint: "next connection", icon: "refresh" };
const until = passedThrough === false ? "No longer passed through" : null;
if (canTrust && trusted === false && decryptsAnything(list)) {
return {
rejected: true,
why: ["Certificate not trusted"],
whySub: null,
fact: null,
actions: [
{ kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" },
...(stop == null ? [] : [stop]),
],
foot: [until ?? "Passed through until trusted"],
};
}
const forHost = own ? "" : " for this host";
if (refuser.kind === "program") {
return {
rejected: true,
why: [`${refuser.name} rejected the certificate${forHost}`],
whySub: "uses its own trust store",
fact: null,
actions: [
{
kind: "copy_environment",
label: "Copy Environment",
hint: "SSL_CERT_FILE, …",
primary: true,
icon: "terminal",
},
...(stop == null ? [] : [stop]),
...(tryAgain == null ? [] : [tryAgain]),
],
foot: until == null ? [`Then rerun ${refuser.name}`] : [until],
};
}
return {
rejected: true,
why: [
refuser.kind === "app"
? `${refuser.name} rejected the certificate${forHost}`
: "A client rejected the certificate",
],
whySub: refuser.kind === "app" ? "likely pinned" : null,
fact: null,
actions: [
...(stop == null ? [] : [{ ...stop, primary: true }]),
...(tryAgain == null ? [] : [tryAgain]),
],
foot: [until ?? "Passed through until relaunch"],
};
}
+6 -28
View File
@@ -1,12 +1,8 @@
import { describe, expect, test } from "vite-plus/test";
import { decryptStep, setupIncomplete } from "./firstRun";
import { setupIncomplete } from "./firstRun";
describe("setupIncomplete", () => {
const done = { trusted: true, systemProxySupported: true, systemProxyEverOn: true };
test("is incomplete while the certificate is untrusted", () => {
expect(setupIncomplete({ ...done, trusted: false })).toBe(true);
});
const done = { systemProxySupported: true, systemProxyEverOn: true };
test("is incomplete until the system proxy has been on once", () => {
expect(setupIncomplete({ ...done, systemProxyEverOn: false })).toBe(true);
@@ -17,29 +13,11 @@ describe("setupIncomplete", () => {
expect(
setupIncomplete({ ...done, systemProxySupported: false, systemProxyEverOn: false }),
).toBe(false);
expect(setupIncomplete({ ...done, systemProxySupported: false, trusted: false })).toBe(true);
});
test("waits for both to be read, so the card does not flash", () => {
expect(setupIncomplete({ ...done, trusted: null })).toBe(false);
expect(setupIncomplete({ ...done, systemProxySupported: null, trusted: false })).toBe(false);
});
});
describe("decryptStep", () => {
const builtIns = [{ entry: "!host:*.apple.com", builtIn: true }];
test("picks hosts as they appear by default, after the certificate", () => {
expect(decryptStep(builtIns, false)).toEqual({ choice: "pick", state: "todo" });
expect(decryptStep(builtIns, true)).toEqual({ choice: "pick", state: "next" });
});
test("is done once everything, or a host, is on the list", () => {
const everything = [{ entry: "*", builtIn: false }, ...builtIns];
expect(decryptStep(everything, true)).toEqual({ choice: "everything", state: "done" });
const host = [{ entry: "host:api.example.com", builtIn: false }];
expect(decryptStep(host, true)).toEqual({ choice: "pick", state: "done" });
// An exclusion alone decrypts nothing.
expect(decryptStep([{ entry: "!host:a.com", builtIn: false }], true).state).toBe("next");
test("waits for the system proxy to be read, so the card does not flash", () => {
expect(setupIncomplete({ ...done, systemProxySupported: null, systemProxyEverOn: false })).toBe(
false,
);
});
});
+6 -29
View File
@@ -1,10 +1,5 @@
import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib";
import { decryptsEverything } from "./decrypt";
/** What decides whether setup is done, as the app last read it */
export interface SetupState {
/** The certificate is trusted, or null until read */
trusted: boolean | null;
/** The system proxy can be set on this platform, or null until read */
systemProxySupported: boolean | null;
/** The system proxy has been on at some point since this install */
@@ -12,31 +7,13 @@ export interface SetupState {
}
/**
* The first-run card shows while setup is incomplete: the certificate untrusted, or the system
* proxy never on since this install, where there is one. Not while either is still being read,
* so the card does not flash. Once setup is done an empty list is the "no flows yet" state,
* after a Clear too.
* The first-run card shows while setup is incomplete: the system proxy never on since this
* install, where there is one. The certificate is not part of it: nothing needs it until
* something is decrypted, which asks for it then. Not while the system proxy is still being
* read, so the card does not flash. Once setup is done an empty list is the "no flows yet"
* state, after a Clear too.
*/
export function setupIncomplete(s: SetupState): boolean {
if (s.trusted == null || s.systemProxySupported == null) return false;
if (!s.trusted) return true;
if (s.systemProxySupported == null) return false;
return s.systemProxySupported && !s.systemProxyEverOn;
}
/** The first-run card's "Choose what to decrypt" step, as the decrypt list has it */
export interface DecryptStep {
/** Everything is decrypted, `*` on the list; else hosts are picked as they appear */
choice: "everything" | "pick";
state: "todo" | "next" | "done";
}
/**
* Picking hosts as they appear is the default and needs nothing done, so the step waits on the
* certificate like the others and is done once a choice is on the list: everything, or any host
* or app picked already.
*/
export function decryptStep(list: DecryptListEntry[], trusted: boolean): DecryptStep {
const choice = decryptsEverything(list) ? "everything" : "pick";
const picked = list.some((e) => !e.builtIn && !e.entry.startsWith("!"));
return { choice, state: picked ? "done" : trusted ? "next" : "todo" };
}
+4
View File
@@ -141,16 +141,20 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
}
};
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) {
+6
View File
@@ -60,6 +60,12 @@ const flow: Flow = {
grpcMessage: null,
graphqlOperation: null,
tunnelled: false,
tunnelReason: null,
tunnelExcludedBy: null,
tunnelExcludedBuiltIn: false,
tunnelRejectedBy: null,
reconnectRequested: false,
tlsRejected: null,
agentRead: false,
clientAddr: null,
elapsedConnectMs: null,
+42 -4
View File
@@ -8,8 +8,14 @@ vi.mock("@yaakapp-internal/ui", async () => {
return { selectedIdsFamily: () => atom<string[]>([]) };
});
const { activeQueryAtom, allTrafficTotalAtom, flowTotalAtom, hideSystemAtom, nonSystemTotalAtom } =
await import("./store");
const {
activeQueryAtom,
allTrafficTotalAtom,
flowTotalAtom,
hideEncryptedAtom,
hideSystemAtom,
allTrafficShownAtom,
} = await import("./store");
describe("Show in All Traffic, in System's menu", () => {
test("shows System's traffic in All traffic by default", () => {
@@ -20,13 +26,45 @@ describe("Show in All Traffic, in System's menu", () => {
test("leaves System out of All traffic and brings it back at once", () => {
const store = createStore();
store.set(nonSystemTotalAtom, 3);
store.set(allTrafficShownAtom, 3);
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
store.set(hideSystemAtom, true);
expect(store.get(activeQueryAtom)).toEqual({ query: "", hideSystem: true });
expect(store.get(activeQueryAtom)).toEqual({
query: "",
hideSystem: true,
hideEncrypted: false,
});
expect(store.get(allTrafficTotalAtom)).toBe(3);
store.set(hideSystemAtom, false);
expect(store.get(activeQueryAtom)).toBeNull();
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
});
});
describe("Show in All Traffic, in Encrypted's menu", () => {
test("shows encrypted connections in All traffic by default", () => {
expect(createStore().get(hideEncryptedAtom)).toBe(false);
});
test("leaves encrypted connections out of All traffic, with System or alone", () => {
const store = createStore();
store.set(allTrafficShownAtom, 5);
store.set(hideEncryptedAtom, true);
expect(store.get(activeQueryAtom)).toEqual({
query: "",
hideSystem: false,
hideEncrypted: true,
});
expect(store.get(allTrafficTotalAtom)).toBe(5);
store.set(hideSystemAtom, true);
expect(store.get(activeQueryAtom)).toEqual({
query: "",
hideSystem: true,
hideEncrypted: true,
});
store.set(hideEncryptedAtom, false);
store.set(hideSystemAtom, false);
expect(store.get(activeQueryAtom)).toBeNull();
expect(store.get(allTrafficTotalAtom)).toBe(store.get(flowTotalAtom));
});
});
+83
View File
@@ -0,0 +1,83 @@
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import { chooseOptions, connectionCount, type StripInput, stripState } from "./httpsStrip";
const BUILT_INS: DecryptListEntry[] = [
{ entry: "!host:*.apple.com", builtIn: true, kind: "apple" },
{ entry: "!host:*.1password.com", builtIn: true, kind: "pinned" },
];
const base: StripInput = {
list: BUILT_INS,
encrypted: 22,
dismissed: false,
trusted: false,
canTrust: true,
};
describe("the strip", () => {
test("shows while nothing is decrypted and something is encrypted", () => {
expect(stripState(base)).toBe("offer");
expect(stripState({ ...base, trusted: true })).toBe("offer");
});
test("goes once something is listed, with nothing encrypted, or dismissed", () => {
const listed = [{ entry: "app:Dia", builtIn: false }, ...BUILT_INS];
expect(stripState({ ...base, list: listed, trusted: true })).toBe("hidden");
expect(stripState({ ...base, encrypted: 0 })).toBe("hidden");
expect(stripState({ ...base, dismissed: true })).toBe("hidden");
// Exclusions alone decrypt nothing
const excluded = [{ entry: "!app:Dia", builtIn: false }, ...BUILT_INS];
expect(stripState({ ...base, list: excluded })).toBe("offer");
});
test("comes back broken, however dismissed, while something listed can't be decrypted", () => {
const listed = [{ entry: "*", builtIn: false }, ...BUILT_INS];
expect(stripState({ ...base, list: listed, dismissed: true })).toBe("broken");
// Not where the app can't trust it itself, nor before the status is read
expect(stripState({ ...base, list: listed, canTrust: false })).toBe("hidden");
expect(stripState({ ...base, list: listed, trusted: null })).toBe("hidden");
});
test("counts connections", () => {
expect(connectionCount(1)).toBe("1 connection");
expect(connectionCount(1200)).toBe("1,200 connections");
});
});
describe("Choose…", () => {
const flow = (url: string, app: Partial<Flow>, tunnelled = true) => ({
url,
tunnelled,
clientName: null,
clientBundleId: null,
clientPath: null,
...app,
});
const dia = { clientName: "Dia", clientBundleId: "company.dia", clientPath: "/A/Dia.app" };
const slack = { clientName: "Slack", clientBundleId: "com.slack", clientPath: "/A/Slack.app" };
const flows = [
flow("www.google.com:443", dia),
flow("fonts.gstatic.com:443", dia),
flow("clients2.google.com:443", dia),
flow("edgeapi.slack.com:443", slack),
// Decrypted already, so not one of the encrypted
flow("https://slack.com/api", slack, false),
// A daemon, and what a built-in keeps out, are deliberate choices elsewhere
flow("gateway.icloud.com:443", { clientName: "cloudd", clientPath: "/usr/libexec/cloudd" }),
flow("my.1password.com:443", { clientName: "1Password", clientBundleId: "com.1password" }),
];
test("lists apps and sites seen, most connections first, checked while decrypted", () => {
const list = [{ entry: "host:*.slack.com", builtIn: false }, ...BUILT_INS];
const { apps, sites } = chooseOptions(flows, list);
expect(apps.map((a) => [a.name, a.count, a.checked])).toEqual([
["Dia", 3, false],
["Slack", 1, false],
]);
expect(sites.map((s) => [s.site, s.count, s.checked])).toEqual([
["google.com", 2, false],
["gstatic.com", 1, false],
["slack.com", 1, true],
]);
});
});
+107
View File
@@ -0,0 +1,107 @@
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
import { decryptsAnything, entryMatches, isDecrypted } from "./decrypt";
import { connectionOf } from "./encryptedCard";
import { isSystemFlow, siteOf } from "./search";
/** What the strip above the list shows */
export type StripState =
/** Not shown */
| "hidden"
/** HTTPS is encrypted, with Decrypt Everything and Choose… */
| "offer"
/** The list decrypts something and the certificate isn't trusted: Trust, however dismissed */
| "broken";
export interface StripInput {
list: DecryptListEntry[];
/** Encrypted connections in the capture */
encrypted: number;
/** Its × was clicked, remembered */
dismissed: boolean;
/** The certificate is trusted, null until read */
trusted: boolean | null;
/** The app can trust the certificate itself, as on macOS */
canTrust: boolean;
}
/**
* Shows while nothing is decrypted and some HTTPS is encrypted, until dismissed; goes once
* anything is on the list, from anywhere. A list that decrypts something while the certificate
* isn't trusted is broken, and says so whatever else holds.
*/
export function stripState({
list,
encrypted,
dismissed,
trusted,
canTrust,
}: StripInput): StripState {
const listed = decryptsAnything(list);
if (listed && canTrust && trusted === false) return "broken";
if (listed || encrypted === 0 || dismissed) return "hidden";
return "offer";
}
/** "22 connections" */
export function connectionCount(n: number): string {
return `${n.toLocaleString()} ${n === 1 ? "connection" : "connections"}`;
}
export interface ChooseApp {
name: string;
bundleId: string | null;
path: string | null;
count: number;
checked: boolean;
}
export interface ChooseSite {
site: string;
count: number;
checked: boolean;
}
/** At most this many apps, and sites, in Choose… */
const CHOOSE_MAX = 8;
/**
* Choose…'s rows: the apps and sites of the encrypted connections seen, most connections first,
* each checked while the list decrypts it. System's daemons and what a built-in exclusion keeps
* out are left out, as decrypting those is deliberate, from the card or Settings.
*/
export function chooseOptions(
flows: Pick<Flow, "url" | "tunnelled" | "clientName" | "clientBundleId" | "clientPath">[],
list: DecryptListEntry[],
): { apps: ChooseApp[]; sites: ChooseSite[] } {
const builtIns = list.filter((e) => e.builtIn && e.entry.startsWith("!"));
const apps = new Map<string, ChooseApp>();
const sites = new Map<string, ChooseSite>();
for (const flow of flows) {
if (!flow.tunnelled || isSystemFlow(flow)) continue;
const c = connectionOf(flow);
const q = { host: c.host, port: c.port, app: c.app };
if (builtIns.some((e) => entryMatches(e.entry, q))) continue;
if (c.app != null) {
const app = apps.get(c.app.name) ?? {
name: c.app.name,
bundleId: c.app.bundleId,
path: flow.clientPath,
count: 0,
checked: isDecrypted(list, { kind: "app", ...c.app }),
};
app.count++;
apps.set(app.name, app);
}
const name = siteOf(c.label);
const site = sites.get(name) ?? {
site: name,
count: 0,
checked: isDecrypted(list, { kind: "site", site: name }),
};
site.count++;
sites.set(name, site);
}
const top = <T extends { count: number }>(rows: Iterable<T>) =>
[...rows].sort((a, b) => b.count - a.count).slice(0, CHOOSE_MAX);
return { apps: top(apps.values()), sites: top(sites.values()) };
}
+11 -2
View File
@@ -55,8 +55,17 @@ describe("terms", () => {
describe("activeQuery", () => {
test("is null for all traffic, and carries hiding System", () => {
expect(activeQuery(" ", false)).toBeNull();
expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true });
expect(activeQuery(" stripe ", false)).toEqual({ query: "stripe", hideSystem: false });
expect(activeQuery("", true)).toEqual({ query: "", hideSystem: true, hideEncrypted: false });
expect(activeQuery(" stripe ", false)).toEqual({
query: "stripe",
hideSystem: false,
hideEncrypted: false,
});
expect(activeQuery("", false, true)).toEqual({
query: "",
hideSystem: false,
hideEncrypted: true,
});
});
});
+10 -4
View File
@@ -3,17 +3,23 @@ import { formatField, parseQuery } from "@yaakapp-internal/query";
/**
* A search over every flow in the database, as `list_flows` takes it: the search box's text,
* which the backend parses and runs, and whether System's flows are hidden.
* which the backend parses and runs, and whether System's flows and encrypted connections are
* hidden.
*/
export interface FlowQuery {
query: string;
hideSystem: boolean;
hideEncrypted: boolean;
}
/** The search the list shows, or null for all traffic */
export function activeQuery(text: string, hideSystem: boolean): FlowQuery | null {
if (text.trim() === "" && !hideSystem) return null;
return { query: text.trim(), hideSystem };
export function activeQuery(
text: string,
hideSystem: boolean,
hideEncrypted = false,
): FlowQuery | null {
if (text.trim() === "" && !hideSystem && !hideEncrypted) return null;
return { query: text.trim(), hideSystem, hideEncrypted };
}
/** `@field:value`, or `-@field:value`, the value quoted when it needs to be */
+12
View File
@@ -67,6 +67,18 @@ describe("proxySettingsView", () => {
]);
expect(view.app.name).toBe("Yaak Proxy");
});
test("shows Updates in a build with the updater, without Yaak's notifications row", () => {
const view = proxySettingsView(DEFAULT_SETTINGS_VALUES, APP, "light", { checking: true });
expect(view.updates).toEqual({ checking: true, notificationsSetting: false });
});
test("follows the beta channel until set otherwise", () => {
expect(DEFAULT_SETTINGS_VALUES.updateChannel).toBe("beta");
expect(readSettingsValues(JSON.stringify({ updateChannel: "stable" })).updateChannel).toBe(
"stable",
);
});
});
describe("size options", () => {
+9 -4
View File
@@ -4,6 +4,7 @@ import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme/src
import type {
SettingsAppInfo,
SettingsThemeOption,
SettingsUpdatesView,
SettingsValues,
SettingsView,
} from "@yaakapp-internal/ui";
@@ -22,7 +23,8 @@ export const DEFAULT_SETTINGS_VALUES: SettingsValues = {
coloredMethods: false,
useNativeTitlebar: false,
hideWindowControls: false,
updateChannel: "stable",
// Early access ships on Yaak's beta tags, so the proxy follows beta until it goes stable
updateChannel: "beta",
autoupdate: true,
autoDownloadUpdates: true,
checkNotifications: true,
@@ -70,13 +72,15 @@ export function resolveAppearance(appearance: string, system: "light" | "dark"):
/**
* The proxy's settings as the shared tabs render them. The proxy has Yaak's two themes, no
* installed-font list, updater or feedback prompts yet, and its license comes with the license
* work, so those parts are left out, as are the Interface rows it doesn't act on.
* installed-font list or feedback prompts yet, and its license comes with the license work, so
* those parts are left out, as are the Interface rows it doesn't act on. `updates` is null in
* a build without the updater, which leaves its section out too.
*/
export function proxySettingsView(
values: SettingsValues,
app: SettingsAppInfo,
systemAppearance: "light" | "dark",
updates: SettingsUpdatesView | null = null,
): SettingsView {
const light = THEMES.find((t) => t.id === values.themeLight && !t.dark) ?? lightOption();
const dark = THEMES.find((t) => t.id === values.themeDark && t.dark) ?? darkOption();
@@ -93,7 +97,8 @@ export function proxySettingsView(
},
fonts: null,
windowControlsSetting: false,
updates: null,
// The proxy fetches no notifications, so its Updates section has no row for them
updates: updates && { ...updates, notificationsSetting: false },
feedbackPrompts: false,
license: null,
// Not acted on in the proxy yet: its editors take no keymap or wrap setting, its list draws
+14 -7
View File
@@ -215,13 +215,20 @@ describe("decrypting from the sidebar", () => {
const root = buildSidebarTree([...flows, tunnel], 5);
const items = itemsById(root);
test("Not decrypted follows All traffic while there are tunnelled flows", () => {
expect((root.children ?? []).slice(0, 2).map((c) => shape(c)[0])).toEqual([
"All traffic 5",
"Not decrypted 1",
]);
expect(items.get("tunnelled")?.query).toBe("@is:tunnelled");
expect(itemsById(buildSidebarTree(flows, 4)).has("tunnelled")).toBe(false);
test("Encrypted comes last, after System, while there are encrypted connections", () => {
expect((root.children ?? []).map((c) => shape(c)[0]).at(-1)).toBe("Encrypted 1");
expect(items.get("encrypted")?.query).toBe("@is:encrypted");
expect(itemsById(buildSidebarTree(flows, 4)).has("encrypted")).toBe(false);
});
test("marks what is all encrypted, once something is decrypted", () => {
expect(items.get("host:bank.example.com")?.encryptedOnly).toBe(true);
expect(items.get("site:example.com")?.encryptedOnly).toBe(true);
// Slack has decrypted flows too
expect(items.get("app:Slack")?.encryptedOnly).toBeUndefined();
// In the first minute, when every row would be marked, none is
const onlyTunnels = itemsById(buildSidebarTree([tunnel], 1));
expect(onlyTunnels.get("host:bank.example.com")?.encryptedOnly).toBeUndefined();
});
test("hosts, sites and apps name what their menu's Decrypt covers", () => {
+49 -26
View File
@@ -14,7 +14,7 @@ import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf
export type SidebarKind =
| "all"
| "tunnelled"
| "encrypted"
| "saved"
| "group"
| "app"
@@ -48,6 +48,11 @@ export interface SidebarItem {
value?: string;
/** What Decrypt in the row's menu decrypts: its host, site or app */
decrypt?: DecryptSubject;
/**
* Every flow under the row is encrypted, marked once something in the capture is decrypted,
* as in the first minute every row would be
*/
encryptedOnly?: boolean;
/** The saved search a Saved row is, for renaming and deleting it */
savedId?: string;
/**
@@ -71,7 +76,7 @@ export const SAVED_ID = "saved";
export const APPS_ID = "apps";
export const DOMAINS_ID = "domains";
export const SYSTEM_ID = "system";
export const TUNNELLED_ID = "tunnelled";
export const ENCRYPTED_ID = "encrypted";
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
interface PlaceRow {
@@ -196,12 +201,14 @@ function programPath(flow: Flow): string | undefined {
interface Trie {
item: SidebarItem;
children: Map<string, Trie>;
/** Encrypted flows under it */
encrypted?: number;
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** Counts a flow under `parent`'s child `row`, making the child the first time */
function child(parent: Trie, row: PlaceRow): Trie {
function child(parent: Trie, row: PlaceRow, encrypted: boolean): Trie {
let node = parent.children.get(row.id);
if (node == null) {
node = {
@@ -224,13 +231,25 @@ function child(parent: Trie, row: PlaceRow): Trie {
delete node.item.path;
}
node.item.count++;
if (encrypted) node.encrypted = (node.encrypted ?? 0) + 1;
return node;
}
/** Adds a flow under `parent` by where it went: its site, then its host, then its path */
function addPlace(parent: Trie, rows: PlaceRow[]) {
function addPlace(parent: Trie, rows: PlaceRow[], encrypted: boolean) {
let node = parent;
for (const row of rows) node = child(node, row);
for (const row of rows) node = child(node, row, encrypted);
}
/** Marks the apps, sites and hosts whose every flow is encrypted */
function markEncrypted(trie: Trie) {
for (const c of trie.children.values()) {
const kind = c.item.kind;
if ((kind === "app" || kind === "site" || kind === "host") && c.encrypted === c.item.count) {
c.item.encryptedOnly = true;
}
markEncrypted(c);
}
}
function toNodes(
@@ -264,9 +283,8 @@ function group(id: string, label: string): Trie {
/**
* The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then
* Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with
* nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through
* undecrypted, when there is any, and Saved lists the saved searches, each with its count from
* the database. `total` is All traffic's count: every flow it covers in the database.
* nothing in it is left out. Encrypted, last, gathers the HTTPS passed through undecrypted,
* when there is any, and Saved lists the saved searches, each with its count from the database. `total` is All traffic's count: every flow it covers in the database.
*/
export function buildSidebarTree(
flows: Flow[],
@@ -284,12 +302,12 @@ export function buildSidebarTree(
const domains = group(DOMAINS_ID, "Domains");
const system = { ...group(SYSTEM_ID, "System") };
system.item.query = fieldTerm("is", "system");
let tunnelled = 0;
let encrypted = 0;
for (const flow of flows) {
const place = placeOf(flow);
if (flow.tunnelled) tunnelled++;
addPlace(domains, place.rows);
if (flow.tunnelled) encrypted++;
addPlace(domains, place.rows, flow.tunnelled);
if (place.app == null) continue;
const parent = place.system ? system : apps;
const id = `app:${place.app}`;
@@ -318,8 +336,13 @@ export function buildSidebarTree(
delete app.item.path;
}
app.item.count++;
if (flow.tunnelled) app.encrypted = (app.encrypted ?? 0) + 1;
parent.item.count++;
addPlace(app, place.appRows);
addPlace(app, place.appRows, flow.tunnelled);
}
// Once something is decrypted: a refused handshake isn't, so it doesn't count
if (encrypted > 0 && flows.some((f) => !f.tunnelled && f.tlsRejected == null)) {
for (const g of [apps, domains, system]) markEncrypted(g);
}
for (const site of domains.children.values()) domains.item.count += site.children.size;
@@ -329,20 +352,6 @@ export function buildSidebarTree(
depth: 1,
draggable: false,
});
if (tunnelled > 0) {
root.children?.push({
item: {
id: TUNNELLED_ID,
kind: "tunnelled",
label: "Not decrypted",
count: tunnelled,
query: fieldTerm("is", "tunnelled"),
},
parent: root,
depth: 1,
draggable: false,
});
}
if (saved.length > 0) {
const node: TreeNode<SidebarItem> = {
item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" },
@@ -378,6 +387,20 @@ export function buildSidebarTree(
node.children = toNodes(g.children.values(), node);
root.children?.push(node);
}
if (encrypted > 0) {
root.children?.push({
item: {
id: ENCRYPTED_ID,
kind: "encrypted",
label: "Encrypted",
count: encrypted,
query: fieldTerm("is", "encrypted"),
},
parent: root,
depth: 1,
draggable: false,
});
}
return root;
}
+32 -13
View File
@@ -81,9 +81,14 @@ export const hideSystemAtom = atomWithStorage("sidebar_hide_system", false, unde
getOnInit: true,
});
/** Encrypted connections are left out of All traffic, by Show in All Traffic in Encrypted's menu */
export const hideEncryptedAtom = atomWithStorage("sidebar_hide_encrypted", false, undefined, {
getOnInit: true,
});
/** The search the list shows, from the filter applied, or null for all traffic */
export const activeQueryAtom = atom<FlowQuery | null>((get) =>
activeQuery(get(appliedFilterAtom), get(hideSystemAtom)),
activeQuery(get(appliedFilterAtom), get(hideSystemAtom), get(hideEncryptedAtom)),
);
/** The saved searches, in their order */
@@ -103,13 +108,16 @@ const savedRowsAtom = atom<SavedRow[]>((get) => {
}));
});
/** Flows All traffic covers in the database: every one, or all but System's while hidden */
/**
* Flows All traffic covers in the database: every one, or all but System's and the encrypted
* connections while either is hidden
*/
export const allTrafficTotalAtom = atom((get) =>
get(hideSystemAtom) ? get(nonSystemTotalAtom) : get(flowTotalAtom),
get(hideSystemAtom) || get(hideEncryptedAtom) ? get(allTrafficShownAtom) : get(flowTotalAtom),
);
/** Flows in the database not from System, counted while System is hidden */
export const nonSystemTotalAtom = atom(0);
/** Flows in the database All traffic shows, counted while System or Encrypted is hidden */
export const allTrafficShownAtom = atom(0);
/**
* The flows the sidebar counts: the window's, taken at most once a second, so a burst of traffic
@@ -450,9 +458,11 @@ export function syncFlows(store: Store) {
win.total === res.total ? win : { ...win, total: res.total },
);
});
if (store.get(hideSystemAtom)) {
void rpc("list_flows", { hideSystem: true, limit: 0 }).then((res) => {
if (thisLoad === loads) store.set(nonSystemTotalAtom, res.matching ?? res.total);
const hideSystem = store.get(hideSystemAtom);
const hideEncrypted = store.get(hideEncryptedAtom);
if (hideSystem || hideEncrypted) {
void rpc("list_flows", { hideSystem, hideEncrypted, limit: 0 }).then((res) => {
if (thisLoad === loads) store.set(allTrafficShownAtom, res.matching ?? res.total);
});
}
countSaved();
@@ -485,7 +495,10 @@ export function syncFlows(store: Store) {
return;
}
counting = true;
rpc("count_saved_queries", { hideSystem: store.get(hideSystemAtom) })
rpc("count_saved_queries", {
hideSystem: store.get(hideSystemAtom),
hideEncrypted: store.get(hideEncryptedAtom),
})
.then((counts) =>
store.set(savedCountsAtom, Object.fromEntries(counts.map((c) => [c.id, c.count]))),
)
@@ -643,9 +656,12 @@ export function syncFlows(store: Store) {
// Read again, it keeps showing its rows until the new ones are in.
const win = (keepRows ? store.get(searchWindowAtom)?.win : null) ?? emptyWindow;
store.set(searchWindowAtom, { key, query, win, loading: true, buffered: [] });
void rpc("watch_query", { key, query: query.query, hideSystem: query.hideSystem }).catch(
(err) => console.warn("Failed to watch the search", err),
);
void rpc("watch_query", {
key,
query: query.query,
hideSystem: query.hideSystem,
hideEncrypted: query.hideEncrypted,
}).catch((err) => console.warn("Failed to watch the search", err));
const settle = (page: FlowWindow | null) =>
store.set(searchWindowAtom, (s) => {
if (s?.key !== key) return s;
@@ -693,6 +709,9 @@ export function syncFlows(store: Store) {
store.sub(hideSystemAtom, () => {
if (loaded) recount();
});
store.sub(hideEncryptedAtom, () => {
if (loaded) recount();
});
// A search counting back from now, `@time:<5m`, is read again while it is shown and the list
// follows new traffic, so flows leave it and join it as they age. Saved ones are counted
@@ -772,7 +791,7 @@ let reloadFlows: (() => void) | null = null;
/** Empties the list, the search's too, and their counts, as a clear leaves them */
const emptyFlowsAtom = atom(null, (_get, set) => {
set(flowWindowAtom, emptyWindow);
set(nonSystemTotalAtom, 0);
set(allTrafficShownAtom, 0);
set(searchWindowAtom, (s) => (s == null ? s : { ...s, win: emptyWindow, buffered: [] }));
set(sortedWindowAtom, (s) =>
s == null ? s : { ...s, flows: [], boundary: null, cursor: null, buffered: [] },
+13
View File
@@ -24,6 +24,19 @@ export function subscribe<T>(event: string, callback: (payload: T) => void): ()
return subscription.unsubscribe;
}
/**
* Subscribe to a Tauri event sent to this window, such as the update prompt, which belongs to
* the window that asked. Returns an unsubscribe function, as `subscribe` does.
*/
export function subscribeHere<T>(event: string, callback: (payload: T) => void): () => void {
const subscription = unsubscribeWhenReady(
getCurrentWebviewWindow().listen<T>(event, (e) => {
if (subscription.active()) callback(e.payload);
}),
);
return subscription.unsubscribe;
}
/** Calls `callback` each time the window regains focus. Returns an unsubscribe function. */
export function onWindowFocus(callback: () => void): () => void {
const subscription = unsubscribeWhenReady(
+87
View File
@@ -0,0 +1,87 @@
import { toastsAtom } from "@yaakapp-internal/ui/toast";
import { atom, createStore } from "jotai";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { DEFAULT_SETTINGS_VALUES } from "./settings";
const command = vi.fn<(cmd: string, args?: Record<string, unknown>) => Promise<unknown>>();
vi.mock("./tauri", () => ({
command: (cmd: string, args?: Record<string, unknown>) => command(cmd, args),
broadcast: vi.fn(),
onWindowFocus: vi.fn(),
subscribeHere: vi.fn(),
}));
vi.mock("./opener", () => ({ openLink: vi.fn() }));
// Outside the app there's no Tauri for the shared components' platform
vi.mock("@yaakapp-internal/platform", () => ({ platform: { osType: () => "macos" } }));
vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "2026.11.0" }));
// The real atom reads local storage as it loads, which tests have none of
vi.mock("./interfaceSettings", () => ({ settingsValuesAtom: atom(DEFAULT_SETTINGS_VALUES) }));
const { checkArgs, checkForUpdatesNow, updateCheckingAtom } = await import("./updates");
async function messages(store: ReturnType<typeof createStore>) {
// Toasts are added on the next tick
await new Promise((resolve) => setTimeout(resolve, 0));
return store.get(toastsAtom).map((t) => ({ color: t.color, message: t.message }));
}
describe("checkArgs", () => {
test("sends the channel and download choice the settings make", () => {
expect(checkArgs(DEFAULT_SETTINGS_VALUES, false)).toEqual({
channel: "beta",
background: false,
autoDownload: true,
});
expect(
checkArgs(
{ ...DEFAULT_SETTINGS_VALUES, updateChannel: "stable", autoDownloadUpdates: false },
true,
),
).toEqual({ channel: "stable", background: true, autoDownload: false });
});
});
describe("checkForUpdatesNow", () => {
beforeEach(() => {
command.mockReset();
});
test("says when there is nothing newer", async () => {
command.mockResolvedValue(false);
const store = createStore();
await checkForUpdatesNow(store);
expect(command).toHaveBeenCalledWith("cmd_check_for_updates", {
channel: "beta",
background: false,
autoDownload: true,
});
expect(await messages(store)).toEqual([
{
color: "info",
message: "No update available. You are on the latest version, 2026.11.0.",
},
]);
expect(store.get(updateCheckingAtom)).toBe(false);
});
test("leaves an update found to the prompt the backend sends", async () => {
command.mockResolvedValue(true);
const store = createStore();
await checkForUpdatesNow(store);
expect(await messages(store)).toEqual([]);
});
test("says, in a toast rather than a dialog, when the server can't be reached", async () => {
command.mockRejectedValue("Updater error: error sending request");
const store = createStore();
await checkForUpdatesNow(store);
expect(await messages(store)).toEqual([
{
color: "danger",
message: "Could not check for updates: Updater error: error sending request",
},
]);
expect(store.get(updateCheckingAtom)).toBe(false);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { getVersion } from "@tauri-apps/api/app";
import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater";
import {
minPromiseMillis,
type SettingsValues,
showToast,
showUpdateAvailableToast,
showUpdateInstalledToast,
showUpdateStateToast,
} from "@yaakapp-internal/ui";
import { atom, type createStore } from "jotai";
import { settingsValuesAtom } from "./interfaceSettings";
import { openLink } from "./opener";
import { broadcast, command, onWindowFocus, subscribeHere } from "./tauri";
type Store = ReturnType<typeof createStore>;
const APP_NAME = "Yaak Proxy";
/** The events the updater sends the window that checked, as Yaak's are named */
const UPDATE_AVAILABLE_EVENT = "update_available";
const UPDATE_INSTALLED_EVENT = "update_installed";
/** Where an update already in hand stands, for a check asked for meanwhile */
const UPDATE_STATE_EVENT = "update_state";
/** The app menu's Check for Updates…, sent to the window it was chosen in */
const CHECK_FOR_UPDATES_EVENT = "menu_check_for_updates";
/** How long after launch, or the window coming forward, a background check waits, as Yaak's */
const BACKGROUND_CHECK_DELAY_MS = 3000;
/** A check the user asked for is running, for the Settings button's spinner */
export const updateCheckingAtom = atom(false);
/** Whether this build has the updater; development builds don't */
export function updaterEnabled(): Promise<boolean> {
return command<boolean>("cmd_updater_enabled");
}
/** What a check sends the backend: the channel and behavior the settings choose */
export function checkArgs(values: SettingsValues, background: boolean) {
return {
channel: values.updateChannel,
background,
autoDownload: values.autoDownloadUpdates,
};
}
/**
* Checks now, as the user asked, and says so when there is nothing newer or the server can't
* be reached. An update found is offered by the prompt the backend sends this window.
*/
export async function checkForUpdatesNow(store: Store): Promise<void> {
if (store.get(updateCheckingAtom)) return;
store.set(updateCheckingAtom, true);
try {
const args = checkArgs(store.get(settingsValuesAtom), false);
const found = await minPromiseMillis(command<boolean>("cmd_check_for_updates", args), 500);
if (!found) {
const version = await getVersion().catch(() => null);
showToast(store, {
id: "no-updates",
color: "info",
message:
version == null
? "No update available. You are on the latest version."
: `No update available. You are on the latest version, ${version}.`,
});
}
} catch (err) {
showToast(store, {
id: "no-updates",
color: "danger",
message: `Could not check for updates: ${String(err)}`,
});
} finally {
store.set(updateCheckingAtom, false);
}
}
/** A quiet check, as the settings allow; the backend keeps to the channel's interval */
function checkInBackground(store: Store) {
const values = store.get(settingsValuesAtom);
if (!values.autoupdate) return;
command<boolean>("cmd_check_for_updates", checkArgs(values, true)).catch((err) =>
console.warn("Failed to check for updates", err),
);
}
/** Offers an update the backend found, as Yaak does, and answers it */
async function offerUpdate(store: Store, info: UpdateInfo) {
const { replyEventId } = info;
const reply = (response: UpdateResponse) => broadcast(replyEventId, response);
// Acknowledged at once, so the backend doesn't fall back to its native dialog
if (info.install === "integrated") await reply({ type: "ack" });
showUpdateAvailableToast(store, {
appName: APP_NAME,
update: info,
onOpenUrl: (url) => openLink(url, (message) => showToast(store, { message, color: "danger" })),
onInstall: () => reply({ type: "action", action: "install" }),
});
}
/**
* Hears the updater in this window: the prompt for an update it found, the offer to relaunch
* once one installed, and the menu's Check for Updates…. With `background`, as the main window
* does, also checks shortly after launch and each time the window comes forward.
*/
export async function syncUpdates(store: Store, { background }: { background: boolean }) {
if (!(await updaterEnabled().catch(() => false))) return;
subscribeHere<UpdateInfo>(UPDATE_AVAILABLE_EVENT, (info) => {
offerUpdate(store, info).catch(console.error);
});
subscribeHere<UpdateState>(UPDATE_STATE_EVENT, (state) => {
showUpdateStateToast(store, { appName: APP_NAME, state });
});
subscribeHere<string>(UPDATE_INSTALLED_EVENT, (version) => {
showUpdateInstalledToast(store, {
appName: APP_NAME,
version,
onRelaunch: () => {
command<void>("cmd_restart").catch(console.error);
},
});
});
subscribeHere<void>(CHECK_FOR_UPDATES_EVENT, () => {
checkForUpdatesNow(store).catch(console.error);
});
if (background) {
const later = () => setTimeout(() => checkInBackground(store), BACKGROUND_CHECK_DELAY_MS);
later();
onWindowFocus(later);
}
}
+3 -3
View File
@@ -33,9 +33,9 @@ describe("windowSync", () => {
});
test("a change is announced as coming from this window", () => {
tauri.label = "settings";
tauri.label = "other__settings";
announceEngineChange();
expect(tauri.sent).toEqual([{ event: "window_changed_engine", payload: { from: "settings" } }]);
expect(tauri.sent).toEqual([{ event: "window_changed_engine", payload: { from: "other__settings" } }]);
});
test("a change in another window reads the state and the settings' queries again", () => {
@@ -44,7 +44,7 @@ describe("windowSync", () => {
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
syncFromOtherWindows(store, queryClient);
tauri.handlers.get("window_changed_engine")?.({ from: "settings" });
tauri.handlers.get("window_changed_engine")?.({ from: "other__settings" });
expect(refreshProxyState).toHaveBeenCalledWith(store);
expect(invalidate).toHaveBeenCalledTimes(1);
+2 -1
View File
@@ -3,7 +3,7 @@ import { openSettingsVia, requestedSettingsTab, windowKind } from "./windows";
describe("windowKind", () => {
test("the Settings window holds the settings", () => {
expect(windowKind("settings")).toBe("settings");
expect(windowKind("other__settings")).toBe("settings");
});
test("a main window holds the app", () => {
@@ -13,6 +13,7 @@ describe("windowKind", () => {
test("a page outside the app's windows holds the app on its own", () => {
expect(windowKind("harness")).toBe("page");
expect(windowKind("settings")).toBe("page");
expect(windowKind("settings_0")).toBe("page");
expect(windowKind("")).toBe("page");
});
+5 -2
View File
@@ -1,5 +1,8 @@
/** The Settings window's label, which `cmd_open_settings` gives it */
export const SETTINGS_WINDOW_LABEL = "settings";
/**
* The Settings window's label, which `cmd_open_settings` gives it: opened as a child of the
* main window, as Yaak's, it takes the prefix the shared window code gives child windows
*/
export const SETTINGS_WINDOW_LABEL = "other__settings";
/** The event that moves an open Settings window to another tab */
export const SETTINGS_TAB_EVENT = "settings_tab";
+3
View File
@@ -11,6 +11,7 @@ import { syncSettingsValues } from "./lib/interfaceSettings";
import { syncNavigation, syncSidebarSelection } from "./lib/navigation";
import { syncFlows, syncProxyState } from "./lib/store";
import { currentWindowLabel } from "./lib/tauri";
import { syncUpdates } from "./lib/updates";
import { syncFromOtherWindows } from "./lib/windowSync";
import { windowKind } from "./lib/windows";
import "./main.css";
@@ -22,6 +23,8 @@ const settingsWindow = windowKind(currentWindowLabel()) === "settings";
syncProxyState(jotaiStore);
syncSettingsValues(jotaiStore);
// Either window shows the updates it checks for; the app's window also checks in the background
syncUpdates(jotaiStore, { background: !settingsWindow }).catch(console.error);
syncFromOtherWindows(jotaiStore, queryClient);
// Only the app's window lists traffic and navigates it
if (!settingsWindow) {
+2
View File
@@ -87,7 +87,9 @@ protoc-bin-vendored = "3"
tonic = { version = "0.14.6", default-features = false }
tonic-reflection = "0.14.6"
reqwest = { workspace = true }
rustls = { workspace = true, features = ["ring"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] }
tokio-rustls = "0.26"
yaak-crypto = { workspace = true, features = ["test-support"] }
yaak-proxy-helper = { workspace = true, features = ["test-support"] }
+36
View File
@@ -147,6 +147,37 @@ graphqlOperation: string | null,
* the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set.
*/
tunnelled: boolean,
/**
* Why a tunnelled flow was not decrypted. None for other flows, and for tunnels recorded
* before the reason was.
*/
tunnelReason: TunnelReason | null,
/**
* For a tunnel the decrypt list excluded, the exclusion that kept it out, as stored:
* `!host:*.apple.com`.
*/
tunnelExcludedBy: string | null,
/**
* That exclusion is one of the built-in ones.
*/
tunnelExcludedBuiltIn: boolean,
/**
* For a tunnel passed through after a client rejected the proxy's certificate, the id of
* the flow of that refused handshake, when it was recorded.
*/
tunnelRejectedBy: string | null,
/**
* The proxy closed this tunnel on request, so its client would reconnect.
*/
reconnectRequested: boolean,
/**
* A client's refused TLS handshake, recorded as its host and port began to be passed
* through: how it refused, its alert (`unknown_ca`, `bad_certificate`,
* `certificate_unknown`, ...) or `hang_up`. Such a flow has no method, its URL is
* `https://<authority>`, and its error says it rejected the certificate. A handshake that
* failed otherwise is recorded the same way with this None and the failure as its error.
*/
tlsRejected: string | null,
/**
* An agent read the flow through the control API: its detail, a body or its messages.
* Never written by an upsert, which keeps what is stored.
@@ -291,3 +322,8 @@ key: string, matches: boolean, };
* The streaming protocol a flow carries.
*/
export type StreamKind = "websocket" | "sse" | "grpc" | "grpc_web";
/**
* Why a tunnel was passed through undecrypted.
*/
export type TunnelReason = "not_listed" | "excluded" | "rejected";
+147 -6
View File
@@ -1,5 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FilterHistoryEntry, Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery } from "./gen_models";
import type { FilterHistoryEntry, Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery, TunnelReason } from "./gen_models";
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
@@ -35,6 +35,23 @@ export type CaStatusRequest = Record<string, never>;
export type CaTrustRequest = Record<string, never>;
/**
* What came of asking to trust the CA, with its status after.
*/
export type CaTrustResult = {
/**
* The person cancelled the password prompt, so nothing changed. Not an error.
*/
cancelled: boolean,
/**
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
*/
trusted: boolean,
/**
* A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
*/
installed: boolean, certPath: string, };
export type ClearFilterHistoryRequest = Record<string, never>;
export type ClearFlowsRequest = Record<string, never>;
@@ -60,7 +77,7 @@ path: string,
*/
cwd: string | null, };
export type CountSavedQueriesRequest = { hideSystem?: boolean, };
export type CountSavedQueriesRequest = { hideSystem?: boolean, hideEncrypted?: boolean, };
export type CreateSavedQueryRequest = { name: string, query: string, };
@@ -115,7 +132,22 @@ entry: string,
/**
* Shipped with the app: an exclusion of Apple's services or a pinned app's hosts.
*/
builtIn: boolean, };
builtIn: boolean,
/**
* Why a built-in exclusion ships. None for the user's own entries.
*/
kind?: ExclusionKind, };
/**
* The decrypt list as saved, and what it means for connections already open.
*/
export type DecryptListSaved = {
/**
* Open connections the list now decrypts that stay encrypted until their clients
* reconnect: `reconnect_tunnels` closes them so they do. Closed by nothing else. Zero from
* an owner too old to count them.
*/
stillEncrypted: number, entries: Array<DecryptListEntry>, };
export type DeleteFilterHistoryRequest = { filter: string, };
@@ -131,6 +163,11 @@ export type DroppedHeader = { name: string, value: string, reason: DropReason, }
*/
export type EditDecryptListRequest = { add: Array<string>, remove: Array<string>, };
/**
* Why a built-in exclusion ships.
*/
export type ExclusionKind = "apple" | "pinned";
export type ExportHarRequest = {
/**
* The file to write.
@@ -171,6 +208,11 @@ query?: string,
* `@is:system` itself.
*/
hideSystem?: boolean,
/**
* Leaves out HTTPS connections passed through undecrypted, as hiding Encrypted does,
* unless the query says `@is:encrypted` itself.
*/
hideEncrypted?: boolean,
/**
* At most this many flows, the newest.
*/
@@ -216,6 +258,22 @@ export type FolderSummary = { id: string, name: string,
*/
folderId: string | null, };
/**
* An address to intercept again: `host`, `host:port` or `[::1]:8443`, as a flow's URL has
* it, the port 443 when left out.
*/
export type ForgetPassthroughRequest = { address: string, };
export type ForgetPassthroughResponse = {
/**
* It was passed through.
*/
forgotten: boolean,
/**
* The addresses still passed through.
*/
addresses: Array<string>, };
export type FormatBodyRequest = { text: string, };
export type GetDecryptListRequest = Record<string, never>;
@@ -391,6 +449,8 @@ messages: Array<FlowMessage>,
*/
total: number, };
export type ListPassthroughRequest = Record<string, never>;
export type ListSavedQueriesRequest = Record<string, never>;
export type ListSeenRequest = Record<string, never>;
@@ -419,6 +479,28 @@ export type OpenTerminalRequest = {
*/
vars: Array<[string, string]>, };
/**
* An open tunnel, as the app is told of it.
*/
export type OpenTunnel = {
/**
* Its id for closing it, unique to this run of the engine, so one listed before the
* engine restarted closes nothing after.
*/
id: string,
/**
* The host it leads to, without an IPv6 address's brackets.
*/
host: string, port: number,
/**
* The app that opened it, when known.
*/
appName: string | null, appBundleId: string | null,
/**
* Its flow, when it was recorded.
*/
flowId: string | null, reason: TunnelReason, };
export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
/**
* The engine's port while it runs.
@@ -430,6 +512,25 @@ proxyPort: number | null, };
*/
export type Owner = "app" | "cli";
/**
* The addresses passed through after clients rejected the CA changed: one was added or
* forgotten.
*/
export type PassthroughChangedPayload = {
/**
* Each `host:port`, an IPv6 host in brackets, sorted.
*/
addresses: Array<string>, };
/**
* The addresses passed through after clients rejected the CA.
*/
export type PassthroughResponse = {
/**
* Each `host:port`, an IPv6 host in brackets, sorted.
*/
addresses: Array<string>, };
export type PreviewSaveRequest = { flowId: string, };
/**
@@ -476,6 +577,31 @@ export type QueryValuesRequest = {
*/
path: string, };
/**
* Tunnels to close: those in `ids`, or, without them, every one `list_reconnectable_tunnels`
* returns for the same `entry` and `flowId`.
*/
export type ReconnectTunnelsRequest = {
/**
* `OpenTunnel` ids. Ones from an earlier run of the engine close nothing.
*/
ids?: Array<string>,
/**
* An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
* names. An exclusion, `!host:*.chase.com`, names those it would keep out.
*/
entry?: string,
/**
* Only the tunnel this flow records.
*/
flowId?: string, };
export type ReconnectTunnelsResponse = {
/**
* Tunnels closed: those still open of the ones asked for.
*/
closed: number, };
/**
* A filter applied from the search box, or from the history itself, put at its top.
*/
@@ -494,9 +620,9 @@ export type RenameSavedQueryRequest = { id: string, name: string, };
export type ResetCapturedDataRequest = Record<string, never>;
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, };
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, passthrough_changed: PassthroughChangedPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaTrustResult], forget_passthrough: [ForgetPassthroughRequest, ForgetPassthroughResponse], list_passthrough: [ListPassthroughRequest, PassthroughResponse], list_reconnectable_tunnels: [TunnelScope, Array<OpenTunnel>], reconnect_tunnels: [ReconnectTunnelsRequest, ReconnectTunnelsResponse], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptListSaved], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptListSaved], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
/**
@@ -672,12 +798,27 @@ export type TakeOverRequest = Record<string, never>;
*/
export type TrafficRetention = "clearOnLaunch" | "keep";
/**
* Which open tunnels to consider: all of them, those one decrypt-list entry names, or the one
* a flow records.
*/
export type TunnelScope = {
/**
* An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
* names. An exclusion, `!host:*.chase.com`, names those it would keep out.
*/
entry?: string,
/**
* Only the tunnel this flow records.
*/
flowId?: string, };
export type ValidateQueryRequest = { query: string, };
/**
* The search the app shows, which flows written from now on are matched against, their events
* naming `key`. No query stops it.
*/
export type WatchQueryRequest = { key: string, query?: string, hideSystem?: boolean, };
export type WatchQueryRequest = { key: string, query?: string, hideSystem?: boolean, hideEncrypted?: boolean, };
export type WorkspaceSummary = { id: string, name: string, folders: Array<FolderSummary>, };
@@ -0,0 +1,14 @@
-- Why an HTTPS connection was passed through undecrypted: `not_listed`, `excluded` (with the
-- exclusion that kept it out, and whether that one is built in) or `rejected` (with the flow of
-- the refused handshake that started passing its address through, when that was recorded).
ALTER TABLE flows ADD COLUMN tunnel_reason TEXT;
ALTER TABLE flows ADD COLUMN tunnel_excluded_by TEXT;
ALTER TABLE flows ADD COLUMN tunnel_excluded_built_in BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE flows ADD COLUMN tunnel_rejected_by TEXT;
-- A tunnel the proxy closed on request so its client reconnects.
ALTER TABLE flows ADD COLUMN reconnect_requested BOOLEAN NOT NULL DEFAULT FALSE;
-- A client's refused TLS handshake with the proxy, recorded once as its address is passed
-- through: the client's alert (`unknown_ca`, `bad_certificate`, ...) or `hang_up`.
ALTER TABLE flows ADD COLUMN tls_rejected TEXT;
+50 -4
View File
@@ -13,6 +13,25 @@ pub struct CaStatus {
pub cert_path: String,
}
/// What came of asking to trust the CA, with its status after.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CaTrustResult {
#[serde(flatten)]
pub status: CaStatus,
/// The person cancelled the password prompt, so nothing changed. Not an error.
pub cancelled: bool,
}
/// What came of [`trust`] when it did not fail.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trusted {
Yes,
/// The person cancelled the password prompt.
Cancelled,
}
#[cfg(target_os = "macos")]
const CA_NAME: &str = "Yaak Proxy CA";
#[cfg(target_os = "macos")]
@@ -30,9 +49,9 @@ pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
}
/// Adds the CA to the login keychain as a trusted root. macOS asks for the user's password;
/// the app never elevates.
/// the app never elevates. Cancelling that prompt is not a failure.
#[cfg(target_os = "macos")]
pub async fn trust(cert_path: &Path) -> Result<(), String> {
pub async fn trust(cert_path: &Path) -> Result<Trusted, String> {
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
let keychain = Path::new(&home).join("Library/Keychains/login.keychain-db");
let output = security(&[
@@ -45,12 +64,25 @@ pub async fn trust(cert_path: &Path) -> Result<(), String> {
])
.await?;
if output.status.success() {
return Ok(());
return Ok(Trusted::Yes);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if is_cancelled(&stderr) {
return Ok(Trusted::Cancelled);
}
Err(if stderr.is_empty() { format!("security exited with {}", output.status) } else { stderr })
}
/// `security`'s error for a cancelled authorization prompt: "The authorization was canceled by
/// the user." (`errAuthorizationCanceled`, -128).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn is_cancelled(stderr: &str) -> bool {
let stderr = stderr.to_lowercase();
stderr.contains("canceled by the user")
|| stderr.contains("cancelled by the user")
|| stderr.contains("errauthorizationcanceled")
}
#[cfg(target_os = "macos")]
async fn security(args: &[&str]) -> Result<std::process::Output, String> {
tokio::process::Command::new(SECURITY)
@@ -70,6 +102,20 @@ pub async fn status(cert_path: &Path) -> Result<CaStatus, String> {
}
#[cfg(not(target_os = "macos"))]
pub async fn trust(_cert_path: &Path) -> Result<(), String> {
pub async fn trust(_cert_path: &Path) -> Result<Trusted, String> {
Err("Trusting the certificate from the app is only supported on macOS".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cancelled_prompt_is_told_apart() {
let cancelled =
"SecTrustSettingsSetTrustSettings: The authorization was canceled by the user.";
assert!(is_cancelled(cancelled));
assert!(!is_cancelled("SecCertificateCreateFromData: Unknown format in import."));
assert!(!is_cancelled(""));
}
}
+47 -4
View File
@@ -3,7 +3,7 @@
//! and `blobs.sqlite` read-only so captured traffic stays reachable.
use crate::bodies::{self, DecodedBody};
use crate::ca::CaStatus;
use crate::ca::CaTrustResult;
use crate::control::{
self, ControlInfo, ControlStatus, DecryptRequest, ErrorBody, NOTE_HEADER, RecordingRequest,
StartRequest, SystemProxyRequest, TRUNCATED_HEADER,
@@ -16,6 +16,7 @@ use crate::macos::SystemProxyStatus;
use crate::models::{Flow, FlowBody};
use crate::save::{SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
use crate::settings::{DecryptList, ProxySettings};
use crate::tunnels::{OpenTunnel, TunnelScope};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::path::Path;
@@ -129,10 +130,49 @@ impl ControlClient {
resp.text().await.map_err(|e| e.to_string())
}
pub async fn trust_ca(&self) -> Result<CaStatus, String> {
pub async fn trust_ca(&self) -> Result<CaTrustResult, String> {
self.post("/ca/trust", &serde_json::json!({})).await
}
/// The addresses the owner's engine passes through after clients rejected the CA.
pub async fn passthrough(&self) -> Result<crate::PassthroughResponse, String> {
self.get("/passthrough").await
}
/// Has the owner's engine intercept one passed-through address again.
pub async fn forget_passthrough(
&self,
req: &crate::ForgetPassthroughRequest,
) -> Result<crate::ForgetPassthroughResponse, String> {
self.post("/passthrough/forget", req).await
}
/// The owner's open tunnels its decrypt list would decrypt if their clients reconnected.
pub async fn reconnectable_tunnels(
&self,
scope: &TunnelScope,
) -> Result<Vec<OpenTunnel>, String> {
let query = {
let mut query = url::form_urlencoded::Serializer::new(String::new());
if let Some(entry) = &scope.entry {
query.append_pair("entry", entry);
}
if let Some(flow_id) = &scope.flow_id {
query.append_pair("flowId", flow_id);
}
query.finish()
};
self.get(&format!("/tunnels/reconnectable?{query}")).await
}
/// Closes tunnels in the owner's engine so their clients reconnect.
pub async fn reconnect_tunnels(
&self,
req: &crate::ReconnectTunnelsRequest,
) -> Result<crate::ReconnectTunnelsResponse, String> {
self.post("/tunnels/reconnect", req).await
}
/// Resumes or pauses storing flows in the owner's engine.
pub async fn set_recording(&self, enabled: bool) -> Result<ControlStatus, String> {
self.post("/recording", &RecordingRequest { enabled }).await
@@ -154,7 +194,7 @@ impl ControlClient {
pub async fn edit_decrypt_list(
&self,
edit: &crate::EditDecryptListRequest,
) -> Result<DecryptList, String> {
) -> Result<crate::DecryptListSaved, String> {
let body = serde_json::to_vec(edit).map_err(|e| e.to_string())?;
let req = self
.http
@@ -166,7 +206,10 @@ impl ControlClient {
}
/// Replaces the decrypt list in the owner, which saves it and applies it to its engine.
pub async fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptList, String> {
pub async fn set_decrypt_list(
&self,
entries: &[String],
) -> Result<crate::DecryptListSaved, String> {
let body = serde_json::to_vec(&DecryptRequest { entries: entries.to_vec() })
.map_err(|e| e.to_string())?;
let req = self
+35 -10
View File
@@ -11,8 +11,10 @@ use crate::macos::SystemProxyStatus;
use crate::messages::{self, FlowMessage, MessageKind};
use crate::models::{FlowBody, find_header};
use crate::save::SaveFlowRequest;
use crate::tunnels::TunnelScope;
use crate::{
AddGrpcSchemaRequest, EditDecryptListRequest, ListMessagesResponse, ProxyCtx, ProxyState,
AddGrpcSchemaRequest, EditDecryptListRequest, ForgetPassthroughRequest, ListMessagesResponse,
PassthroughResponse, ProxyCtx, ProxyState, ReconnectTunnelsRequest, ReconnectTunnelsResponse,
ReflectGrpcHostRequest, bodies,
};
use bytes::Bytes;
@@ -626,15 +628,24 @@ async fn route(state: &ServerState, req: Request<Incoming>) -> Result<ControlRes
};
Ok(response(StatusCode::OK, "application/x-pem-file", ca.pem().to_string()))
}
(&Method::POST, ["ca", "trust"]) => {
// Off the runtime's few workers: it may wait for the keychain or a conversion.
let ca = {
let ctx = ctx.clone();
blocking(move || ctx.load_ca()).await?
};
ca::trust(ca.cert_path()).await.map_err(bad_request)?;
ctx.forget_passthrough();
json(&ca::status(ca.cert_path()).await?)
(&Method::POST, ["ca", "trust"]) => json(&ctx.trust_ca().await.map_err(bad_request)?),
(&Method::GET, ["passthrough"]) => {
json(&PassthroughResponse { addresses: ctx.passthrough() })
}
(&Method::POST, ["passthrough", "forget"]) => {
let body: ForgetPassthroughRequest = read_json(req).await?;
json(&ctx.forget_passthrough_request(&body).map_err(bad_request)?)
}
(&Method::GET, ["tunnels", "reconnectable"]) => {
let scope = tunnel_scope(&query);
let ctx = ctx.clone();
json(&blocking(move || ctx.reconnectable_tunnels(&scope)).await.map_err(bad_request)?)
}
(&Method::POST, ["tunnels", "reconnect"]) => {
let body: ReconnectTunnelsRequest = read_json(req).await?;
let ctx = ctx.clone();
let closed = blocking(move || ctx.reconnect_tunnels(body.ids.as_deref(), &body.scope));
json(&ReconnectTunnelsResponse { closed: closed.await.map_err(bad_request)? })
}
(&Method::GET, ["system-proxy"]) => {
let ctx = ctx.clone();
@@ -723,6 +734,20 @@ fn message_page(query: &str) -> Result<(Option<i64>, Option<usize>), ApiError> {
Ok((after, limit))
}
/// `entry` and `flowId` from a query string.
fn tunnel_scope(query: &str) -> TunnelScope {
let mut scope = TunnelScope::default();
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
let value = Some(value.into_owned()).filter(|v| !v.is_empty());
match key.as_ref() {
"entry" => scope.entry = value,
"flowId" => scope.flow_id = value,
_ => {}
}
}
scope
}
fn body_limit(query: &str) -> Result<usize, ApiError> {
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
if key == "bodyLimit" {
+1 -1
View File
@@ -618,7 +618,7 @@ mod tests {
&applied[6..],
[
"00000007", "00000008", "00000009", "00000010", "00000011", "00000012", "00000013",
"00000014"
"00000014", "00000015"
]
);
let mark_columns = columns(&conn, "flow_clear_mark");
+89 -3
View File
@@ -1,7 +1,7 @@
use crate::bodies;
use crate::db::{ProxyQueryManager, Stores};
use crate::models::{Flow, FlowBody};
use crate::query::{FlowColumns, FlowQuery};
use crate::query::{FlowColumns, FlowQuery, Hidden};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use rusqlite::types::Value;
@@ -23,6 +23,10 @@ pub struct FlowFilter {
/// `@is:system` itself.
#[ts(optional, as = "Option<bool>")]
pub hide_system: bool,
/// Leaves out HTTPS connections passed through undecrypted, as hiding Encrypted does,
/// unless the query says `@is:encrypted` itself.
#[ts(optional, as = "Option<bool>")]
pub hide_encrypted: bool,
/// At most this many flows, the newest.
#[ts(optional, type = "number")]
pub limit: Option<usize>,
@@ -338,7 +342,8 @@ impl FlowFilter {
Self { query: Some(query.into()), ..Self::default() }
}
/// The filter from a query string's `query`, `hide_system`, `limit`, `after` and `before`.
/// The filter from a query string's `query`, `hide_system`, `hide_encrypted`, `limit`,
/// `after` and `before`.
pub fn from_query(query: &str) -> Result<Self, String> {
let mut filter = Self::default();
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
@@ -351,6 +356,12 @@ impl FlowFilter {
.transpose()?
.unwrap_or_default()
}
"hide_encrypted" => {
filter.hide_encrypted = value
.map(|v| v.parse().map_err(|_| format!("Invalid hide_encrypted {v:?}")))
.transpose()?
.unwrap_or_default()
}
"after" => filter.after = value,
"before" => filter.before = value,
"limit" => {
@@ -368,9 +379,11 @@ impl FlowFilter {
let mut query = url::form_urlencoded::Serializer::new(String::new());
let limit = self.limit.map(|l| l.to_string());
let hide_system = self.hide_system.then(|| "true".to_string());
let hide_encrypted = self.hide_encrypted.then(|| "true".to_string());
for (key, value) in [
("query", &self.query),
("hide_system", &hide_system),
("hide_encrypted", &hide_encrypted),
("limit", &limit),
("after", &self.after),
("before", &self.before),
@@ -384,7 +397,12 @@ impl FlowFilter {
/// The search, prepared to run now.
pub fn flow_query(&self) -> FlowQuery {
FlowQuery::new(self.query.as_deref().unwrap_or_default(), self.hide_system)
FlowQuery::new(self.query.as_deref().unwrap_or_default(), self.hidden())
}
/// What the filter leaves out unless its query names it.
pub fn hidden(&self) -> Hidden {
Hidden { system: self.hide_system, encrypted: self.hide_encrypted }
}
/// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows.
@@ -1867,6 +1885,67 @@ mod tests {
assert_eq!(hidden("@host:*.github.com OR @is:SYSTEM"), vec!["fl_daemon", "fl_node"]);
}
/// Hiding encrypted connections leaves them out of the list and its count, the totals the
/// sidebar reads, and saved searches' counts, unless the search names them.
#[test]
fn hides_encrypted_connections() {
let dir = tempfile::tempdir().unwrap();
let db =
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
let flow = |id: &str, url: &str, tunnelled: bool, path: Option<&str>| Flow {
id: id.into(),
url: url.into(),
method: if tunnelled { "CONNECT" } else { "GET" }.into(),
tunnelled,
client_path: path.map(String::from),
..Default::default()
};
for f in [
flow("fl_get", "https://api.example.com/v1", false, None),
flow("fl_tunnel", "https://bank.example.com", true, None),
flow("fl_daemon_tunnel", "https://p01.icloud.com", true, Some("/usr/libexec/trustd")),
] {
db.with_tx(|tx| tx.upsert(&f, &yaak_database::UpdateSource::Background)).unwrap();
}
let filter = |query: &str, hide_system: bool| FlowFilter {
hide_encrypted: true,
hide_system,
..FlowFilter::search(query)
};
let ids = |filter: FlowFilter| -> Vec<String> {
let mut ids: Vec<String> =
list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect();
ids.sort();
ids
};
assert_eq!(ids(filter("", false)), vec!["fl_get"]);
assert_eq!(ids(filter("example", false)), vec!["fl_get"]);
assert_eq!(ids(filter("@is:encrypted", false)), vec!["fl_daemon_tunnel", "fl_tunnel"]);
assert_eq!(ids(filter("@is:encrypted", true)), vec!["fl_tunnel"]);
assert_eq!(ids(filter("@is:tunnelled", false)).len(), 2);
// The totals: everything stored, and what shows with encrypted connections hidden.
let page = sorted_page(&db, &FlowFilter { limit: Some(0), ..filter("", false) }).unwrap();
assert_eq!((page.total, page.matching), (3, Some(1)));
let page = sorted_page(&db, &FlowFilter::default()).unwrap();
assert_eq!((page.total, page.matching), (3, None));
assert!(FlowFilter { hide_encrypted: true, ..Default::default() }.narrows());
assert_eq!(
count_each(&db, &[filter("", false), filter("@is:encrypted", true)]).unwrap(),
[1, 1]
);
// Saved searches count as the list would show them.
let all = crate::saved_queries::create(&db, "All HTTPS", "@scheme:https").unwrap();
let count = |hidden: Hidden| {
let counts = crate::saved_queries::counts(&db, hidden).unwrap();
counts.into_iter().find(|c| c.id == all.id).unwrap().count
};
assert_eq!(count(Hidden::default()), 3);
assert_eq!(count(Hidden { system: false, encrypted: true }), 1);
assert_eq!(count(Hidden { system: true, encrypted: false }), 2);
}
#[test]
fn tells_system_daemons_apart() {
assert!(is_system(None, Some("/usr/libexec/nsurlsessiond")));
@@ -1897,6 +1976,13 @@ mod tests {
};
assert_eq!(filter.to_query(), "query=post+%40status%3A4xx&hide_system=true&limit=20");
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
let filter = FlowFilter { hide_encrypted: true, ..filter };
assert_eq!(
filter.to_query(),
"query=post+%40status%3A4xx&hide_system=true&hide_encrypted=true&limit=20"
);
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
assert!(FlowFilter::from_query("hide_encrypted=maybe").is_err());
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
assert!(FlowFilter::from_query("limit=lots").is_err());
assert!(FlowFilter::from_query("hide_system=maybe").is_err());
+606 -39
View File
@@ -20,6 +20,7 @@ pub mod save;
pub mod saved_queries;
pub mod settings;
pub mod terminal;
pub mod tunnels;
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
use crate::attribution::Attribution;
@@ -32,7 +33,9 @@ use crate::grpc::{DecodedGrpcMessage, GrpcSchema, GrpcState};
use crate::helper::HelperState;
use crate::macos::{SystemProxyBackend, SystemProxyStatus};
use crate::messages::FlowMessage;
use crate::models::{Flow, FlowBody, ModelPayload, ProxyHeader, StreamKind, find_header};
use crate::models::{
Flow, FlowBody, ModelPayload, ProxyHeader, StreamKind, TunnelReason, find_header,
};
use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
use crate::settings::{DecryptList, HttpVersion, ProxySettings, TrafficRetention};
use base64::Engine;
@@ -130,6 +133,8 @@ pub struct ProxyCtx {
pub grpc: GrpcState,
/// The search the app shows, which written flows' events are matched against.
live_search: Arc<query::LiveSearch>,
/// The HTTPS connections the running engine passes through undecrypted now.
tunnels: Arc<tunnels::OpenTunnels>,
}
impl ProxyCtx {
@@ -178,6 +183,7 @@ impl ProxyCtx {
events,
grpc: GrpcState::default(),
live_search: Default::default(),
tunnels: Default::default(),
};
// Unreadable settings keep the traffic: nothing is deleted on a guess. Nor is traffic
// another process is capturing as this launches, which this one may take over later.
@@ -513,6 +519,7 @@ impl ProxyCtx {
let mut proxy_handle = yaak_proxy::start_proxy(config)?;
let attribution = Attribution::new(proxy_handle.port);
let _ = attribution_slot.set(attribution.clone());
let tunnel_run = self.tunnels.begin_run(Some(attribution.clone()));
if let Some(event_rx) = proxy_handle.take_event_rx() {
let sink = FlowSink {
@@ -524,6 +531,10 @@ impl ProxyCtx {
recording: self.recording.clone(),
finished: Default::default(),
clock: BatchClock::Real,
tunnels: self.tunnels.clone(),
tunnel_run,
handle: Arc::downgrade(&self.handle),
rejections: Default::default(),
};
std::thread::spawn(move || run_event_loop(event_rx, sink));
}
@@ -615,6 +626,9 @@ impl ProxyCtx {
let mut handle = self.handle.lock().map_err(|_| "lock poisoned".to_string())?;
// Under the handle's lock, so a start waiting for the databases sees it.
self.stops.fetch_add(1, Ordering::SeqCst);
// Its tunnels go with it, in the same turn of the lock, so nothing reads them as open
// once it is gone.
self.tunnels.stop_run();
// Dropping the handle stops the listener and joins its thread.
let port = handle.take().map(|h| h.port);
drop(handle);
@@ -1098,7 +1112,7 @@ impl ProxyCtx {
/// Replaces the decrypt list, saved in `settings.json` and applied to the running engine's
/// next connections. Connections already open stay as they were.
pub fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptList, String> {
pub fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptListSaved, String> {
self.change_decrypt_list(|settings| settings.set_decrypt_list(entries))
}
@@ -1108,38 +1122,167 @@ impl ProxyCtx {
&self,
add: &[String],
remove: &[String],
) -> Result<DecryptList, String> {
) -> Result<DecryptListSaved, String> {
self.change_decrypt_list(|settings| settings.edit_decrypt_list(add, remove))
}
/// Changes the list in `settings.json`, one change at a time, applies it to the running
/// engine, and tells the app.
/// engine, and tells the app. Connections already open are left as they are; the reply
/// counts those the new list would decrypt if they reconnected.
fn change_decrypt_list(
&self,
change: impl FnOnce(&mut ProxySettings) -> Result<(), String>,
) -> Result<DecryptList, String> {
) -> Result<DecryptListSaved, String> {
let _turn = self.decrypt_edits.lock().map_err(|_| "lock poisoned".to_string())?;
let mut settings = self.settings()?;
change(&mut settings)?;
settings.save(&self.settings_path())?;
if let Ok(handle) = self.handle.lock()
&& let Some(handle) = handle.as_ref()
{
handle.set_decrypt_policy(settings.decrypt_policy());
}
let policy = settings.decrypt_policy();
// Applied and counted against the one engine running, whose tunnels these are.
let still_encrypted = self.with_engine(|engine| {
let Some(engine) = engine else {
return Ok::<_, String>(0);
};
engine.set_decrypt_policy(policy.clone());
let all = tunnels::TunnelScope::default();
Ok(self.reconnectable_in(engine, &policy, &all)?.len())
})?;
let list = settings.decrypt_list();
self.events.emit("decrypt_list_changed", &DecryptListChangedPayload { list: list.clone() });
Ok(list)
Ok(DecryptListSaved { list, still_encrypted })
}
/// `f` with the engine running now, if any, under its lock. Starting and stopping the engine
/// swap it and the open-tunnel registry's run under this same lock, so what `f` reads of
/// the engine's tunnels and pass-through set and what it does to them belong to one run.
fn with_engine<T>(&self, f: impl FnOnce(Option<&ProxyHandle>) -> T) -> T {
let handle = self.handle.lock().unwrap_or_else(PoisonError::into_inner);
f(handle.as_ref())
}
/// The open tunnels of `engine` within `scope` that `policy` would decrypt if their clients
/// reconnected. For a caller holding the engine's lock.
fn reconnectable_in(
&self,
engine: &ProxyHandle,
policy: &yaak_proxy::DecryptPolicy,
scope: &tunnels::TunnelScope,
) -> Result<Vec<tunnels::OpenTunnel>, String> {
tunnels::reconnectable(&self.tunnels.list(), policy, &engine.passthrough(), scope)
}
/// Intercepts again the hosts passed through after clients rejected the CA, as they no
/// longer will once it is trusted.
pub fn forget_passthrough(&self) {
if let Ok(handle) = self.handle.lock()
&& let Some(handle) = handle.as_ref()
{
handle.forget_passthrough();
let addresses = self.with_engine(|engine| {
let engine = engine?;
engine.forget_passthrough();
Some(engine.passthrough())
});
let addresses = addresses.unwrap_or_default();
self.events.emit("passthrough_changed", &PassthroughChangedPayload { addresses });
}
/// Intercepts again one address (`host:port`) passed through after a client rejected the
/// CA. Returns whether it was passed through, and what still is, read from the same engine.
pub fn forget_passthrough_address(&self, address: &str) -> (bool, Vec<String>) {
let (forgotten, addresses) = self.with_engine(|engine| match engine {
Some(e) => (e.forget_passthrough_address(address), e.passthrough()),
None => (false, Vec::new()),
});
if forgotten {
let payload = PassthroughChangedPayload { addresses: addresses.clone() };
self.events.emit("passthrough_changed", &payload);
}
(forgotten, addresses)
}
/// The addresses (`host:port`) the running engine passes through after clients rejected
/// the CA. Empty while it is stopped.
pub fn passthrough(&self) -> Vec<String> {
self.with_engine(|engine| engine.map(ProxyHandle::passthrough).unwrap_or_default())
}
/// [`Self::forget_passthrough_address`] for an address as the app or the control API names
/// it, with what is still passed through.
pub fn forget_passthrough_request(
&self,
req: &ForgetPassthroughRequest,
) -> Result<ForgetPassthroughResponse, String> {
let address = passthrough_address(&req.address)
.ok_or_else(|| format!("Invalid address {:?}", req.address))?;
let (forgotten, addresses) = self.forget_passthrough_address(&address);
Ok(ForgetPassthroughResponse { forgotten, addresses })
}
/// Trusts the CA, made first when the engine has never run so there is one to trust. Once
/// trusted, the addresses passed through after clients rejected it are intercepted again.
pub async fn trust_ca(&self) -> Result<ca::CaTrustResult, String> {
// Off the async threads: it may wait for the keychain or a conversion.
let ca = {
let ctx = self.clone();
blocking(move || ctx.load_ca()).await.map_err(|e| e.message)?
};
let trusted = ca::trust(ca.cert_path()).await?;
if trusted == ca::Trusted::Yes {
self.forget_passthrough();
}
let status = ca::status(ca.cert_path()).await?;
Ok(ca::CaTrustResult { status, cancelled: trusted == ca::Trusted::Cancelled })
}
/// The process that owns the engine, while it is another one: commands about the running
/// engine go there.
fn engine_elsewhere(&self) -> Option<ControlInfo> {
if self.port().is_some() {
return None;
}
self.other_owner()
}
/// The HTTPS connections open now that the decrypt list would decrypt if their clients
/// reconnected, within `scope`. Not those passed through after a client rejected the CA.
pub fn reconnectable_tunnels(
&self,
scope: &tunnels::TunnelScope,
) -> Result<Vec<tunnels::OpenTunnel>, String> {
let policy = self.settings()?.decrypt_policy();
self.with_engine(|engine| match engine {
Some(engine) => self.reconnectable_in(engine, &policy, scope),
None => Ok(Vec::new()),
})
}
/// Closes tunnels so their clients reconnect, decrypted as the list now says: those in
/// `ids` this run of the engine gave out, or, without ids, those
/// [`Self::reconnectable_tunnels`] finds in `scope`. Each ends with its flow marked
/// `reconnectRequested`. Returns how many were open. Looked up and closed under the engine's
/// lock, so a restart can't come between and have another run's tunnels closed.
pub fn reconnect_tunnels(
&self,
ids: Option<&[String]>,
scope: &tunnels::TunnelScope,
) -> Result<usize, String> {
let policy = match ids {
Some(_) => None,
None => Some(self.settings()?.decrypt_policy()),
};
self.with_engine(|engine| {
let Some(engine) = engine else {
return Ok(0);
};
let ids = match (ids, &policy) {
(Some(ids), _) => self.tunnels.engine_ids(ids),
(None, Some(policy)) => {
let found = self.reconnectable_in(engine, policy, scope)?;
found.iter().map(|t| t.engine_id).collect()
}
(None, None) => Vec::new(),
};
#[cfg(test)]
tests::pause_between_lookup_and_close();
Ok(engine.close_tunnels(&ids))
})
}
/// Flows are being stored.
@@ -1630,6 +1773,51 @@ pub struct DecryptListChangedPayload {
pub list: DecryptList,
}
/// `host`, `host:port`, `[::1]` or `[::1]:8443` as the engine keys its pass-through set:
/// `host:port`, an IPv6 host in brackets. None when it is none of these.
fn passthrough_address(address: &str) -> Option<String> {
let address = address.trim();
let (host, port) = if let Some(rest) = address.strip_prefix('[') {
let (host, after) = rest.split_once(']')?;
let port = match after {
"" => 443,
p => p.strip_prefix(':')?.parse().ok()?,
};
(format!("[{host}]"), port)
} else {
match address.split_once(':') {
Some((host, port)) if !port.contains(':') => (host.to_string(), port.parse().ok()?),
Some(_) => (format!("[{address}]"), 443),
None => (address.to_string(), 443),
}
};
(!host.is_empty() && host != "[]").then(|| format!("{host}:{port}"))
}
/// The decrypt list as saved, and what it means for connections already open.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct DecryptListSaved {
#[serde(flatten)]
pub list: DecryptList,
/// Open connections the list now decrypts that stay encrypted until their clients
/// reconnect: `reconnect_tunnels` closes them so they do. Closed by nothing else. Zero from
/// an owner too old to count them.
#[serde(default)]
#[ts(type = "number")]
pub still_encrypted: usize,
}
/// The addresses passed through after clients rejected the CA changed: one was added or
/// forgotten.
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct PassthroughChangedPayload {
/// Each `host:port`, an IPv6 host in brackets, sorted.
pub addresses: Vec<String>,
}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ProxyStatePayload {
@@ -1702,6 +1890,9 @@ pub struct WatchQueryRequest {
#[serde(default)]
#[ts(optional, as = "Option<bool>")]
pub hide_system: bool,
#[serde(default)]
#[ts(optional, as = "Option<bool>")]
pub hide_encrypted: bool,
}
/// Which of some flows a search covers, for flows whose events were matched against another.
@@ -1744,6 +1935,9 @@ pub struct CountSavedQueriesRequest {
#[serde(default)]
#[ts(optional, as = "Option<bool>")]
pub hide_system: bool,
#[serde(default)]
#[ts(optional, as = "Option<bool>")]
pub hide_encrypted: bool,
}
#[derive(Serialize, TS)]
@@ -2189,6 +2383,56 @@ pub struct CaStatusRequest {}
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CaTrustRequest {}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ListPassthroughRequest {}
/// The addresses passed through after clients rejected the CA.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct PassthroughResponse {
/// Each `host:port`, an IPv6 host in brackets, sorted.
pub addresses: Vec<String>,
}
/// An address to intercept again: `host`, `host:port` or `[::1]:8443`, as a flow's URL has
/// it, the port 443 when left out.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ForgetPassthroughRequest {
pub address: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ForgetPassthroughResponse {
/// It was passed through.
pub forgotten: bool,
/// The addresses still passed through.
pub addresses: Vec<String>,
}
/// Tunnels to close: those in `ids`, or, without them, every one `list_reconnectable_tunnels`
/// returns for the same `entry` and `flowId`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ReconnectTunnelsRequest {
/// `OpenTunnel` ids. Ones from an earlier run of the engine close nothing.
#[ts(optional)]
pub ids: Option<Vec<String>>,
#[serde(flatten)]
pub scope: tunnels::TunnelScope,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ReconnectTunnelsResponse {
/// Tunnels closed: those still open of the ones asked for.
#[ts(type = "number")]
pub closed: usize,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct SystemProxyStatusRequest {}
@@ -2472,7 +2716,7 @@ fn get_decrypt_list(ctx: &ProxyCtx, _req: GetDecryptListRequest) -> Result<Decry
async fn set_decrypt_list(
ctx: ProxyCtx,
req: SetDecryptListRequest,
) -> Result<DecryptList, RpcError> {
) -> Result<DecryptListSaved, RpcError> {
if ctx.port().is_none()
&& let Some(owner) = ctx.other_owner()
{
@@ -2485,7 +2729,7 @@ async fn set_decrypt_list(
async fn edit_decrypt_list(
ctx: ProxyCtx,
req: EditDecryptListRequest,
) -> Result<DecryptList, RpcError> {
) -> Result<DecryptListSaved, RpcError> {
if ctx.port().is_none()
&& let Some(owner) = ctx.other_owner()
{
@@ -2524,6 +2768,9 @@ async fn list_workspaces(
async fn preview_save(ctx: ProxyCtx, req: PreviewSaveRequest) -> Result<SavePreview, RpcError> {
let flow = find_flow(&ctx, &req.flow_id)?;
if flow.is_handshake_only() {
return Err(rpc_error(NOT_A_REQUEST));
}
let limit = settings(&ctx)?.capture_body_limit;
let body = bodies::read_body(&ctx.db()?, &ctx.blobs()?, &flow, FlowBody::Request, limit)
.await
@@ -2683,16 +2930,59 @@ async fn ca_status(ctx: ProxyCtx, _req: CaStatusRequest) -> Result<CaStatus, Rpc
ca::status(&ctx.ca_dir().join("ca.pem")).await.map_err(rpc_error)
}
async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<CaStatus, RpcError> {
// Creates the CA when the engine has never run, so there is something to trust. Off the
// async threads: it may wait for the keychain or a conversion.
let ca = {
let ctx = ctx.clone();
blocking(move || ctx.load_ca()).await?
};
ca::trust(ca.cert_path()).await.map_err(rpc_error)?;
ctx.forget_passthrough();
ca::status(ca.cert_path()).await.map_err(rpc_error)
/// Asks macOS to trust the CA, whenever the app needs it, not only during setup. A cancelled
/// password prompt is a result, `cancelled`, not an error.
async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<ca::CaTrustResult, RpcError> {
ctx.trust_ca().await.map_err(rpc_error)
}
/// The addresses passed through after clients rejected the CA, in the engine that runs, here
/// or in the process that owns it.
async fn list_passthrough(
ctx: ProxyCtx,
_req: ListPassthroughRequest,
) -> Result<PassthroughResponse, RpcError> {
if let Some(owner) = ctx.engine_elsewhere() {
return ControlClient::new(owner).passthrough().await.map_err(rpc_error);
}
Ok(PassthroughResponse { addresses: ctx.passthrough() })
}
/// Intercepts one passed-through address again from its next connection on.
async fn forget_passthrough(
ctx: ProxyCtx,
req: ForgetPassthroughRequest,
) -> Result<ForgetPassthroughResponse, RpcError> {
if let Some(owner) = ctx.engine_elsewhere() {
return ControlClient::new(owner).forget_passthrough(&req).await.map_err(rpc_error);
}
ctx.forget_passthrough_request(&req).map_err(rpc_error)
}
/// The open tunnels the decrypt list would decrypt if their clients reconnected.
async fn list_reconnectable_tunnels(
ctx: ProxyCtx,
req: tunnels::TunnelScope,
) -> Result<Vec<tunnels::OpenTunnel>, RpcError> {
if let Some(owner) = ctx.engine_elsewhere() {
return ControlClient::new(owner).reconnectable_tunnels(&req).await.map_err(rpc_error);
}
blocking(move || ctx.reconnectable_tunnels(&req)).await
}
/// Closes tunnels so their clients reconnect, decrypted as the list now says.
async fn reconnect_tunnels(
ctx: ProxyCtx,
req: ReconnectTunnelsRequest,
) -> Result<ReconnectTunnelsResponse, RpcError> {
if let Some(owner) = ctx.engine_elsewhere() {
return ControlClient::new(owner).reconnect_tunnels(&req).await.map_err(rpc_error);
}
blocking(move || {
let closed = ctx.reconnect_tunnels(req.ids.as_deref(), &req.scope)?;
Ok::<_, String>(ReconnectTunnelsResponse { closed })
})
.await
}
async fn system_proxy_status(
@@ -2780,8 +3070,9 @@ async fn query_values(ctx: ProxyCtx, req: QueryValuesRequest) -> Result<Vec<Stri
}
fn watch_query(ctx: &ProxyCtx, req: WatchQueryRequest) -> Result<bool, RpcError> {
let watched = req.query.filter(|q| !q.trim().is_empty() || req.hide_system);
ctx.live_search.watch(watched.map(|q| (req.key, q, req.hide_system)));
let hidden = query::Hidden { system: req.hide_system, encrypted: req.hide_encrypted };
let watched = req.query.filter(|q| !q.trim().is_empty() || hidden != Default::default());
ctx.live_search.watch(watched.map(|q| (req.key, q, hidden)));
Ok(true)
}
@@ -2886,7 +3177,8 @@ async fn count_saved_queries(
ctx: ProxyCtx,
req: CountSavedQueriesRequest,
) -> Result<Vec<saved_queries::SavedQueryCount>, RpcError> {
blocking(move || saved_queries::counts(&ctx.db()?, req.hide_system)).await
let hidden = query::Hidden { system: req.hide_system, encrypted: req.hide_encrypted };
blocking(move || saved_queries::counts(&ctx.db()?, hidden)).await
}
// -- Event loop --
@@ -2936,6 +3228,13 @@ const STOPPED_MID_STREAM: &str = "Yaak Proxy stopped before the stream ended";
/// Why a tunnel still open when the engine stopped ended.
const STOPPED_MID_TUNNEL: &str = "Yaak Proxy stopped before the connection closed";
/// Why a failed handshake's flow can't be saved as a request.
pub(crate) const NOT_A_REQUEST: &str =
"Not a request: the client's TLS handshake failed before it sent one";
/// The error of a flow recording a client's refusal of the proxy's certificate.
const REJECTED_CERTIFICATE: &str = "Rejected the proxy's certificate";
/// A flow between its `RequestStart` and its end, with its bodies being written as they
/// stream.
struct InFlight {
@@ -3043,6 +3342,15 @@ struct FlowSink {
finished: std::cell::RefCell<FinishedFlows>,
/// What a batch's wait is timed by.
clock: BatchClock,
/// The open tunnels, kept for this engine's run whether or not flows are recorded.
tunnels: Arc<tunnels::OpenTunnels>,
tunnel_run: u64,
/// The engine, for its pass-through set as it changes. Weak, so the loop never keeps alive
/// the engine whose events it waits for.
handle: Weak<Mutex<Option<ProxyHandle>>>,
/// The flow recorded for each refused handshake, by the engine's id for it, for the tunnels
/// passed through after it to name.
rejections: std::cell::RefCell<HashMap<u64, String>>,
}
/// What the event loop times a batch's wait by: the time, or in tests a time that stands still,
@@ -3168,6 +3476,7 @@ fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, sink: FlowSink) {
}
}
sink.write_finished();
sink.tunnels.end_run(sink.tunnel_run);
// Its hold on the databases goes before the gate opens again.
drop(sink);
}
@@ -3279,19 +3588,61 @@ impl FlowSink {
},
);
}
ProxyEvent::TunnelStart { id, started_at, authority, connection, client_addr } => {
if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) {
return;
}
ProxyEvent::TunnelStart {
id,
started_at,
authority,
host,
port,
connection,
client_addr,
reason,
} => {
attribution.begin(connection, client_addr);
let recorded = recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true);
let flow_id = recorded.then(Flow::generate_id);
let (tunnel_reason, excluded_by, excluded_built_in, rejected_by) = match reason {
yaak_proxy::TunnelReason::NotListed => {
(TunnelReason::NotListed, None, false, None)
}
yaak_proxy::TunnelReason::Excluded { entry, built_in } => {
(TunnelReason::Excluded, Some(entry), built_in, None)
}
yaak_proxy::TunnelReason::Rejected { rejected_by } => {
let flow = self.rejections.borrow().get(&rejected_by).cloned();
(TunnelReason::Rejected, None, false, flow)
}
};
// Kept paused too, so it can be closed for a reconnect.
let mut open = tunnels::OpenTunnel {
id: String::new(),
engine_id: id,
host,
port,
app_name: None,
app_bundle_id: None,
flow_id: flow_id.clone(),
reason: tunnel_reason,
};
if let Some(app) = attribution.get(connection, Duration::ZERO) {
tunnels::set_app(&mut open, app);
}
self.tunnels.open(self.tunnel_run, open, connection);
let Some(flow_id) = flow_id else {
return;
};
let flow = Flow {
id: Flow::generate_id(),
id: flow_id,
started_at: DateTime::<Utc>::from(started_at).naive_utc(),
method: "CONNECT".to_string(),
url: format!("https://{authority}"),
client_http_version: "HTTP/1.1".to_string(),
client_addr: Some(client_addr.to_string()),
tunnelled: true,
tunnel_reason: Some(tunnel_reason),
tunnel_excluded_by: excluded_by,
tunnel_excluded_built_in: excluded_built_in,
tunnel_rejected_by: rejected_by,
..Default::default()
};
let mut f = InFlight {
@@ -3310,15 +3661,77 @@ impl FlowSink {
self.write_stream_flow(&mut f);
in_flight.insert(id, f);
}
ProxyEvent::TunnelEnd { id, sent, received, elapsed_ms, error } => {
ProxyEvent::TunnelEnd {
id,
sent,
received,
elapsed_ms,
error,
reconnect_requested,
} => {
self.tunnels.close(self.tunnel_run, id);
if let Some(mut f) = in_flight.remove(&id) {
f.flow.request_size = sent as i64;
f.flow.response_size = received as i64;
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
f.flow.error = error;
f.flow.reconnect_requested = reconnect_requested;
self.finish_flow(f);
}
}
ProxyEvent::HandshakeFailed {
id,
started_at,
authority,
connection,
client_addr,
rejection,
error,
elapsed_ms,
} => {
// Whether or not it is recorded, the pass-through set has changed.
let addresses = self
.handle
.upgrade()
.and_then(|handle| {
let handle = handle.lock().ok()?;
handle.as_ref().map(ProxyHandle::passthrough)
})
.unwrap_or_default();
self.events.emit("passthrough_changed", &PassthroughChangedPayload { addresses });
if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) {
return;
}
attribution.begin(connection, client_addr);
let error = match &rejection {
Some(_) => REJECTED_CERTIFICATE.to_string(),
None => format!("TLS handshake failed: {error}"),
};
let flow = Flow {
id: Flow::generate_id(),
started_at: DateTime::<Utc>::from(started_at).naive_utc(),
url: format!("https://{authority}"),
client_http_version: "HTTP/1.1".to_string(),
client_addr: Some(client_addr.to_string()),
elapsed_ms: Some(clamp_ms(elapsed_ms)),
tls_rejected: rejection,
error: Some(error),
..Default::default()
};
self.rejections.borrow_mut().insert(id, flow.id.clone());
self.finish_flow(InFlight {
flow,
connection,
request_body: None,
response_body: None,
persisted: false,
cleared: false,
next_seq: 0,
dirty: false,
last_write: None,
graphql_scan: None,
});
}
ProxyEvent::StreamStart { id, kind } => {
if let Some(f) = in_flight.get_mut(&id) {
let kind = StreamKind::from(kind);
@@ -3787,7 +4200,11 @@ define_rpc! {
add_grpc_schema(AddGrpcSchemaRequest) -> GrpcSchema,
app_icon(AppIconRequest) -> Option<String>,
ca_status(CaStatusRequest) -> CaStatus,
ca_trust(CaTrustRequest) -> CaStatus,
ca_trust(CaTrustRequest) -> ca::CaTrustResult,
forget_passthrough(ForgetPassthroughRequest) -> ForgetPassthroughResponse,
list_passthrough(ListPassthroughRequest) -> PassthroughResponse,
list_reconnectable_tunnels(tunnels::TunnelScope) -> Vec<tunnels::OpenTunnel>,
reconnect_tunnels(ReconnectTunnelsRequest) -> ReconnectTunnelsResponse,
clear_flows(ClearFlowsRequest) -> usize,
decode_grpc_message(DecodeGrpcMessageRequest) -> DecodedGrpcMessage,
export_har(ExportHarRequest) -> usize,
@@ -3796,9 +4213,9 @@ define_rpc! {
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
helper_register(HelperRegisterRequest) -> SystemProxyStatus,
helper_unregister(HelperUnregisterRequest) -> SystemProxyStatus,
edit_decrypt_list(EditDecryptListRequest) -> DecryptList,
edit_decrypt_list(EditDecryptListRequest) -> DecryptListSaved,
list_seen(ListSeenRequest) -> flows::SeenTargets,
set_decrypt_list(SetDecryptListRequest) -> DecryptList,
set_decrypt_list(SetDecryptListRequest) -> DecryptListSaved,
list_workspaces(ListWorkspacesRequest) -> ListWorkspacesResponse,
preview_save(PreviewSaveRequest) -> SavePreview,
reflect_grpc_host(ReflectGrpcHostRequest) -> GrpcSchema,
@@ -3823,6 +4240,7 @@ define_rpc! {
flows_removed(FlowsRemovedPayload),
message_write(MessagePayload),
model_write(ModelPayload),
passthrough_changed(PassthroughChangedPayload),
proxy_state_changed(ProxyStatePayload),
saved_queries_changed(SavedQueriesChangedPayload),
}
@@ -3832,6 +4250,151 @@ define_rpc! {
mod tests {
use super::*;
/// While set, [`ProxyCtx::reconnect_tunnels`] says when it has looked up what to close and
/// waits, up to a time, to be let go, so a test can restart the engine in between.
static BETWEEN_LOOKUP_AND_CLOSE: Mutex<
Option<(std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>)>,
> = Mutex::new(None);
pub(super) fn pause_between_lookup_and_close() {
let pause = BETWEEN_LOOKUP_AND_CLOSE.lock().unwrap().take();
if let Some((reached, go)) = pause {
let _ = reached.send(());
let _ = go.recv_timeout(Duration::from_secs(10));
}
}
/// A CONNECT tunnel through the engine on `port` to `target`, its bytes crossing both ways.
async fn open_tunnel(port: u16, target: &str) -> tokio::net::TcpStream {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
tcp.write_all(connect.as_bytes()).await.unwrap();
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
head.push(tcp.read_u8().await.unwrap());
}
assert!(head.starts_with(b"HTTP/1.1 200"), "{}", String::from_utf8_lossy(&head));
assert!(still_open(&mut tcp).await, "the tunnel did not relay");
tcp
}
/// Whether `tcp` still relays: a ping comes back, rather than the connection ending.
async fn still_open(tcp: &mut tokio::net::TcpStream) -> bool {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut echoed = [0u8; 4];
tcp.write_all(b"ping").await.is_ok() && tcp.read_exact(&mut echoed).await.is_ok()
}
async fn spawn_echo() -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
let (mut tcp, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let (mut read, mut write) = tcp.split();
let _ = tokio::io::copy(&mut read, &mut write).await;
});
}
});
port
}
async fn wait_for_open_tunnels(ctx: &ProxyCtx, n: usize) {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while ctx.tunnels.list().len() != n {
assert!(std::time::Instant::now() < deadline, "{:?}", ctx.tunnels.list());
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
/// An engine restart between finding what to close and closing it can't close a tunnel of
/// the new run, though the new run numbers its tunnels from 1 again: Greptile's "Reconnect
/// closes the wrong tunnel" on #835.
#[tokio::test(flavor = "multi_thread")]
async fn a_restart_between_lookup_and_close_closes_nothing_of_the_new_run() {
let dir = tempfile::tempdir().unwrap();
let settings =
ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, _rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
let echo = spawn_echo().await;
// The address the echo listens on, not `localhost`, which Windows tries as `::1` first
// and takes seconds to give up on.
let target = format!("127.0.0.1:{echo}");
let host = "host:127.0.0.1".to_string();
// Run 1: one tunnel, listed as reconnectable once the list names its host.
let mut old = open_tunnel(ctx.port().unwrap(), &target).await;
ctx.edit_decrypt_list(std::slice::from_ref(&host), &[]).unwrap();
wait_for_open_tunnels(&ctx, 1).await;
let listed = ctx.reconnectable_tunnels(&tunnels::TunnelScope::default()).unwrap();
let ids: Vec<String> = listed.iter().map(|t| t.id.clone()).collect();
assert_eq!((ids.len(), listed[0].engine_id), (1, 1));
// Reconnect pauses once it has looked up what to close.
let (reached_tx, reached_rx) = std::sync::mpsc::channel();
let (go_tx, go_rx) = std::sync::mpsc::channel();
*BETWEEN_LOOKUP_AND_CLOSE.lock().unwrap() = Some((reached_tx, go_rx));
let reconnecting = {
let ctx = ctx.clone();
tokio::task::spawn_blocking(move || {
ctx.reconnect_tunnels(Some(&ids), &tunnels::TunnelScope::default())
})
};
tokio::task::spawn_blocking(move || reached_rx.recv().unwrap()).await.unwrap();
// Then the engine restarts, and the new run's first tunnel is number 1 too. While the
// reconnect holds the engine's lock, a restart would wait for it, so it is let go
// first; were the lock not held, the restart and the new tunnel land in between.
let holds_engine = ctx.handle.try_lock().is_err();
if holds_engine {
go_tx.send(()).unwrap();
}
let restarting = ctx.clone();
tokio::task::spawn_blocking(move || {
restarting.stop().unwrap();
restarting.edit_decrypt_list(&[], std::slice::from_ref(&host)).unwrap();
restarting.start().unwrap();
})
.await
.unwrap();
let mut new = open_tunnel(ctx.port().unwrap(), &target).await;
wait_for_open_tunnels(&ctx, 1).await;
assert_eq!(ctx.tunnels.list()[0].engine_id, 1, "the new run's first tunnel");
if !holds_engine {
go_tx.send(()).unwrap();
}
let closed = reconnecting.await.unwrap().unwrap();
assert!(still_open(&mut new).await, "the new run's tunnel was closed");
assert!(!still_open(&mut old).await, "the old run's tunnel outlived its engine");
assert!(holds_engine, "looked up without the engine's lock held");
assert_eq!(closed, 1, "the old run's tunnel, closed in its own run");
}
/// An owner from before the count answers a forwarded save without it.
#[test]
fn a_saved_list_from_an_older_owner_reads_without_its_count() {
let saved: DecryptListSaved = serde_json::from_str(r#"{"entries": []}"#).unwrap();
assert_eq!((saved.list.entries.len(), saved.still_encrypted), (0, 0));
}
#[test]
fn passthrough_addresses_as_the_engine_keys_them() {
assert_eq!(passthrough_address("a.com").as_deref(), Some("a.com:443"));
assert_eq!(passthrough_address(" a.com:8443 ").as_deref(), Some("a.com:8443"));
assert_eq!(passthrough_address("[::1]").as_deref(), Some("[::1]:443"));
assert_eq!(passthrough_address("[::1]:8443").as_deref(), Some("[::1]:8443"));
assert_eq!(passthrough_address("::1").as_deref(), Some("[::1]:443"));
assert_eq!(passthrough_address(""), None);
assert_eq!(passthrough_address("a.com:http"), None);
assert_eq!(passthrough_address("[]"), None);
}
#[test]
fn settings_the_password_prompt_parked_keep_it_over_the_helper() {
use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy, Snapshot};
@@ -3899,6 +4462,10 @@ mod tests {
recording: ctx.recording.clone(),
finished: Default::default(),
clock: BatchClock::Real,
tunnels: ctx.tunnels.clone(),
tunnel_run: ctx.tunnels.begin_run(None),
handle: Arc::downgrade(&ctx.handle),
rejections: Default::default(),
}
}
+72
View File
@@ -110,11 +110,58 @@ pub struct Flow {
/// client rejected the proxy's certificate: method `CONNECT`, its URL the host, its sizes
/// the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set.
pub tunnelled: bool,
/// Why a tunnelled flow was not decrypted. None for other flows, and for tunnels recorded
/// before the reason was.
pub tunnel_reason: Option<TunnelReason>,
/// For a tunnel the decrypt list excluded, the exclusion that kept it out, as stored:
/// `!host:*.apple.com`.
pub tunnel_excluded_by: Option<String>,
/// That exclusion is one of the built-in ones.
pub tunnel_excluded_built_in: bool,
/// For a tunnel passed through after a client rejected the proxy's certificate, the id of
/// the flow of that refused handshake, when it was recorded.
pub tunnel_rejected_by: Option<String>,
/// The proxy closed this tunnel on request, so its client would reconnect.
pub reconnect_requested: bool,
/// A client's refused TLS handshake, recorded as its host and port began to be passed
/// through: how it refused, its alert (`unknown_ca`, `bad_certificate`,
/// `certificate_unknown`, ...) or `hang_up`. Such a flow has no method, its URL is
/// `https://<authority>`, and its error says it rejected the certificate. A handshake that
/// failed otherwise is recorded the same way with this None and the failure as its error.
pub tls_rejected: Option<String>,
/// An agent read the flow through the control API: its detail, a body or its messages.
/// Never written by an upsert, which keeps what is stored.
pub agent_read: bool,
}
/// Why a tunnel was passed through undecrypted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
pub enum TunnelReason {
/// No entry of the decrypt list named it.
NotListed,
/// An entry named it, and an exclusion kept it out: `tunnelExcludedBy`.
Excluded,
/// The list decrypts it, but a client rejected the proxy's certificate for its host and
/// port, which are passed through until the proxy restarts or forgets them.
Rejected,
}
impl TunnelReason {
pub fn as_str(self) -> &'static str {
match self {
Self::NotListed => "not_listed",
Self::Excluded => "excluded",
Self::Rejected => "rejected",
}
}
pub fn parse(s: &str) -> Option<Self> {
[Self::NotListed, Self::Excluded, Self::Rejected].into_iter().find(|r| r.as_str() == s)
}
}
/// The streaming protocol a flow carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
@@ -182,6 +229,11 @@ impl Flow {
}
}
/// A client's failed TLS handshake, recorded without a request: nothing to save or replay.
pub fn is_handshake_only(&self) -> bool {
self.method.is_empty()
}
pub fn truncated(&self, body: FlowBody) -> bool {
match body {
FlowBody::Request => self.request_truncated,
@@ -278,6 +330,12 @@ impl UpsertModelInfo for Flow {
(GrpcMessage, self.grpc_message.into()),
(GraphqlOperation, self.graphql_operation.into()),
(Tunnelled, self.tunnelled.into()),
(FlowIden::TunnelReason, self.tunnel_reason.map(|r| r.as_str()).into()),
(TunnelExcludedBy, self.tunnel_excluded_by.into()),
(TunnelExcludedBuiltIn, self.tunnel_excluded_built_in.into()),
(TunnelRejectedBy, self.tunnel_rejected_by.into()),
(ReconnectRequested, self.reconnect_requested.into()),
(TlsRejected, self.tls_rejected.into()),
(ElapsedConnectMs, self.elapsed_connect_ms.into()),
(ElapsedTlsMs, self.elapsed_tls_ms.into()),
(ElapsedRequestMs, self.elapsed_request_ms.into()),
@@ -322,6 +380,12 @@ impl UpsertModelInfo for Flow {
FlowIden::GrpcMessage,
FlowIden::GraphqlOperation,
FlowIden::Tunnelled,
FlowIden::TunnelReason,
FlowIden::TunnelExcludedBy,
FlowIden::TunnelExcludedBuiltIn,
FlowIden::TunnelRejectedBy,
FlowIden::ReconnectRequested,
FlowIden::TlsRejected,
FlowIden::ElapsedConnectMs,
FlowIden::ElapsedTlsMs,
FlowIden::ElapsedRequestMs,
@@ -381,6 +445,14 @@ impl UpsertModelInfo for Flow {
grpc_message: added_column(r, "grpc_message")?,
graphql_operation: added_column(r, "graphql_operation")?,
tunnelled: added_column(r, "tunnelled")?.unwrap_or_default(),
tunnel_reason: added_column::<String>(r, "tunnel_reason")?
.and_then(|r| TunnelReason::parse(&r)),
tunnel_excluded_by: added_column(r, "tunnel_excluded_by")?,
tunnel_excluded_built_in: added_column(r, "tunnel_excluded_built_in")?
.unwrap_or_default(),
tunnel_rejected_by: added_column(r, "tunnel_rejected_by")?,
reconnect_requested: added_column(r, "reconnect_requested")?.unwrap_or_default(),
tls_rejected: added_column(r, "tls_rejected")?,
agent_read: added_column(r, "agent_read")?.unwrap_or_default(),
})
}
+67 -25
View File
@@ -30,7 +30,8 @@ pub const FLAGS: &[&str] = &[
"error",
"pending",
"system",
"tunnelled",
"encrypted",
"rejected",
"saved",
"read",
"loop",
@@ -40,6 +41,10 @@ pub const FLAGS: &[&str] = &[
"graphql",
];
/// Other names `@is:` takes for a flag, kept for queries written with them: `tunnelled` is
/// `encrypted`'s name from before.
const FLAG_ALIASES: &[(&str, &str)] = &[("tunnelled", "encrypted")];
/// Flags `@is:` will take once flows have what they need. Valid, matching nothing.
const FLAGS_NOT_YET: &[&str] = &["marked"];
@@ -84,7 +89,12 @@ pub fn schema() -> &'static Schema {
field("status", number(), "Response status", "@status:4xx @status:>=400"),
field("type", text(), "Content type", "@type:json"),
field("app", text(), "App or program", "@app:Slack"),
field("is", text(), "error, pending, system, …", "@is:error -@is:system"),
field(
"is",
text(),
"error, pending, system, encrypted, rejected, …",
"@is:error -@is:system @is:encrypted",
),
map("header", "A header on either side", "@header.content-type:json"),
map("req.header", "A request header", "@req.header.accept:*"),
map("res.header", "A response header", "@res.header.x-cache:hit"),
@@ -130,30 +140,53 @@ const TIME_UNITS: &[(&str, f64)] = &[
// -- Preparing --
/// A query prepared for running: its shorthands written out, System left out when hidden.
/// What a query leaves out unless it names it: System's flows, encrypted connections.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Hidden {
pub system: bool,
pub encrypted: bool,
}
/// System alone hidden, or nothing.
impl From<bool> for Hidden {
fn from(system: bool) -> Self {
Self { system, encrypted: false }
}
}
/// A query prepared for running: its shorthands written out, hidden flows left out.
#[derive(Debug, Clone, PartialEq)]
pub struct FlowQuery {
root: Option<Node>,
}
impl FlowQuery {
/// `text` as of now. With `hide_system`, macOS's own daemons are left out, unless the text
/// says `@is:system` itself.
pub fn new(text: &str, hide_system: bool) -> Self {
Self::at(text, hide_system, Utc::now())
/// `text` as of now. With `hidden.system`, macOS's own daemons are left out, unless the text
/// says `@is:system` itself; with `hidden.encrypted`, connections tunnelled undecrypted,
/// unless it says `@is:encrypted` (or `@is:tunnelled`).
pub fn new(text: &str, hidden: impl Into<Hidden>) -> Self {
Self::at(text, hidden, Utc::now())
}
/// [`Self::new`], with `now` for the times `@time` counts back from.
pub fn at(text: &str, hide_system: bool, now: DateTime<Utc>) -> Self {
pub fn at(text: &str, hidden: impl Into<Hidden>, now: DateTime<Utc>) -> Self {
let hidden = hidden.into();
let parsed = yaak_query::parse(text);
let mentions_system = parsed.as_ref().is_some_and(mentions_system);
let mentions = |flags: &[&str]| parsed.as_ref().is_some_and(|r| mentions_flag(r, flags));
let hide: Vec<&str> = [
(hidden.system && !mentions(&["system"])).then_some("system"),
(hidden.encrypted && !mentions(&["encrypted", "tunnelled"])).then_some("encrypted"),
]
.into_iter()
.flatten()
.collect();
let mut root = parsed.map(|root| prepare(&root, now, &mut Vec::new()));
if hide_system && !mentions_system {
for flag in hide {
let span = Span::default();
let not_system = Node::Not { node: Box::new(is_flag("system", span)), span };
let not_flag = Node::Not { node: Box::new(is_flag(flag, span)), span };
root = Some(match root {
None => not_system,
Some(root) => Node::And { children: vec![root, not_system], span },
None => not_flag,
Some(root) => Node::And { children: vec![root, not_flag], span },
});
}
Self { root }
@@ -197,15 +230,15 @@ fn is_flag(flag: &str, span: Span) -> Node {
}
}
/// The query names `@is:system`, either way, so hiding System leaves it be.
fn mentions_system(root: &Node) -> bool {
/// The query names `@is:` one of `flags`, either way, so hiding it leaves it be.
fn mentions_flag(root: &Node, flags: &[&str]) -> bool {
let mut found = false;
root.walk(&mut |node| {
if let Node::Field { path, value, .. } = node
&& path.len() == 1
&& path[0].eq_ignore_ascii_case("is")
&& let FieldValue::Text { text, .. } | FieldValue::Wildcard { pattern: text } = value
&& text.eq_ignore_ascii_case("system")
&& flags.iter().any(|f| text.eq_ignore_ascii_case(f))
{
found = true;
}
@@ -295,7 +328,10 @@ fn prepare(node: &Node, now: DateTime<Utc>, issues: &mut Vec<Issue>) -> Node {
with(FieldValue::Wildcard { pattern: text.clone() })
}
("is", FieldValue::Text { text, .. }) if !text.is_empty() => {
let flag = text.to_lowercase();
let mut flag = text.to_lowercase();
if let Some((_, to)) = FLAG_ALIASES.iter().find(|(alias, _)| *alias == flag) {
flag = to.to_string();
}
if FLAGS.contains(&flag.as_str()) {
with(FieldValue::Wildcard { pattern: flag })
} else if FLAGS_NOT_YET.contains(&flag.as_str()) {
@@ -441,22 +477,22 @@ pub struct LiveSearch(RwLock<Option<Watched>>);
struct Watched {
key: String,
text: String,
hide_system: bool,
hidden: Hidden,
}
impl LiveSearch {
/// Matches flows against `text` from now on, their events naming `key`; None stops.
pub fn watch(&self, watched: Option<(String, String, bool)>) {
let watched = watched.map(|(key, text, hide_system)| Watched { key, text, hide_system });
pub fn watch(&self, watched: Option<(String, String, Hidden)>) {
let watched = watched.map(|(key, text, hidden)| Watched { key, text, hidden });
*self.0.write().unwrap_or_else(PoisonError::into_inner) = watched;
}
/// `flow` matched against the search shown, if any.
pub fn check(&self, flow: &Flow) -> Option<SearchMatch> {
let watched = self.0.read().unwrap_or_else(PoisonError::into_inner);
let Watched { key, text, hide_system } = watched.as_ref()?;
let Watched { key, text, hidden } = watched.as_ref()?;
// Prepared again for each flow, so `@time:<5m` counts back from now.
let matches = FlowQuery::new(text, *hide_system).matches(flow);
let matches = FlowQuery::new(text, *hidden).matches(flow);
Some(SearchMatch { key: key.clone(), matches })
}
@@ -812,7 +848,8 @@ fn flags(flow: &Flow) -> Vec<&'static str> {
&& flow.elapsed_ms.is_none()
}
"system" => is_system(flow.client_bundle_id.as_deref(), flow.client_path.as_deref()),
"tunnelled" => flow.tunnelled,
"encrypted" => flow.tunnelled,
"rejected" => flow.tls_rejected.is_some(),
"saved" => flow.saved_request_id.is_some(),
"read" => flow.agent_read,
"loop" => flow.status == Some(508),
@@ -970,7 +1007,11 @@ impl FlowColumns {
return name.to_string();
}
match name {
"cancelled" | "tunnelled" | "agent_read" => "0".into(),
"cancelled"
| "tunnelled"
| "agent_read"
| "reconnect_requested"
| "tunnel_excluded_built_in" => "0".into(),
_ => "NULL".into(),
}
}
@@ -1145,7 +1186,8 @@ impl Sql<'_> {
c("cancelled")
),
"system" => format!("is_system({}, {})", c("client_bundle_id"), c("client_path")),
"tunnelled" => c("tunnelled"),
"encrypted" => c("tunnelled"),
"rejected" => format!("{} IS NOT NULL", c("tls_rejected")),
"saved" => format!("{} IS NOT NULL", c("saved_request_id")),
"read" => c("agent_read"),
"loop" => "status = 508".into(),
+64 -6
View File
@@ -381,6 +381,53 @@ fn headers_on_either_side_or_one() {
);
}
/// `@is:encrypted` finds tunnelled connections, as `@is:tunnelled` did and still does, and
/// `@is:rejected` refused handshakes; hiding encrypted connections leaves them out unless the
/// query names them.
#[test]
fn encrypted_and_rejected() {
let rejected = Flow {
id: "fl_rejected".into(),
url: "https://my.1password.com".into(),
client_http_version: "HTTP/1.1".into(),
tls_rejected: Some("unknown_ca".into()),
error: Some("Rejected the proxy's certificate".into()),
elapsed_ms: Some(3),
started_at: (now() - Duration::minutes(5)).naive_utc(),
..Default::default()
};
let mut flows = fixture();
flows.push(rejected);
let (_dir, db, flows) = db_of(flows);
let find = |query: &str, hidden: Hidden| {
let prepared = FlowQuery::at(query, hidden, now());
let sql = sql_ids(&db, &prepared);
assert_eq!(sql, memory_ids(&flows, &prepared), "the two disagree on {query:?}");
sql
};
let none = Hidden::default();
let encrypted = Hidden { system: false, encrypted: true };
assert_eq!(find("@is:encrypted", none), ids(&["fl_tunnel"]));
assert_eq!(find("@is:tunnelled", none), ids(&["fl_tunnel"]));
assert_eq!(find("@is:TUNNELLED", none), ids(&["fl_tunnel"]));
assert_eq!(find("@is:rejected", none), ids(&["fl_rejected"]));
assert!(find("@is:error", none).contains(&"fl_rejected".to_string()));
// Hidden, unless named.
let all = find("", none);
let shown = find("", encrypted);
assert_eq!(shown.len(), all.len() - 1);
assert!(!shown.contains(&"fl_tunnel".to_string()));
assert_eq!(find("@is:encrypted", encrypted), ids(&["fl_tunnel"]));
assert_eq!(find("@is:tunnelled", encrypted), ids(&["fl_tunnel"]));
assert_eq!(find("bank", encrypted), Vec::<String>::new());
// Both hidden.
let both = find("", Hidden { system: true, encrypted: true });
assert_eq!(both.len(), all.len() - 2);
assert_eq!(diagnostics("@is:encrypted @is:rejected @is:tunnelled"), vec![]);
assert!(FLAGS.contains(&"encrypted") && FLAGS.contains(&"rejected"));
assert!(!FLAGS.contains(&"tunnelled"), "an alias, not offered");
}
#[test]
fn flags() {
assert_eq!(finds("@is:system"), ids(&["fl_daemon"]));
@@ -701,6 +748,10 @@ fn sample(count: usize) -> Vec<Flow> {
cancelled: rng.chance(10),
saved_request_id: rng.chance(15).then(|| "rq_x".to_string()),
tunnelled,
tls_rejected: rng
.chance(10)
.then(|| rng.pick(&["unknown_ca", "hang_up"]).to_string())
.filter(|_| !tunnelled),
agent_read: rng.chance(20),
..Default::default()
}
@@ -888,6 +939,12 @@ const BATTERY: &[&str] = &[
"@nope:x",
"-@nope:x",
"@status:4xx OR @is:tunnelled",
"@is:encrypted",
"-@is:encrypted",
"@is:rejected",
"@is:rejected OR @is:encrypted",
"-@is:rejected @is:error",
"@is:e*",
"(@status:4xx OR @is:error) -@method:post",
"-(@status:* OR @is:pending)",
"github -@host:github.com",
@@ -903,8 +960,9 @@ fn the_database_and_the_evaluator_find_the_same_flows() {
let (_dir, db, flows) = db_of(sample(400));
let mut checked = 0;
for query in BATTERY {
for hide_system in [false, true] {
let prepared = FlowQuery::at(query, hide_system, now());
for (system, encrypted) in [(false, false), (true, false), (false, true), (true, true)] {
let hidden = Hidden { system, encrypted };
let prepared = FlowQuery::at(query, hidden, now());
let sql = sql_ids(&db, &prepared);
let memory = memory_ids(&flows, &prepared);
if sql != memory {
@@ -915,14 +973,14 @@ fn the_database_and_the_evaluator_find_the_same_flows() {
.or(only_memory.first())
.map(|id| flows.iter().find(|f| &&f.id == id).cloned());
panic!(
"{query:?} (hide_system {hide_system}): only in SQL {only_sql:?}, \
"{query:?} ({hidden:?}): only in SQL {only_sql:?}, \
only in memory {only_memory:?}; e.g. {example:#?}"
);
}
checked += 1;
}
}
assert_eq!(checked, BATTERY.len() * 2);
assert_eq!(checked, BATTERY.len() * 4);
// The battery means something only if the sample spreads its answers.
let spread = BATTERY
.iter()
@@ -938,9 +996,9 @@ fn live_search_matches_as_the_list_does() {
let live = LiveSearch::default();
let flow = fixture().remove(0);
assert_eq!(live.check(&flow), None);
live.watch(Some(("k1".into(), "@status:2xx @host:*.github.com".into(), false)));
live.watch(Some(("k1".into(), "@status:2xx @host:*.github.com".into(), false.into())));
assert_eq!(live.check(&flow), Some(SearchMatch { key: "k1".into(), matches: true }));
live.watch(Some(("k2".into(), "@status:4xx".into(), false)));
live.watch(Some(("k2".into(), "@status:4xx".into(), false.into())));
let payload = live.payload(flow, true);
assert_eq!(payload.search, Some(SearchMatch { key: "k2".into(), matches: false }));
live.watch(None);
+3
View File
@@ -111,6 +111,9 @@ impl ClientDb {
body: &DecodedBody,
target: &SaveFlowRequest,
) -> Result<SaveFlowResponse, String> {
if flow.is_handshake_only() {
return Err(crate::NOT_A_REQUEST.to_string());
}
let db = self.query_manager.connect();
db.get_workspace(&target.workspace_id)
.map_err(|_| format!("No workspace {} in Yaak", target.workspace_id))?;
@@ -2,6 +2,7 @@
use crate::db::ProxyQueryManager;
use crate::flows::{self, FlowFilter};
use crate::query::Hidden;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_database::generate_prefixed_id;
@@ -115,13 +116,17 @@ pub fn delete(db: &ProxyQueryManager, id: &str) -> Result<bool, String> {
.map_err(|e| failed("delete", e))
}
/// Each saved query's count of flows, with System's left out when `hide_system`, as the list
/// would show it, read from one snapshot.
pub fn counts(db: &ProxyQueryManager, hide_system: bool) -> Result<Vec<SavedQueryCount>, String> {
/// Each saved query's count of flows, with what `hidden` says left out, as the list would show
/// it, read from one snapshot.
pub fn counts(db: &ProxyQueryManager, hidden: Hidden) -> Result<Vec<SavedQueryCount>, String> {
let saved = list(db)?;
let filters: Vec<FlowFilter> = saved
.iter()
.map(|s| FlowFilter { hide_system, ..FlowFilter::search(s.query.clone()) })
.map(|s| FlowFilter {
hide_system: hidden.system,
hide_encrypted: hidden.encrypted,
..FlowFilter::search(s.query.clone())
})
.collect();
let counts = flows::count_each(db, &filters)?;
Ok(saved
@@ -165,7 +170,7 @@ mod tests {
["Errors", "Fine"]
);
let counts = counts(&db, false).unwrap();
let counts = counts(&db, false.into()).unwrap();
assert_eq!(
counts,
vec![
+49 -9
View File
@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use ts_rs::TS;
use yaak_proxy::{BUILT_IN_EXCLUSIONS, DEFAULT_CAPTURE_BODY_LIMIT, DecryptEntry, DecryptPolicy};
use yaak_proxy::{
BUILT_IN_EXCLUSIONS, BuiltInKind, DEFAULT_CAPTURE_BODY_LIMIT, DecryptEntry, DecryptPolicy,
};
/// `settings.json` in the proxy data directory. Missing keys take their defaults.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -69,6 +71,30 @@ pub struct DecryptListEntry {
pub entry: String,
/// Shipped with the app: an exclusion of Apple's services or a pinned app's hosts.
pub built_in: bool,
/// Why a built-in exclusion ships. None for the user's own entries.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub kind: Option<ExclusionKind>,
}
/// Why a built-in exclusion ships.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_rpc.ts")]
pub enum ExclusionKind {
/// Apple's own services, which macOS daemons reach with pinned certificates.
Apple,
/// The hosts of an app that pins its certificates.
Pinned,
}
impl From<BuiltInKind> for ExclusionKind {
fn from(kind: BuiltInKind) -> Self {
match kind {
BuiltInKind::Apple => Self::Apple,
BuiltInKind::Pinned => Self::Pinned,
}
}
}
/// What HTTPS is decrypted: the user's entries in order, then the built-in exclusions not
@@ -115,12 +141,19 @@ impl ProxySettings {
/// The user's entries, then the built-in exclusions they kept.
pub fn decrypt_list(&self) -> DecryptList {
let user =
self.decrypt.iter().map(|e| DecryptListEntry { entry: e.clone(), built_in: false });
let user = self.decrypt.iter().map(|e| DecryptListEntry {
entry: e.clone(),
built_in: false,
kind: None,
});
let built_in = BUILT_IN_EXCLUSIONS
.iter()
.filter(|e| !self.decrypt_built_ins_removed.iter().any(|r| r == *e))
.map(|e| DecryptListEntry { entry: e.to_string(), built_in: true });
.filter(|(e, _)| !self.decrypt_built_ins_removed.iter().any(|r| r == e))
.map(|(e, kind)| DecryptListEntry {
entry: e.to_string(),
built_in: true,
kind: Some((*kind).into()),
});
DecryptList { entries: user.chain(built_in).collect() }
}
@@ -142,8 +175,8 @@ impl ProxySettings {
self.decrypt = user;
self.decrypt_built_ins_removed = BUILT_IN_EXCLUSIONS
.iter()
.filter(|e| !kept_built_ins.iter().any(|k| k == *e))
.map(|e| e.to_string())
.filter(|(e, _)| !kept_built_ins.iter().any(|k| k == e))
.map(|(e, _)| e.to_string())
.collect();
Ok(())
}
@@ -346,7 +379,10 @@ mod tests {
];
// Every built-in exclusion but zoom.us, kept.
entries.extend(
BUILT_IN_EXCLUSIONS.iter().filter(|e| **e != "!host:*.zoom.us").map(|e| e.to_string()),
BUILT_IN_EXCLUSIONS
.iter()
.filter(|(e, _)| *e != "!host:*.zoom.us")
.map(|(e, _)| e.to_string()),
);
settings.set_decrypt_list(&entries).unwrap();
assert_eq!(settings.decrypt, ["host:api.example.com", "*", "app:Google Chrome"]);
@@ -354,9 +390,13 @@ mod tests {
let list = settings.decrypt_list();
assert_eq!(
list.entries[0],
DecryptListEntry { entry: "host:api.example.com".into(), built_in: false }
DecryptListEntry { entry: "host:api.example.com".into(), built_in: false, kind: None }
);
assert!(list.entries[3].built_in);
assert_eq!(list.entries[3].entry, "!host:*.apple.com");
assert_eq!(list.entries[3].kind, Some(ExclusionKind::Apple));
let onepassword = list.entries.iter().find(|e| e.entry == "!host:*.1password.com");
assert_eq!(onepassword.unwrap().kind, Some(ExclusionKind::Pinned));
assert!(!list.entries.iter().any(|e| e.entry == "!host:*.zoom.us"));
// Written again, a built-in exclusion is restored.
+368
View File
@@ -0,0 +1,368 @@
//! The HTTPS connections the engine is passing through undecrypted right now, kept whether or
//! not flows are recorded, since a tunnel opened while paused has no flow. Each is added as its
//! `TunnelStart` is handled and removed at its `TunnelEnd`, never on the decrypted path. From
//! them and the decrypt list come the tunnels that would be decrypted if their client
//! reconnected, which the app may close so it does.
use crate::attribution::{Attribution, ClientApp};
use crate::models::TunnelReason;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Mutex, PoisonError};
use std::time::Duration;
use ts_rs::TS;
use yaak_proxy::{DecryptApp, DecryptEntry, DecryptPolicy, DecryptQuery};
/// An open tunnel, as the app is told of it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct OpenTunnel {
/// Its id for closing it, unique to this run of the engine, so one listed before the
/// engine restarted closes nothing after.
pub id: String,
/// The engine's own number for it, which starts again each run.
#[serde(skip)]
#[ts(skip)]
pub engine_id: u64,
/// The host it leads to, without an IPv6 address's brackets.
pub host: String,
pub port: u16,
/// The app that opened it, when known.
pub app_name: Option<String>,
pub app_bundle_id: Option<String>,
/// Its flow, when it was recorded.
pub flow_id: Option<String>,
pub reason: TunnelReason,
}
impl OpenTunnel {
/// `host:port`, as the engine keys its pass-through set, an IPv6 host in brackets.
pub fn address(&self) -> String {
if self.host.contains(':') {
format!("[{}]:{}", self.host, self.port)
} else {
format!("{}:{}", self.host, self.port)
}
}
fn query(&self) -> DecryptQuery {
DecryptQuery {
host: self.host.clone(),
port: self.port,
app: self.app_name.as_ref().map(|name| DecryptApp {
name: name.clone(),
bundle_id: self.app_bundle_id.clone(),
}),
}
}
}
/// Which open tunnels to consider: all of them, those one decrypt-list entry names, or the one
/// a flow records.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct TunnelScope {
/// An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
/// names. An exclusion, `!host:*.chase.com`, names those it would keep out.
#[ts(optional)]
pub entry: Option<String>,
/// Only the tunnel this flow records.
#[ts(optional)]
pub flow_id: Option<String>,
}
/// The open tunnels among `tunnels` in `scope` that `policy` would decrypt if their clients
/// reconnected. Those whose address `passthrough` holds, after a client rejected the proxy's
/// certificate, are left out, as their next connection would be passed through again; once the
/// address is forgotten, its tunnels count again.
pub fn reconnectable(
tunnels: &[OpenTunnel],
policy: &DecryptPolicy,
passthrough: &[String],
scope: &TunnelScope,
) -> Result<Vec<OpenTunnel>, String> {
let entry = scope.entry.as_deref().map(DecryptEntry::parse).transpose()?;
Ok(tunnels
.iter()
.filter(|t| scope.flow_id.as_ref().is_none_or(|f| t.flow_id.as_ref() == Some(f)))
.filter(|t| !passthrough.contains(&t.address()))
.filter(|t| {
let query = t.query();
entry.as_ref().is_none_or(|e| e.covers(&query)) && policy.decrypts(&query)
})
.cloned()
.collect())
}
/// The registry of open tunnels, shared by the context and the engine's event loop. Each start
/// of the engine begins a new run, its ids its own, and an event loop of an earlier run changes
/// nothing.
#[derive(Default)]
pub struct OpenTunnels(Mutex<Run>);
#[derive(Default)]
struct Run {
run: u64,
/// Random for each run, so ids from another run or process never match this one's.
token: String,
/// Finds the app behind a tunnel not known when it opened.
attribution: Option<Attribution>,
tunnels: HashMap<u64, Open>,
}
struct Open {
tunnel: OpenTunnel,
/// The client connection it came on, for finding its app.
connection: u64,
}
impl OpenTunnels {
fn lock(&self) -> std::sync::MutexGuard<'_, Run> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Starts a run for an engine just started, forgetting the last one's tunnels, which ended
/// with it. Returns the run, for the event loop's changes.
pub fn begin_run(&self, attribution: Option<Attribution>) -> u64 {
let mut run = self.lock();
run.run += 1;
run.token = yaak_database::generate_prefixed_id("tnl");
run.attribution = attribution;
run.tunnels.clear();
run.run
}
/// The engine stopped, and its tunnels with it. Its event loop's late changes, and ids it
/// gave out, apply to nothing from now on.
pub fn stop_run(&self) {
let mut run = self.lock();
run.run += 1;
run.token.clear();
run.attribution = None;
run.tunnels.clear();
}
/// The engine of `run` stopped, and its tunnels with it.
pub fn end_run(&self, run: u64) {
let mut current = self.lock();
if current.run == run {
current.tunnels.clear();
}
}
/// A tunnel opened in `run`, on client `connection`, its `id` given here from its
/// `engine_id`.
pub fn open(&self, run: u64, mut tunnel: OpenTunnel, connection: u64) {
let mut current = self.lock();
if current.run == run {
tunnel.id = format!("{}.{}", current.token, tunnel.engine_id);
current.tunnels.insert(tunnel.engine_id, Open { tunnel, connection });
}
}
/// The engine's numbers for those of `ids` this run gave out. Others are left out.
pub fn engine_ids(&self, ids: &[String]) -> Vec<u64> {
let current = self.lock();
if current.token.is_empty() {
return Vec::new();
}
ids.iter()
.filter_map(|id| id.strip_prefix(current.token.as_str())?.strip_prefix('.'))
.filter_map(|n| n.parse().ok())
.collect()
}
/// A tunnel of `run` closed.
pub fn close(&self, run: u64, id: u64) {
let mut current = self.lock();
if current.run == run {
current.tunnels.remove(&id);
}
}
/// The tunnels open now, oldest first, each with its app when it can be found without
/// waiting.
pub fn list(&self) -> Vec<OpenTunnel> {
let mut current = self.lock();
let Run { attribution, tunnels, .. } = &mut *current;
for open in tunnels.values_mut() {
if open.tunnel.app_name.is_none()
&& let Some(app) =
attribution.as_ref().and_then(|a| a.get(open.connection, Duration::ZERO))
{
set_app(&mut open.tunnel, app);
}
}
let mut list: Vec<OpenTunnel> = tunnels.values().map(|o| o.tunnel.clone()).collect();
list.sort_by_key(|t| t.engine_id);
list
}
}
/// Records the app that opened `tunnel`.
pub fn set_app(tunnel: &mut OpenTunnel, app: ClientApp) {
tunnel.app_name = Some(app.name);
tunnel.app_bundle_id = app.bundle_id;
}
#[cfg(test)]
mod tests {
use super::*;
use yaak_proxy::BUILT_IN_EXCLUSIONS;
fn tunnel(id: u64, host: &str, app: Option<&str>, reason: TunnelReason) -> OpenTunnel {
OpenTunnel {
id: String::new(),
engine_id: id,
host: host.into(),
port: 443,
app_name: app.map(String::from),
app_bundle_id: app.map(|a| format!("com.example.{}", a.to_lowercase())),
flow_id: Some(format!("fl_{id}")),
reason,
}
}
fn sample() -> Vec<OpenTunnel> {
use TunnelReason::*;
vec![
tunnel(1, "www.google.com", Some("Dia"), NotListed),
tunnel(2, "api.example.com", Some("Slack"), NotListed),
tunnel(3, "gateway.icloud.com", Some("Dia"), Excluded),
tunnel(4, "my.1password.com", Some("1Password"), Rejected),
tunnel(5, "www.chase.com", None, Excluded),
tunnel(6, "maps.google.com", None, NotListed),
]
}
fn policy(entries: &[&str]) -> DecryptPolicy {
let mut all = entries.to_vec();
all.extend(BUILT_IN_EXCLUSIONS.iter().map(|(e, _)| *e));
DecryptPolicy::parse(&all).unwrap()
}
fn ids(
policy: &DecryptPolicy,
passthrough: &[&str],
entry: Option<&str>,
flow_id: Option<&str>,
) -> Vec<u64> {
let passthrough: Vec<String> = passthrough.iter().map(|p| p.to_string()).collect();
let scope =
TunnelScope { entry: entry.map(String::from), flow_id: flow_id.map(String::from) };
reconnectable(&sample(), policy, &passthrough, &scope)
.unwrap()
.iter()
.map(|t| t.engine_id)
.collect()
}
#[test]
fn host_entries() {
let p = policy(&["host:www.google.com"]);
assert_eq!(ids(&p, &[], None, None), [1]);
let p = policy(&["host:*.google.com"]);
assert_eq!(ids(&p, &[], None, None), [1, 6]);
// Narrowed to one entry of several.
let p = policy(&["host:*.google.com", "host:api.example.com"]);
assert_eq!(ids(&p, &[], Some("host:api.example.com"), None), [2]);
assert_eq!(ids(&p, &[], Some("api.example.com"), None), [2], "as typed");
assert_eq!(ids(&p, &[], Some("host:*.google.com"), None), [1, 6]);
}
#[test]
fn app_entries() {
// Dia's iCloud tunnel too: an app entry outranks the built-in exclusion.
let p = policy(&["app:Dia"]);
assert_eq!(ids(&p, &[], None, None), [1, 3]);
let p = policy(&["app:com.example.slack"]);
assert_eq!(ids(&p, &[], Some("app:com.example.slack"), None), [2]);
}
#[test]
fn everything_and_exclusions() {
// The built-in exclusions keep iCloud and 1Password out.
let p = policy(&["*"]);
assert_eq!(ids(&p, &[], None, None), [1, 2, 5, 6]);
assert_eq!(ids(&p, &[], Some("*"), None), [1, 2, 5, 6]);
// The person's own exclusion.
let p = policy(&["*", "!host:*.chase.com"]);
assert_eq!(ids(&p, &[], None, None), [1, 2, 6]);
// A removed exclusion, asked about by name: what it kept out.
let p = policy(&["*"]);
assert_eq!(ids(&p, &[], Some("!host:*.chase.com"), None), [5]);
assert!(reconnectable(&sample(), &p, &[], &TunnelScope::search_entry("host:a b")).is_err());
}
#[test]
fn rejected_pass_throughs_are_left_out() {
let p = policy(&["*", "host:my.1password.com"]);
let rejected = "my.1password.com:443";
assert!(!ids(&p, &[rejected], None, None).contains(&4), "passed through still");
// Forgotten, its next connection would be decrypted.
assert!(ids(&p, &[], None, None).contains(&4));
// An address passed through since it opened.
assert_eq!(ids(&p, &[rejected, "www.google.com:443"], None, None), [2, 5, 6]);
}
#[test]
fn one_flow() {
let p = policy(&["*"]);
assert_eq!(ids(&p, &[], None, Some("fl_2")), [2]);
assert!(ids(&p, &[], None, Some("fl_3")).is_empty(), "excluded still");
assert!(ids(&p, &[], None, Some("fl_nope")).is_empty());
}
#[test]
fn nothing_listed_reconnects_nothing() {
assert!(ids(&policy(&[]), &[], None, None).is_empty());
}
#[test]
fn the_registry_keeps_one_run() {
let tunnels = OpenTunnels::default();
let first = tunnels.begin_run(None);
tunnels.open(first, tunnel(1, "a.com", None, TunnelReason::NotListed), 1);
tunnels.open(first, tunnel(2, "b.com", None, TunnelReason::NotListed), 2);
tunnels.close(first, 1);
assert_eq!(tunnels.list().iter().map(|t| t.engine_id).collect::<Vec<_>>(), [2]);
let id = tunnels.list()[0].id.clone();
assert!(id.starts_with("tnl_") && id.ends_with(".2"), "{id}");
assert_eq!(tunnels.engine_ids(&[id.clone(), "tnl_x.2".into(), "2".into()]), [2]);
// A new run starts empty, and the old one's late events change nothing.
let second = tunnels.begin_run(None);
tunnels.open(first, tunnel(3, "c.com", None, TunnelReason::NotListed), 3);
tunnels.open(second, tunnel(2, "d.com", None, TunnelReason::NotListed), 4);
tunnels.close(first, 2);
tunnels.end_run(first);
assert_eq!(tunnels.list().iter().map(|t| t.host.as_str()).collect::<Vec<_>>(), ["d.com"]);
// An id the first run gave out names nothing now, though its number is reused.
assert!(tunnels.engine_ids(&[id]).is_empty());
tunnels.end_run(second);
assert!(tunnels.list().is_empty());
// Stopped, a run's late events and ids apply to nothing.
let third = tunnels.begin_run(None);
tunnels.open(third, tunnel(1, "e.com", None, TunnelReason::NotListed), 5);
let id = tunnels.list()[0].id.clone();
tunnels.stop_run();
assert!(tunnels.list().is_empty());
tunnels.open(third, tunnel(2, "f.com", None, TunnelReason::NotListed), 6);
assert!(tunnels.list().is_empty());
assert!(tunnels.engine_ids(&[id, ".1".into()]).is_empty());
}
#[test]
fn addresses_as_the_engine_keys_them() {
assert_eq!(tunnel(1, "a.com", None, TunnelReason::NotListed).address(), "a.com:443");
assert_eq!(tunnel(1, "::1", None, TunnelReason::NotListed).address(), "[::1]:443");
}
impl TunnelScope {
fn search_entry(entry: &str) -> Self {
Self { entry: Some(entry.into()), flow_id: None }
}
}
}
+176 -1
View File
@@ -494,6 +494,103 @@ async fn the_app_takes_over_from_the_cli() {
assert!(control::live_owner(dir.path()).is_none());
}
/// Tunnels listed while the CLI owned the engine can't be closed once the app has taken over,
/// by id or by flow, though the app's engine numbers its tunnels from 1 again: a request closes
/// only tunnels of the engine run it names. An entry names what is open now.
#[tokio::test(flavor = "multi_thread")]
async fn tunnels_listed_before_the_app_took_over_close_nothing_after() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli);
let mut server = ControlServer::start(cli.clone()).unwrap();
let stop_rx = server.take_stop_rx().unwrap();
let daemon = std::thread::spawn(move || {
stop_rx.recv().unwrap();
drop(server);
});
cli.start().unwrap();
let app = ctx(dir.path(), client_dir.path());
let echo = TcpListener::bind("127.0.0.1:0").await.unwrap();
let echo_port = echo.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
let (mut tcp, _) = echo.accept().await.unwrap();
tokio::spawn(async move {
let (mut read, mut write) = tcp.split();
let _ = tokio::io::copy(&mut read, &mut write).await;
});
}
});
// The address the echo listens on, not `localhost`, which Windows tries as `::1` first and
// takes seconds to give up on.
let target = format!("127.0.0.1:{echo_port}");
let open = |port: u16| {
let target = target.clone();
async move {
let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
tcp.write_all(connect.as_bytes()).await.unwrap();
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
head.push(tcp.read_u8().await.unwrap());
}
tcp
}
};
let alive = |mut tcp: tokio::net::TcpStream| async move {
let mut echoed = [0u8; 4];
let ok = tcp.write_all(b"ping").await.is_ok() && tcp.read_exact(&mut echoed).await.is_ok();
(ok, tcp)
};
let listed = |app: &ProxyCtx| {
let app = app.clone();
async move {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let tunnels = rpc(&app, "list_reconnectable_tunnels", json!({})).await.unwrap();
if tunnels.as_array().unwrap().len() == 1 && !tunnels[0]["flowId"].is_null() {
return tunnels[0].clone();
}
assert!(Instant::now() < deadline, "the tunnel never showed: {tunnels}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
};
// The CLI's tunnel, listed through it.
let (ok, _old) = alive(open(cli.port().unwrap()).await).await;
assert!(ok);
rpc(&app, "edit_decrypt_list", json!({"add": ["host:127.0.0.1"]})).await.unwrap();
let old = listed(&app).await;
// The app takes over, and its first tunnel is number 1 again.
assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true));
daemon.join().unwrap();
rpc(&app, "edit_decrypt_list", json!({"remove": ["host:127.0.0.1"]})).await.unwrap();
let (ok, new) = alive(open(app.port().unwrap()).await).await;
assert!(ok);
rpc(&app, "edit_decrypt_list", json!({"add": ["host:127.0.0.1"]})).await.unwrap();
let current = listed(&app).await;
assert_ne!(current["id"], old["id"]);
assert_ne!(current["flowId"], old["flowId"]);
let by_id = rpc(&app, "reconnect_tunnels", json!({"ids": [old["id"]]})).await.unwrap();
assert_eq!(by_id["closed"], 0);
let by_flow = rpc(&app, "reconnect_tunnels", json!({"flowId": old["flowId"]})).await.unwrap();
assert_eq!(by_flow["closed"], 0);
let (ok, mut new) = alive(new).await;
assert!(ok, "the app's tunnel was closed by the CLI's ids");
// An entry is resolved against what is open now.
let by_entry = rpc(&app, "reconnect_tunnels", json!({"entry": "host:127.0.0.1"})).await;
assert_eq!(by_entry.unwrap()["closed"], 1);
let mut rest = Vec::new();
let _ = new.read_to_end(&mut rest).await;
app.stop().unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn the_save_dialog_reads_workspaces_previews_and_saves() {
let dir = tempfile::tempdir().unwrap();
@@ -809,9 +906,11 @@ async fn the_decrypt_list_is_read_and_replaced_over_the_control_api() {
]),
"every built-in exclusion left out is removed"
);
// Saved, it says how many open connections stay encrypted: none, as none are open.
assert_eq!(list["stillEncrypted"], 0);
let read: Value =
http.get(&url).bearer_auth(&info.token).send().await.unwrap().json().await.unwrap();
assert_eq!(read, list);
assert_eq!(read["entries"], list["entries"]);
let bad = http
.put(&url)
@@ -859,6 +958,82 @@ async fn the_decrypt_list_reaches_the_cli_that_owns_the_engine() {
}
}
/// While the CLI owns the engine, the app reads its open tunnels and pass-through set and
/// reconnects tunnels there, through the control API.
#[tokio::test(flavor = "multi_thread")]
async fn open_tunnels_are_reconnected_in_the_cli_that_owns_the_engine() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let dir = tempfile::tempdir().unwrap();
let client_dir = tempfile::tempdir().unwrap();
let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli);
let _server = ControlServer::start(cli.clone()).unwrap();
cli.start().unwrap();
let app = ctx(dir.path(), client_dir.path());
// An open tunnel, which the empty list leaves encrypted.
let echo = TcpListener::bind("127.0.0.1:0").await.unwrap();
let echo_port = echo.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
let (mut tcp, _) = echo.accept().await.unwrap();
tokio::spawn(async move {
let (mut read, mut write) = tcp.split();
let _ = tokio::io::copy(&mut read, &mut write).await;
});
}
});
let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", cli.port().unwrap())).await.unwrap();
let target = format!("localhost:{echo_port}");
tcp.write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes())
.await
.unwrap();
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
head.push(tcp.read_u8().await.unwrap());
}
tcp.write_all(b"ping").await.unwrap();
let mut echoed = [0u8; 4];
tcp.read_exact(&mut echoed).await.unwrap();
rpc(&app, "edit_decrypt_list", json!({"add": ["host:localhost"]})).await.unwrap();
// Known once the CLI's event loop has seen it open.
let deadline = Instant::now() + Duration::from_secs(5);
let tunnels = loop {
let tunnels = rpc(&app, "list_reconnectable_tunnels", json!({})).await.unwrap();
if !tunnels.as_array().unwrap().is_empty() {
break tunnels;
}
assert!(Instant::now() < deadline, "the tunnel never showed");
tokio::time::sleep(Duration::from_millis(20)).await;
};
let saved = rpc(&app, "edit_decrypt_list", json!({"add": ["host:localhost"]})).await.unwrap();
assert_eq!(saved["stillEncrypted"], 1, "counted where the engine runs");
assert_eq!(tunnels.as_array().unwrap().len(), 1);
assert_eq!(tunnels[0]["host"], "localhost");
let scope = json!({"entry": "host:localhost"});
let closed = rpc(&app, "reconnect_tunnels", scope).await.unwrap();
assert_eq!(closed["closed"], 1);
let mut rest = Vec::new();
tcp.read_to_end(&mut rest).await.unwrap();
let set = rpc(&app, "list_passthrough", json!({})).await.unwrap();
assert_eq!(set["addresses"], json!([]));
let forgot = rpc(&app, "forget_passthrough", json!({"address": target})).await.unwrap();
assert_eq!(forgot, json!({"forgotten": false, "addresses": []}));
// The routes themselves.
let info = control::read(dir.path()).unwrap();
let http = reqwest::Client::builder().no_proxy().build().unwrap();
let get =
|path: &str| http.get(format!("{}{path}", info.base_url())).bearer_auth(&info.token).send();
let none: Value =
get("/tunnels/reconnectable?entry=host%3Alocalhost").await.unwrap().json().await.unwrap();
assert_eq!(none, json!([]));
assert_eq!(get("/tunnels/reconnectable?entry=host%3Aa+b").await.unwrap().status(), 400);
let set: Value = get("/passthrough").await.unwrap().json().await.unwrap();
assert_eq!(set, json!({"addresses": []}));
}
#[tokio::test(flavor = "multi_thread")]
async fn the_decrypt_list_is_edited_over_the_control_api() {
let dir = tempfile::tempdir().unwrap();
@@ -0,0 +1,376 @@
//! HTTPS decryption through the engine: why each connection was tunnelled, a client's refused
//! handshake as a flow, the addresses passed through after it, and the open tunnels the decrypt
//! list would decrypt if their clients reconnected.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rustls::pki_types::ServerName;
use rustls::{ClientConfig, RootCertStore};
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_rustls::TlsConnector;
use yaak_proxy_lib::models::{Flow, TunnelReason};
use yaak_proxy_lib::settings::ProxySettings;
use yaak_proxy_lib::{MasterKeySource, ProxyCtx};
use yaak_rpc::{RpcEvent, RpcEventEmitter};
async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String> {
yaak_proxy_lib::build_router().dispatch(cmd, payload, ctx).await.map_err(|e| e.message)
}
/// A started engine with `decrypt` as the list, and the events it sends the app.
fn started(decrypt: &[&str]) -> (tempfile::TempDir, ProxyCtx, std::sync::mpsc::Receiver<RpcEvent>) {
let dir = tempfile::tempdir().unwrap();
let settings = ProxySettings {
port: 0,
system_proxy_at_launch: false,
decrypt: decrypt.iter().map(|e| e.to_string()).collect(),
..Default::default()
};
settings.save(&dir.path().join("settings.json")).unwrap();
let (events, event_rx) = RpcEventEmitter::new();
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
ctx.start().unwrap();
(dir, ctx, event_rx)
}
/// A TCP server that echoes what it reads, then closes once the client does.
async fn spawn_echo() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (mut tcp, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let (mut read, mut write) = tcp.split();
let _ = tokio::io::copy(&mut read, &mut write).await;
});
}
});
addr
}
/// Opens a CONNECT tunnel to `target` through the engine of `ctx`.
async fn connect_through(ctx: &ProxyCtx, target: &str) -> TcpStream {
let mut tcp = TcpStream::connect(("127.0.0.1", ctx.port().unwrap())).await.unwrap();
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
tcp.write_all(connect.as_bytes()).await.unwrap();
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
head.push(tcp.read_u8().await.unwrap());
}
assert!(head.starts_with(b"HTTP/1.1 200"), "{}", String::from_utf8_lossy(&head));
tcp
}
/// A tunnel whose bytes have crossed both ways, so the engine has judged it and is relaying.
async fn open_tunnel(ctx: &ProxyCtx, target: &str) -> TcpStream {
let mut tcp = connect_through(ctx, target).await;
tcp.write_all(b"ping").await.unwrap();
let mut echoed = [0u8; 4];
tcp.read_exact(&mut echoed).await.unwrap();
tcp
}
/// TLS through the engine to `target` from a client that trusts no CA at all, so it refuses the
/// engine's certificate with an `unknown_ca` alert.
async fn refuse_certificate(ctx: &ProxyCtx, target: &str) {
let config =
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(RootCertStore::empty())
.with_no_client_auth();
let tcp = connect_through(ctx, target).await;
let name = ServerName::try_from("localhost").unwrap();
let tls = TlsConnector::from(Arc::new(config)).connect(name, tcp).await;
assert!(tls.is_err(), "trusting nothing, the client refuses the engine's certificate");
}
fn flows(ctx: &ProxyCtx) -> Vec<Flow> {
ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap())
}
async fn wait_for(ctx: &ProxyCtx, what: &str, check: impl Fn(&Flow) -> bool) -> Flow {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(flow) = flows(ctx).into_iter().find(|f| check(f)) {
return flow;
}
assert!(Instant::now() < deadline, "no flow {what}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
/// Waits until `check` holds of what `list_reconnectable_tunnels` returns for `scope`.
async fn wait_for_tunnels(ctx: &ProxyCtx, scope: Value, check: impl Fn(&[Value]) -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let tunnels = rpc(ctx, "list_reconnectable_tunnels", scope.clone()).await.unwrap();
if check(tunnels.as_array().unwrap()) {
return;
}
assert!(Instant::now() < deadline, "tunnels for {scope} stayed {tunnels}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
async fn reconnectable(ctx: &ProxyCtx, scope: Value) -> Vec<Value> {
let tunnels = rpc(ctx, "list_reconnectable_tunnels", scope).await.unwrap();
tunnels.as_array().unwrap().clone()
}
#[tokio::test(flavor = "multi_thread")]
async fn a_tunnel_records_why_it_was_not_decrypted() {
let (_dir, ctx, _events) = started(&["host:localhost", "!host:localhost"]);
let echo = spawn_echo().await;
// Nothing names 127.0.0.1.
let _plain = open_tunnel(&ctx, &format!("127.0.0.1:{}", echo.port())).await;
let url = format!("https://127.0.0.1:{}", echo.port());
let flow = wait_for(&ctx, "to 127.0.0.1", |f| f.url == url).await;
assert_eq!(flow.tunnel_reason, Some(TunnelReason::NotListed));
assert_eq!((flow.tunnel_excluded_by, flow.tunnel_rejected_by), (None, None));
// The person's exclusion beats their entry.
let _excluded = open_tunnel(&ctx, &format!("localhost:{}", echo.port())).await;
let url = format!("https://localhost:{}", echo.port());
let flow = wait_for(&ctx, "to localhost", |f| f.url == url).await;
assert_eq!(flow.tunnel_reason, Some(TunnelReason::Excluded));
assert_eq!(flow.tunnel_excluded_by.as_deref(), Some("!host:localhost"));
assert!(!flow.tunnel_excluded_built_in);
let encrypted = rpc(&ctx, "list_flows", json!({"query": "@is:encrypted"})).await.unwrap();
assert_eq!(encrypted["flows"].as_array().unwrap().len(), 2);
let flow = &encrypted["flows"][0];
assert_eq!(flow["tunnelReason"], "excluded");
assert_eq!(flow["tunnelExcludedBy"], "!host:localhost");
}
/// A client refusing the certificate is one flow per address, however many refuse together,
/// and the tunnels passed through after it name it.
#[tokio::test(flavor = "multi_thread")]
async fn a_refused_handshake_is_one_flow_per_address() {
let (_dir, ctx, events) = started(&["*"]);
let echo = spawn_echo().await;
let target = format!("localhost:{}", echo.port());
let url = format!("https://{target}");
refuse_certificate(&ctx, &target).await;
let rejected = wait_for(&ctx, "refused", |f| f.tls_rejected.is_some()).await;
assert_eq!(rejected.url, url);
assert_eq!(rejected.method, "");
assert_eq!(rejected.tls_rejected.as_deref(), Some("unknown_ca"));
assert_eq!(rejected.error.as_deref(), Some("Rejected the proxy's certificate"));
assert!(!rejected.tunnelled);
assert!(rejected.elapsed_ms.is_some(), "ended, not pending");
// No request was sent, so there is none to save.
let preview = rpc(&ctx, "preview_save", json!({"flowId": rejected.id})).await;
assert!(preview.unwrap_err().starts_with("Not a request"));
// Passed through from now on, naming the refusal.
let _tunnel = open_tunnel(&ctx, &target).await;
let tunnel = wait_for(&ctx, "passed through", |f| f.tunnelled && f.url == url).await;
assert_eq!(tunnel.tunnel_reason, Some(TunnelReason::Rejected));
assert_eq!(tunnel.tunnel_rejected_by.as_deref(), Some(rejected.id.as_str()));
// It would be passed through again, so it is no tunnel to reconnect.
assert!(reconnectable(&ctx, json!({})).await.is_empty());
// Several refusing at once to another address make one flow too.
let other = format!("127.0.0.1:{}", echo.port());
let mut tunnels = Vec::new();
for _ in 0..6 {
tunnels.push(connect_through(&ctx, &other).await);
}
let config = Arc::new(
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.unwrap()
.with_root_certificates(RootCertStore::empty())
.with_no_client_auth(),
);
let attempts: Vec<_> = tunnels
.into_iter()
.map(|tcp| {
let connector = TlsConnector::from(config.clone());
let name = ServerName::try_from("localhost").unwrap();
tokio::spawn(async move { connector.connect(name, tcp).await.map(drop) })
})
.collect();
for attempt in attempts {
let _ = attempt.await.unwrap();
}
let other_url = format!("https://{other}");
wait_for(&ctx, "refused at once", |f| f.url == other_url && f.tls_rejected.is_some()).await;
tokio::time::sleep(Duration::from_millis(200)).await;
let refused: Vec<Flow> = flows(&ctx).into_iter().filter(|f| f.tls_rejected.is_some()).collect();
assert_eq!(refused.len(), 2, "one for each address: {refused:#?}");
let listed = rpc(&ctx, "list_flows", json!({"query": "@is:rejected"})).await.unwrap();
assert_eq!(listed["flows"].as_array().unwrap().len(), 2);
// The set, read and changed.
let set = rpc(&ctx, "list_passthrough", json!({})).await.unwrap();
assert_eq!(set["addresses"], json!([other, target]));
let pushed: Vec<RpcEvent> =
events.try_iter().filter(|e| e.event == "passthrough_changed").collect();
assert_eq!(pushed.len(), 2, "one push as each address was added");
assert_eq!(pushed[1].payload["addresses"], json!([other, target]));
// Forgotten, by the flow's authority, the address is intercepted again, and a refusal is
// another flow.
let forgot = rpc(&ctx, "forget_passthrough", json!({"address": target})).await.unwrap();
assert_eq!(forgot, json!({"forgotten": true, "addresses": [other]}));
let pushed: Vec<RpcEvent> =
events.try_iter().filter(|e| e.event == "passthrough_changed").collect();
assert_eq!(pushed.len(), 1);
assert_eq!(pushed[0].payload["addresses"], json!([other]));
// Its tunnel still open would now be decrypted if it reconnected.
let open = reconnectable(&ctx, json!({"flowId": tunnel.id})).await;
assert_eq!(open.len(), 1, "forgotten, its tunnel counts again");
let again = rpc(&ctx, "forget_passthrough", json!({"address": target})).await.unwrap();
assert_eq!(again["forgotten"], false);
assert!(rpc(&ctx, "forget_passthrough", json!({"address": ""})).await.is_err());
refuse_certificate(&ctx, &target).await;
let deadline = Instant::now() + Duration::from_secs(5);
while flows(&ctx).iter().filter(|f| f.url == url && f.tls_rejected.is_some()).count() < 2 {
assert!(Instant::now() < deadline, "no second refusal");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
/// Open tunnels are kept whether or not flows are recorded; saving the list closes none of
/// them but counts those it would decrypt, and reconnecting closes them by flow, by entry or
/// all at once.
#[tokio::test(flavor = "multi_thread")]
async fn open_tunnels_are_reconnected_only_when_asked() {
let (_dir, ctx, _events) = started(&[]);
let echo = spawn_echo().await;
let local = format!("localhost:{}", echo.port());
let loopback = format!("127.0.0.1:{}", echo.port());
let mut a = open_tunnel(&ctx, &local).await;
let a_flow = wait_for(&ctx, "a", |f| f.url == format!("https://{local}")).await;
let mut b = open_tunnel(&ctx, &loopback).await;
let b_flow = wait_for(&ctx, "b", |f| f.url == format!("https://{loopback}")).await;
// Paused, a tunnel has no flow, and is kept all the same.
rpc(&ctx, "set_recording", json!({"enabled": false})).await.unwrap();
let mut c = open_tunnel(&ctx, &local).await;
// Nothing is listed, so nothing would be decrypted.
assert!(reconnectable(&ctx, json!({})).await.is_empty());
let saved = rpc(&ctx, "edit_decrypt_list", json!({"add": ["host:localhost"]})).await.unwrap();
assert!(saved["stillEncrypted"].as_u64().unwrap() <= 2);
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 2).await;
let listed = reconnectable(&ctx, json!({})).await;
assert_eq!(listed[0]["flowId"], json!(a_flow.id));
assert_eq!(listed[0]["host"], "localhost");
assert_eq!(listed[0]["port"], echo.port());
assert_eq!(listed[0]["reason"], "not_listed");
assert_eq!(listed[1]["flowId"], Value::Null, "opened while paused");
// Saving counts what stays encrypted, and closes nothing.
let saved = rpc(&ctx, "edit_decrypt_list", json!({"add": ["127.0.0.1"]})).await.unwrap();
assert_eq!(saved["stillEncrypted"], 3);
for tcp in [&mut a, &mut b, &mut c] {
tcp.write_all(b"ping").await.unwrap();
let mut echoed = [0u8; 4];
tcp.read_exact(&mut echoed).await.unwrap();
}
// Narrowed to an entry, or to a flow.
let entry = reconnectable(&ctx, json!({"entry": "host:127.0.0.1"})).await;
assert_eq!(entry.len(), 1);
assert_eq!(entry[0]["flowId"], json!(b_flow.id));
assert_eq!(reconnectable(&ctx, json!({"flowId": a_flow.id})).await.len(), 1);
assert!(rpc(&ctx, "list_reconnectable_tunnels", json!({"entry": "host:a b"})).await.is_err());
// By flow.
let closed = rpc(&ctx, "reconnect_tunnels", json!({"flowId": a_flow.id})).await.unwrap();
assert_eq!(closed, json!({"closed": 1}));
let mut rest = Vec::new();
a.read_to_end(&mut rest).await.unwrap();
let ended = wait_for(&ctx, "a ended", |f| f.id == a_flow.id && f.elapsed_ms.is_some()).await;
assert!(ended.reconnect_requested);
assert_eq!(ended.error, None);
// By entry.
let closed = rpc(&ctx, "reconnect_tunnels", json!({"entry": "127.0.0.1"})).await.unwrap();
assert_eq!(closed["closed"], 1);
b.read_to_end(&mut rest).await.unwrap();
let ended = wait_for(&ctx, "b ended", |f| f.id == b_flow.id && f.elapsed_ms.is_some()).await;
assert!(ended.reconnect_requested);
// All, and by id what is closed already.
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 1).await;
let id = reconnectable(&ctx, json!({})).await[0]["id"].clone();
let closed = rpc(&ctx, "reconnect_tunnels", json!({})).await.unwrap();
assert_eq!(closed["closed"], 1);
c.read_to_end(&mut rest).await.unwrap();
wait_for_tunnels(&ctx, json!({}), |t| t.is_empty()).await;
let closed = rpc(&ctx, "reconnect_tunnels", json!({"ids": [id]})).await.unwrap();
assert_eq!(closed["closed"], 0);
// An id listed before the engine restarted closes nothing after, though the new run
// numbers its tunnels from 1 again. Each is opened while its host is unlisted.
let unlisted = || rpc(&ctx, "edit_decrypt_list", json!({"remove": ["host:127.0.0.1"]}));
let listed = || rpc(&ctx, "edit_decrypt_list", json!({"add": ["host:127.0.0.1"]}));
unlisted().await.unwrap();
let mut d = open_tunnel(&ctx, &loopback).await;
listed().await.unwrap();
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 1).await;
let old = reconnectable(&ctx, json!({})).await[0]["id"].clone();
ctx.stop().unwrap();
// Stopped, nothing is open, at once, whatever its event loop has still to read.
assert!(reconnectable(&ctx, json!({})).await.is_empty());
assert_eq!(listed().await.unwrap()["stillEncrypted"], 0);
assert_eq!(rpc(&ctx, "reconnect_tunnels", json!({"ids": [old]})).await.unwrap()["closed"], 0);
assert_eq!(rpc(&ctx, "list_passthrough", json!({})).await.unwrap()["addresses"], json!([]));
let forgot = rpc(&ctx, "forget_passthrough", json!({"address": "localhost"})).await.unwrap();
assert_eq!(forgot, json!({"forgotten": false, "addresses": []}));
d.read_to_end(&mut rest).await.unwrap();
ctx.start().unwrap();
unlisted().await.unwrap();
let mut e = open_tunnel(&ctx, &loopback).await;
listed().await.unwrap();
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 1).await;
assert_ne!(reconnectable(&ctx, json!({})).await[0]["id"], old);
let closed = rpc(&ctx, "reconnect_tunnels", json!({"ids": [old]})).await.unwrap();
assert_eq!(closed["closed"], 0);
e.write_all(b"ping").await.unwrap();
let mut echoed = [0u8; 4];
e.read_exact(&mut echoed).await.unwrap();
}
/// Dropping the last context stops its engine, which its event loop does not keep alive.
#[tokio::test(flavor = "multi_thread")]
async fn dropping_the_context_stops_the_engine() {
let (_dir, ctx, _events) = started(&[]);
let port = ctx.port().unwrap();
drop(ctx);
let deadline = Instant::now() + Duration::from_secs(5);
while TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
assert!(Instant::now() < deadline, "the engine still listens on {port}");
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
/// A tunnel that ends on its own is no longer open, and is not marked for a reconnect.
#[tokio::test(flavor = "multi_thread")]
async fn a_closed_tunnel_leaves_the_registry() {
let (_dir, ctx, _events) = started(&["*"]);
let echo = spawn_echo().await;
// Kept out by the built-in exclusions' rival, an exclusion of the person's.
rpc(&ctx, "edit_decrypt_list", json!({"add": ["!host:localhost"]})).await.unwrap();
let tcp = open_tunnel(&ctx, &format!("localhost:{}", echo.port())).await;
let url = format!("https://localhost:{}", echo.port());
wait_for(&ctx, "open", |f| f.url == url).await;
let saved = rpc(&ctx, "edit_decrypt_list", json!({"remove": ["!host:localhost"]})).await;
assert_eq!(saved.unwrap()["stillEncrypted"], 1);
drop(tcp);
let ended = wait_for(&ctx, "ended", |f| f.url == url && f.elapsed_ms.is_some()).await;
assert!(!ended.reconnect_requested);
wait_for_tunnels(&ctx, json!({}), |t| t.is_empty()).await;
}
+4 -3
View File
@@ -530,16 +530,17 @@ async fn the_decrypt_list_rpcs_save_and_apply() {
json!([
{"entry": "host:api.example.com", "builtIn": false},
{"entry": "app:Google Chrome", "builtIn": false},
{"entry": "!host:*.apple.com", "builtIn": true},
{"entry": "!host:*.apple.com", "builtIn": true, "kind": "apple"},
])
);
assert_eq!(list["stillEncrypted"], 0);
let saved = ProxySettings::load(&dir.path().join("settings.json")).unwrap();
assert_eq!(saved.decrypt, ["host:api.example.com", "app:Google Chrome"]);
assert!(saved.decrypt_built_ins_removed.contains(&"!host:*.icloud.com".to_string()));
let invalid = rpc(&ctx, "set_decrypt_list", json!({"entries": ["host:a b"]})).await;
assert!(invalid.unwrap_err().contains("Invalid host"));
assert_eq!(rpc(&ctx, "get_decrypt_list", json!({})).await.unwrap(), list);
assert_eq!(rpc(&ctx, "get_decrypt_list", json!({})).await.unwrap()["entries"], list["entries"]);
// Applied to the running engine's next connection: decrypting everything intercepts a
// CONNECT, which a client that does not trust the CA then rejects.
@@ -629,7 +630,7 @@ async fn decrypt_list_edits_apply_to_the_latest_list_and_are_announced() {
let changes: Vec<_> =
event_rx.try_iter().filter(|e| e.event == "decrypt_list_changed").collect();
assert_eq!(changes.len(), 3);
assert_eq!(changes[2].payload["list"], list);
assert_eq!(changes[2].payload["list"]["entries"], list["entries"]);
}
/// Capture throughput through the engine, end to end: 3,000 requests, 16 at a time over
+1
View File
@@ -99,5 +99,6 @@ yaak-system-appearance = { workspace = true }
yaak-sync = { workspace = true }
yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
yaak-updater = { workspace = true }
yaak-window = { workspace = true }
yaak-ws = { workspace = true }
-20
View File
@@ -6,26 +6,6 @@ export type PluginUpdateInfo = { name: string; currentVersion: string; latestVer
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
export type UpdateInfo = {
replyEventId: string;
version: string;
downloaded: boolean;
/**
* How this update gets applied. Anything but `Integrated` means the app can't do it
* itself and the user is told how to update instead.
*/
install: UpdateInstall;
};
/**
* How an update can be applied to this install.
*/
export type UpdateInstall = "integrated" | "flatpak" | "manual";
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
export type UpdateResponseAction = "install" | "skip";
export type YaakNotification = {
timestamp: string;
timeout: number | null;
+2 -1
View File
@@ -1,4 +1,5 @@
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
// desktop-only surface: updater and notification types.
// desktop-only surface: plugin update and notification types. The app updater's
// types are @yaakapp-internal/tauri-updater's, shared with Yaak Proxy.
export * from "./bindings/index";
+3 -1
View File
@@ -52,6 +52,7 @@ use yaak_tauri_utils::window::WorkspaceWindowTrait;
use yaak_templates::strip_json_comments::strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
use yaak_updater::restart;
mod encoding;
mod error;
@@ -67,7 +68,6 @@ mod notifications;
mod plugin_events;
mod plugins_ext;
mod render;
mod restart;
mod rpc_ext;
mod sync_ext;
mod updates;
@@ -1333,6 +1333,8 @@ pub fn run() {
// Add updater
let yaak_updater = YaakUpdater::new();
app.manage(Mutex::new(yaak_updater));
// The one update this process runs at a time, whichever window or trigger asks
app.manage(yaak_updater::UpdateGate::default());
// Add notifier
let yaak_notifier = YaakNotifier::new();
+23 -357
View File
@@ -1,68 +1,24 @@
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::time::Instant;
use crate::error::Result;
use crate::models_ext::QueryManagerExt;
use crate::restart;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
use log::info;
use tauri::{Manager, Runtime, WebviewWindow};
use tauri_plugin_updater::{Update, UpdaterExt};
use tokio::task::block_in_place;
use tokio::time::sleep;
use ts_rs::TS;
use yaak_models::util::generate_id;
use url::Url;
use yaak_api::get_system_proxy_url;
use yaak_updater::{UpdateGate, UpdateInstall};
pub use yaak_updater::{UpdateMode, UpdateTrigger};
use crate::error::Error::GenericError;
use crate::is_dev;
const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;
const MAX_UPDATE_CHECK_HOURS_BETA: u64 = 3;
const MAX_UPDATE_CHECK_HOURS_ALPHA: u64 = 1;
// Create updater struct
pub struct YaakUpdater {
last_check: Option<Instant>,
}
pub enum UpdateMode {
Stable,
Beta,
Alpha,
}
impl Display for UpdateMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let s = match self {
UpdateMode::Stable => "stable",
UpdateMode::Beta => "beta",
UpdateMode::Alpha => "alpha",
};
write!(f, "{}", s)
}
}
impl UpdateMode {
pub fn new(mode: &str) -> UpdateMode {
match mode {
"beta" => UpdateMode::Beta,
"alpha" => UpdateMode::Alpha,
_ => UpdateMode::Stable,
}
}
}
#[derive(PartialEq)]
pub enum UpdateTrigger {
Background,
User,
}
impl YaakUpdater {
pub fn new() -> Self {
Self { last_check: None }
@@ -75,6 +31,20 @@ impl YaakUpdater {
auto_download: bool,
update_trigger: UpdateTrigger,
) -> Result<bool> {
// One check and one update at a time, whichever window or trigger asks. The user who
// asks while one is in hand sees where it stands, so there's no "no update" to show.
let token = match window.state::<UpdateGate>().begin_check() {
Ok(token) => token,
Err(busy) => {
info!("Not checking for updates: the update is {:?}", busy.state);
if update_trigger == UpdateTrigger::User {
busy.show(window);
return Ok(true);
}
return Ok(false);
}
};
let settings = window.db().get_settings();
let update_key = format!("{:x}", md5::compute(settings.id));
self.last_check = Some(Instant::now());
@@ -105,13 +75,7 @@ impl YaakUpdater {
})
.header("X-Update-Mode", mode.to_string())?
.header("X-Update-Key", update_key)?
.header(
"X-Update-Trigger",
match update_trigger {
UpdateTrigger::Background => "background",
UpdateTrigger::User => "user",
},
)?
.header("X-Update-Trigger", update_trigger.header_value())?
.header("X-Install-Mode", detect_install_mode().unwrap_or("unknown"))?
.build()?
.check()
@@ -120,57 +84,8 @@ impl YaakUpdater {
let result = match update_check_result? {
None => false,
Some(update) => {
let w = window.clone();
tauri::async_runtime::spawn(async move {
// Only hand the artifact to the updater plugin when this install can
// apply it itself; otherwise tell the user how to update instead
let install = update_install_method(&update);
if install != UpdateInstall::Integrated {
info!(
"{} available, but this install updates via {install:?}",
update.version
);
notify_external_update(&w, &update, install);
return;
}
// Force native updater if specified (useful if a release broke the UI)
let native_install_mode =
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
== Some("native");
if native_install_mode {
start_native_update(&w, &update).await;
return;
}
// If it's a background update, try downloading it first
if update_trigger == UpdateTrigger::Background && auto_download {
info!("Downloading update {} in background", update.version);
if let Err(e) = download_update_idempotent(&w, &update).await {
error!("Failed to download {}: {}", update.version, e);
}
}
match start_integrated_update(&w, &update).await {
Ok(UpdateResponseAction::Skip) => {
info!("Confirmed {}: skipped", update.version);
}
Ok(UpdateResponseAction::Install) => {
info!("Confirmed {}: install", update.version);
if let Err(e) = install_update_maybe_download(&w, &update).await {
error!("Failed to install: {e}");
return;
};
info!("Installed {}", update.version);
finish_integrated_update(&w, &update).await;
}
Err(e) => {
warn!("Failed to notify frontend, falling back: {e}",);
start_native_update(&w, &update).await;
}
};
});
let install = update_install_method(&update);
token.offer(window, update, update_trigger, auto_download, install);
true
}
};
@@ -183,14 +98,8 @@ impl YaakUpdater {
auto_download: bool,
mode: UpdateMode,
) -> Result<bool> {
let update_period_seconds = match mode {
UpdateMode::Stable => MAX_UPDATE_CHECK_HOURS_STABLE,
UpdateMode::Beta => MAX_UPDATE_CHECK_HOURS_BETA,
UpdateMode::Alpha => MAX_UPDATE_CHECK_HOURS_ALPHA,
} * (60 * 60);
if let Some(i) = self.last_check
&& i.elapsed().as_secs() < update_period_seconds
&& i.elapsed() < mode.check_interval()
{
return Ok(false);
}
@@ -204,208 +113,6 @@ impl YaakUpdater {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
struct UpdateInfo {
reply_event_id: String,
version: String,
downloaded: bool,
/// How this update gets applied. Anything but `Integrated` means the app can't do it
/// itself and the user is told how to update instead.
install: UpdateInstall,
}
/// How an update can be applied to this install.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "index.ts")]
enum UpdateInstall {
/// The app downloads and installs it itself
#[default]
Integrated,
/// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark)
Flatpak,
/// Nothing can install it in-app (distro package, Nix, unknown); download by hand
Manual,
}
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
#[serde(rename_all = "camelCase", tag = "type")]
#[ts(export, export_to = "index.ts")]
enum UpdateResponse {
Ack,
Action { action: UpdateResponseAction },
}
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "index.ts")]
enum UpdateResponseAction {
Install,
Skip,
}
async fn finish_integrated_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
if let Err(e) = window.emit_to(window.label(), "update_installed", update.version.to_string()) {
warn!("Failed to notify frontend of update install: {}", e);
}
}
async fn start_integrated_update<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<UpdateResponseAction> {
let download_path = ensure_download_dir(window)?.join(download_file_name(update));
debug!("Download path: {}", download_path.display());
let downloaded = download_path.exists();
let ack_wait = Duration::from_secs(3);
let reply_id = generate_id();
// 1) Start listening BEFORE emitting to avoid missing a fast reply
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpdateResponse>();
let w_for_listener = window.clone();
let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {
match serde_json::from_str::<UpdateResponse>(ev.payload()) {
Ok(UpdateResponse::Ack) => {
let _ = tx.send(UpdateResponse::Ack);
}
Ok(UpdateResponse::Action { action }) => {
let _ = tx.send(UpdateResponse::Action { action });
}
Err(e) => {
warn!("Failed to parse update reply from frontend: {e:?}");
}
}
});
// Make sure we always unlisten
struct Unlisten<'a, R: Runtime> {
win: &'a WebviewWindow<R>,
id: tauri::EventId,
}
impl<'a, R: Runtime> Drop for Unlisten<'a, R> {
fn drop(&mut self) {
self.win.unlisten(self.id);
}
}
let _guard = Unlisten { win: window, id: event_id };
// 2) Emit the event now that listener is in place
let info = UpdateInfo {
version: update.version.to_string(),
downloaded,
install: UpdateInstall::Integrated,
reply_event_id: reply_id,
};
window
.emit_to(window.label(), "update_available", &info)
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
// 3) Two-stage timeout: first wait for ack, then wait for final action
// --- Phase 1: wait for ACK with timeout ---
let ack_timer = sleep(ack_wait);
tokio::pin!(ack_timer);
loop {
tokio::select! {
msg = rx.recv() => match msg {
Some(UpdateResponse::Ack) => break, // proceed to Phase 2
Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast
None => return Err(GenericError("frontend channel closed before ack".into())),
},
_ = &mut ack_timer => {
return Err(GenericError("timed out waiting for frontend ack".into()));
}
}
}
// --- Phase 2: wait forever for final action ---
loop {
match rx.recv().await {
Some(UpdateResponse::Action { action }) => return Ok(action),
Some(UpdateResponse::Ack) => { /* ignore extra acks */ }
None => return Err(GenericError("frontend channel closed before action".into())),
}
}
}
/// Tell the frontend about an update this install can't apply itself, so the user can be
/// told how to get it. Unlike the integrated flow, there is nothing to reply to.
fn notify_external_update<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
install: UpdateInstall,
) {
let info = UpdateInfo {
version: update.version.to_string(),
downloaded: false,
install,
reply_event_id: generate_id(),
};
if let Err(e) = window.emit_to(window.label(), "update_available", &info) {
warn!("Failed to emit update_available: {e}");
}
}
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
// If the frontend doesn't respond, fallback to native dialogs
let confirmed = window
.dialog()
.message(format!(
"{} is available. Would you like to download and install it now?",
update.version
))
.buttons(MessageDialogButtons::OkCancelCustom("Download".to_string(), "Later".to_string()))
.title("Update Available")
.blocking_show();
if !confirmed {
return;
}
match update.download_and_install(|_, _| {}, || {}).await {
Ok(()) => {
if window
.dialog()
.message("Would you like to restart the app?")
.title("Update Installed")
.buttons(MessageDialogButtons::OkCancelCustom(
"Restart".to_string(),
"Later".to_string(),
))
.blocking_show()
{
restart::request_restart(window.app_handle());
}
}
Err(e) => {
window.dialog().message(format!("The update failed to install: {}", e));
}
}
}
pub async fn download_update_idempotent<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<PathBuf> {
let dl_path = ensure_download_dir(window)?.join(download_file_name(update));
if dl_path.exists() {
info!("{} already downloaded to {}", update.version, dl_path.display());
return Ok(dl_path);
}
info!("{} downloading: {}", update.version, dl_path.display());
let dl_bytes = update.download(|_, _| {}, || {}).await?;
std::fs::write(&dl_path, dl_bytes)
.map_err(|e| GenericError(format!("Failed to write update: {e}")))?;
info!("{} downloaded", update.version);
Ok(dl_path)
}
/// Detect the installer type so the update server can serve the correct artifact.
fn detect_install_mode() -> Option<&'static str> {
#[cfg(target_os = "windows")]
@@ -551,44 +258,3 @@ mod tests {
}
}
}
pub async fn install_update_maybe_download<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
) -> Result<()> {
let dl_path = download_update_idempotent(window, update).await?;
let update_bytes = std::fs::read(&dl_path)?;
update.install(update_bytes.as_slice())?;
delete_download_dir(window);
Ok(())
}
pub fn download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
Ok(window.path().app_cache_dir()?.join("updates"))
}
pub fn ensure_download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
let base_dir = download_dir(window)?;
std::fs::create_dir_all(&base_dir)?;
Ok(base_dir)
}
pub fn download_file_name(update: &Update) -> String {
let sig_digest = md5::compute(&update.signature);
format!("yaak-{}-{:x}", update.version, sig_digest)
}
pub fn delete_download_dir<R: Runtime>(window: &WebviewWindow<R>) {
let base_dir = match download_dir(window) {
Ok(dir) => dir,
Err(e) => {
warn!("Failed to locate update downloads dir: {}", e);
return;
}
};
match std::fs::remove_dir_all(&base_dir) {
Ok(()) => info!("Removed update downloads dir {}", base_dir.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("Failed to remove update downloads dir {}: {}", base_dir.display(), e),
}
}
+10
View File
@@ -15,6 +15,8 @@ tauri-build = { version = "2.6.1", features = [] }
[features]
default = ["wry"]
wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"]
# Checks for and installs updates, as release builds do (`tauri.release.conf.json`).
updater = ["dep:tauri-plugin-updater"]
[dependencies]
log = { workspace = true }
@@ -23,7 +25,15 @@ tauri = { workspace = true, features = ["devtools"] }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-opener = "2.5.4"
tauri-plugin-os = "2.3.2"
tauri-plugin-updater = { version = "2.10.1", optional = true }
tokio = { workspace = true, features = ["sync"] }
yaak-mac-window = { workspace = true }
yaak-proxy-lib = { workspace = true }
yaak-rpc = { workspace = true }
yaak-updater = { workspace = true }
yaak-window = { workspace = true }
[dev-dependencies]
# A mock app for the update checks' tests
tauri = { workspace = true, features = ["test"] }
tokio = { workspace = true, features = ["macros", "rt"] }
+66 -51
View File
@@ -6,6 +6,7 @@ use yaak_proxy_lib::{MasterKeySource, ProxyCtx};
use yaak_rpc::{RpcEventEmitter, RpcRouter};
use yaak_window::window::CreateWindowConfig;
mod updates;
mod window_menu;
fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) {
@@ -47,6 +48,14 @@ fn handle_menu_events<R: Runtime>(win: &WebviewWindow<R>) {
error!("Failed to emit the HAR export: {e}");
}
}
// To the window it was chosen in alone, which checks and shows what it finds
window_menu::CHECK_FOR_UPDATES_ID => {
if let Err(e) =
webview_window.emit_to(w.label(), window_menu::CHECK_FOR_UPDATES_EVENT, ())
{
error!("Failed to emit {}: {e}", window_menu::CHECK_FOR_UPDATES_EVENT);
}
}
window_menu::GO_BACK_ID | window_menu::GO_FORWARD_ID => {
let event = if event_id == window_menu::GO_BACK_ID {
window_menu::GO_BACK_EVENT
@@ -90,8 +99,11 @@ fn bundled_protoc<R: Runtime>(app: &tauri::AppHandle<R>) -> Option<yaak_proxy_li
/// The main window's label.
const MAIN_WINDOW_LABEL: &str = "main_0";
/// The Settings window's label, by which its page renders the settings alone.
const SETTINGS_WINDOW_LABEL: &str = "settings";
/// The name the Settings window is opened under, and the key its size and position are kept by.
const SETTINGS_WINDOW_NAME: &str = "settings";
/// The Settings window's label, by which its page renders the settings alone: its name with the
/// prefix the shared code gives each child window, as Yaak's Settings window has.
const SETTINGS_WINDOW_LABEL: &str = "other__settings";
/// The event that moves an open Settings window to another tab.
const SETTINGS_TAB_EVENT: &str = "settings_tab";
/// The Settings window's size, as Yaak's.
@@ -105,8 +117,10 @@ fn settings_tab_script(tab: &str) -> String {
format!("window.__YAAK_SETTINGS_TAB__ = {tab};")
}
/// Opens Settings in its own window on `tab`, or, when it is open, brings it forward and moves
/// it to `tab` if one is given.
/// Opens Settings on `tab` in a child of the main window, or, when it is open, brings it forward
/// and moves it to `tab` if one is given. As Yaak's, the child sits over the middle of the main
/// window the first time and where it was left after; it stays in front, taking focus back when
/// the main window is clicked, and closes with it.
#[tauri::command]
async fn cmd_open_settings(
app_handle: tauri::AppHandle,
@@ -124,53 +138,25 @@ async fn cmd_open_settings(
return Ok(());
}
// Over the middle of the main window the first time, as Yaak's; after that, where it was left
let position = app_handle
let main = app_handle
.get_webview_window(MAIN_WINDOW_LABEL)
.and_then(|main| centered_over(&main, SETTINGS_WINDOW_SIZE).ok());
let config = CreateWindowConfig {
url: "/",
label: SETTINGS_WINDOW_LABEL,
title: "Yaak Proxy Settings",
inner_size: Some(SETTINGS_WINDOW_SIZE),
position,
initialization_script: tab.as_deref().map(settings_tab_script),
hidden: true,
hide_titlebar: true,
..Default::default()
};
let win = yaak_window::window::create_window(&app_handle, config).map_err(|e| e.to_string())?;
.ok_or("The main window is not open to open Settings over")?;
let win = yaak_window::window::create_child_window(
&main,
"/",
SETTINGS_WINDOW_NAME,
"Yaak Proxy Settings",
SETTINGS_WINDOW_SIZE,
tab.as_deref().map(settings_tab_script),
false,
)
.map_err(|e| e.to_string())?;
let (width, height) = SETTINGS_WINDOW_MIN_SIZE;
win.set_min_size(Some(tauri::LogicalSize::new(width, height))).map_err(|e| e.to_string())?;
handle_menu_events(&win);
Ok(())
}
/// Where a window of `size` sits centred over `parent`, in logical pixels.
fn centered_over<R: Runtime>(
parent: &WebviewWindow<R>,
(width, height): (f64, f64),
) -> tauri::Result<(f64, f64)> {
let scale = parent.scale_factor()?;
let pos = parent.inner_position()?.to_logical::<f64>(scale);
let size = parent.inner_size()?.to_logical::<f64>(scale);
Ok((pos.x + (size.width - width) / 2.0, pos.y + (size.height - height) / 2.0))
}
/// Closing the main window closes Settings with it, which would otherwise keep the app open
/// with nothing to set.
fn close_settings_with<R: Runtime>(main: &WebviewWindow<R>) {
let app_handle = main.app_handle().clone();
main.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event
&& let Some(settings) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL)
&& let Err(e) = settings.close()
{
warn!("Failed to close the settings window: {e}");
}
});
}
#[tauri::command]
async fn rpc(
router: State<'_, RpcRouter<ProxyCtx>>,
@@ -188,11 +174,22 @@ fn set_go_menu(app: tauri::AppHandle, back: bool, forward: bool) {
}
pub fn run() {
tauri::Builder::default()
#[allow(unused_mut)]
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(yaak_mac_window::init())
.plugin(yaak_mac_window::init());
#[cfg(feature = "updater")]
{
builder = builder.plugin(tauri_plugin_updater::Builder::default().build());
}
builder
.manage(tokio::sync::Mutex::new(updates::ProxyUpdater::default()))
// The one update this process runs at a time, whichever window or trigger asks
.manage(yaak_updater::UpdateGate::default())
.setup(|app| {
let data_dir = app.path().app_data_dir().expect("no app data dir");
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
@@ -240,7 +237,14 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![rpc, set_go_menu, cmd_open_settings])
.invoke_handler(tauri::generate_handler![
rpc,
set_go_menu,
cmd_open_settings,
updates::cmd_updater_enabled,
updates::cmd_check_for_updates,
updates::cmd_restart
])
.build(tauri::generate_context!())
.expect("error while building yaak proxy tauri application")
.run(|app_handle, event| {
@@ -249,6 +253,8 @@ pub fn run() {
let ctx = app_handle.state::<ProxyCtx>();
ctx.shutdown();
ctx.stop_control_api();
// Relaunches after an update, once the system proxy is put back
yaak_updater::restart::relaunch_if_requested();
return;
}
if let RunEvent::Ready = event {
@@ -262,10 +268,7 @@ pub fn run() {
..Default::default()
};
match yaak_window::window::create_window(app_handle, config) {
Ok(win) => {
setup_window_menu(&win);
close_settings_with(&win);
}
Ok(win) => setup_window_menu(&win),
Err(e) => error!("Failed to create proxy window: {e:?}"),
}
}
@@ -333,6 +336,18 @@ mod tests {
}
}
/// The page knows the Settings window by the label the shared code gives a child window
/// opened under its name, or it would render the whole app in it.
#[test]
fn the_page_knows_the_settings_window_by_its_label() {
assert_eq!(SETTINGS_WINDOW_LABEL, format!("other__{SETTINGS_WINDOW_NAME}"));
let windows = include_str!("../../../apps/yaak-proxy/lib/windows.ts");
assert!(
windows.contains(&format!("SETTINGS_WINDOW_LABEL = \"{SETTINGS_WINDOW_LABEL}\"")),
"the page's Settings window label is not {SETTINGS_WINDOW_LABEL}"
);
}
/// The tab reaches the new window's page as a string literal, whatever it holds.
#[test]
fn the_settings_tab_script_quotes_the_tab() {
+592
View File
@@ -0,0 +1,592 @@
//! Yaak Proxy's update check. The prompt, download, install and relaunch are Yaak's, shared
//! through `yaak-updater`; what differs is where the proxy asks and what it accepts.
//!
//! The channel and update behavior are the shared settings tabs' values, which the window
//! keeps, so the window passes them with each check.
//!
//! A build without the `updater` feature, as development builds are, keeps the commands, which
//! say there is no updater, but uses none of the checking itself.
#![cfg_attr(not(feature = "updater"), allow(dead_code, unused_imports))]
use std::hash::BuildHasher;
use std::time::Instant;
use log::{info, warn};
use tauri::{Manager, Runtime, State, WebviewWindow};
use tokio::sync::Mutex;
use yaak_updater::{UpdateGate, UpdateInstall, UpdateMode, UpdateTrigger};
/// Where Yaak Proxy asks for updates. The `/proxy/` segment tells the update server which app
/// is asking; Yaak asks at `/check/...` and its answers are unaffected. A server that doesn't
/// know the proxy yet answers 404, which is a failed check here, never Yaak's update.
pub const UPDATE_ENDPOINT: &str =
"https://update.yaak.app/proxy/check/{{target}}/{{arch}}/{{current_version}}";
/// How every Yaak Proxy updater artifact's file name starts, as the release workflow uploads
/// it. An update whose file is named otherwise, such as Yaak's own `Yaak_aarch64.app.tar.gz`,
/// is refused: it is signed by the same key, so the signature alone would let it replace the
/// proxy with Yaak.
pub const ARTIFACT_PREFIX: &str = "Yaak.Proxy_";
/// The file in the app's data directory holding the random key the update server buckets
/// gradual rollouts by, so a Mac keeps its place in a rollout.
const UPDATE_KEY_FILE: &str = "update-key";
/// When the last check ran, so background checks keep to their channel's interval.
#[derive(Default)]
pub struct ProxyUpdater {
last_check: Option<Instant>,
}
/// Whether this build has the updater, so the window shows its settings and menu item.
#[tauri::command]
pub fn cmd_updater_enabled() -> bool {
cfg!(feature = "updater")
}
/// Asks the update server for a newer Yaak Proxy on `channel` and, when there is one, offers
/// it in this window. True when an update was found. A background check returns false at once
/// when the channel's interval since the last check hasn't passed.
#[tauri::command]
pub async fn cmd_check_for_updates<R: Runtime>(
window: WebviewWindow<R>,
updater: State<'_, Mutex<ProxyUpdater>>,
channel: String,
background: bool,
auto_download: bool,
) -> Result<bool, String> {
let mode = UpdateMode::new(&channel);
let trigger = if background { UpdateTrigger::Background } else { UpdateTrigger::User };
let mut updater = updater.lock().await;
if trigger == UpdateTrigger::Background
&& let Some(last) = updater.last_check
&& last.elapsed() < mode.check_interval()
{
return Ok(false);
}
updater.last_check = Some(Instant::now());
drop(updater);
check(&window, mode, trigger, auto_download).await
}
/// Relaunches the app after an update installed, through LaunchServices on macOS.
#[tauri::command]
pub fn cmd_restart<R: Runtime>(app_handle: tauri::AppHandle<R>) {
yaak_updater::restart::request_restart(&app_handle);
}
#[cfg(feature = "updater")]
async fn check<R: Runtime>(
window: &WebviewWindow<R>,
mode: UpdateMode,
trigger: UpdateTrigger,
auto_download: bool,
) -> Result<bool, String> {
use tauri_plugin_updater::UpdaterExt;
// One check and one update at a time, whichever window or trigger asks
let token = match window.state::<UpdateGate>().begin_check() {
Ok(token) => token,
Err(busy) => {
info!("Not checking for updates: the update is {:?}", busy.state);
if trigger == UpdateTrigger::User {
busy.show(window);
return Ok(true);
}
return Ok(false);
}
};
info!("Checking for updates mode={mode} trigger={}", trigger.header_value());
let endpoint = tauri::Url::parse(&endpoint()).map_err(|e| e.to_string())?;
let update = window
.updater_builder()
.endpoints(vec![endpoint])
.and_then(|b| b.header("X-Update-Mode", mode.to_string()))
.and_then(|b| b.header("X-Update-Key", update_key(window)))
.and_then(|b| b.header("X-Update-Trigger", trigger.header_value()))
.and_then(|b| b.header("X-Install-Mode", "unknown"))
.and_then(|b| b.build())
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?;
let Some(update) = update else {
return Ok(false);
};
let file = artifact_file_name(update.download_url.path());
if !is_proxy_artifact(file) {
warn!(
"Refused update {} at {}: not a Yaak Proxy artifact",
update.version, update.download_url
);
return Err(format!("The update server offered {file}, which is not a Yaak Proxy update"));
}
token.offer(window, update, trigger, auto_download, UpdateInstall::Integrated);
Ok(true)
}
#[cfg(not(feature = "updater"))]
async fn check<R: Runtime>(
_window: &WebviewWindow<R>,
_mode: UpdateMode,
_trigger: UpdateTrigger,
_auto_download: bool,
) -> Result<bool, String> {
Err("This build of Yaak Proxy has no updater".to_string())
}
/// Where checks go: [`UPDATE_ENDPOINT`], or, in a debug build only, `YAAK_PROXY_UPDATE_ENDPOINT`
/// when it is set, to try the flow against a local server.
#[cfg(feature = "updater")]
fn endpoint() -> String {
#[cfg(debug_assertions)]
if let Ok(url) = std::env::var("YAAK_PROXY_UPDATE_ENDPOINT")
&& !url.is_empty()
{
warn!("Checking for updates at {url}, from YAAK_PROXY_UPDATE_ENDPOINT");
return url;
}
UPDATE_ENDPOINT.to_string()
}
/// The last segment of a download URL's path.
fn artifact_file_name(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
/// Whether a file is one of the proxy's updater artifacts, by the name the release gives it.
fn is_proxy_artifact(file: &str) -> bool {
file.starts_with(ARTIFACT_PREFIX)
}
/// This Mac's update key, made once and kept in the data directory. Without one, the server
/// buckets by address, which is still deterministic, so a failure here is only logged.
fn update_key<R: Runtime>(window: &WebviewWindow<R>) -> String {
// Tests leave the data directory alone; a mock app's would be Application Support itself
if cfg!(test) {
return "test".to_string();
}
let Ok(dir) = window.path().app_data_dir() else {
return String::new();
};
let path = dir.join(UPDATE_KEY_FILE);
if let Ok(key) = std::fs::read_to_string(&path)
&& !key.trim().is_empty()
{
return key.trim().to_string();
}
// Random per process: each `RandomState` is seeded afresh
let random = || std::collections::hash_map::RandomState::new().hash_one(std::process::id());
let key = format!("{:016x}{:016x}", random(), random());
if let Err(e) = std::fs::create_dir_all(&dir) {
warn!("Failed to create the data directory for the update key: {e}");
}
if let Err(e) = std::fs::write(&path, &key) {
warn!("Failed to save the update key: {e}");
}
key
}
#[cfg(test)]
mod tests {
use super::*;
/// The endpoint is the update server's, with the proxy's own path, so Yaak's is untouched.
#[test]
fn the_endpoint_is_the_proxys_own() {
let url = tauri::Url::parse(UPDATE_ENDPOINT).unwrap();
assert_eq!(url.scheme(), "https");
assert_eq!(url.host_str(), Some("update.yaak.app"));
assert!(url.path().starts_with("/proxy/check/"), "{}", url.path());
}
/// Only the proxy's artifacts install, never Yaak's, which the same key signs.
#[test]
fn only_proxy_artifacts_are_accepted() {
let base = "/mountain-loop/yaak/releases/download/v2026.11.0/";
for (file, ok) in [
("Yaak.Proxy_aarch64.app.tar.gz", true),
("Yaak.Proxy_x64.app.tar.gz", true),
("Yaak_aarch64.app.tar.gz", false),
("Yaak_x64.app.tar.gz", false),
("Yaak.app.tar.gz", false),
] {
let name = artifact_file_name(&format!("{base}{file}")).to_string();
assert_eq!(name, file);
assert_eq!(is_proxy_artifact(&name), ok, "{file}");
}
}
/// The release config carries the updater's key but not its endpoint, which is
/// [`UPDATE_ENDPOINT`] alone, and builds the updater artifacts; it never turns on a license.
#[test]
fn the_release_config_has_the_updater_and_no_license() {
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.release.conf.json")).unwrap();
let features: Vec<&str> = config["build"]["features"]
.as_array()
.unwrap()
.iter()
.filter_map(|f| f.as_str())
.collect();
assert!(features.contains(&"updater"), "{features:?}");
assert!(!features.contains(&"license"), "{features:?}");
assert!(config["plugins"]["updater"]["pubkey"].as_str().is_some_and(|k| !k.is_empty()));
assert!(config["plugins"]["updater"].get("endpoints").is_none());
assert_eq!(config["bundle"]["createUpdaterArtifacts"], serde_json::json!(true));
}
/// The proxy verifies updates with the key Yaak's release signs them with.
#[test]
fn the_updater_key_is_yaaks() {
let proxy: serde_json::Value =
serde_json::from_str(include_str!("../tauri.release.conf.json")).unwrap();
let client: serde_json::Value =
serde_json::from_str(include_str!("../../yaak-app-client/tauri.release.conf.json"))
.unwrap();
assert_eq!(proxy["plugins"]["updater"]["pubkey"], client["plugins"]["updater"]["pubkey"]);
}
}
/// The checks every trigger makes (launch, focus, the menu, Settings), raced against a local
/// update server through a mock app with the real updater plugin.
#[cfg(all(test, feature = "updater"))]
mod race_tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::time::Duration;
use tauri::test::{MockRuntime, mock_builder, mock_context, noop_assets};
use tauri::{Emitter, Listener, WebviewUrl, WebviewWindowBuilder};
/// A local update server that always offers 99.0.0 as a proxy artifact. Returns its port.
fn serve_update() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut request = [0u8; 4096];
let _ = stream.read(&mut request);
if String::from_utf8_lossy(&request).contains(".app.tar.gz") {
// A slow download, to check while it runs. Not a real archive, so the
// install then fails its signature check and the run ends.
std::thread::sleep(Duration::from_millis(1500));
let body = "not an archive";
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
continue;
}
let version = served_version().clone();
let body = format!(
r#"{{"version":"{version}","url":"http://127.0.0.1:{port}/Yaak.Proxy_aarch64.app.tar.gz","signature":"unused"}}"#
);
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
}
});
port
}
/// Where a test app keeps its downloads, removed after each test.
const TEST_IDENTIFIER: &str = "app.yaak.proxy.updates-test";
/// The version the test server offers, 99.0.0 unless a test publishes a newer one.
fn served_version() -> std::sync::MutexGuard<'static, String> {
static VERSION: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
let mut version = VERSION.lock().unwrap_or_else(|e| e.into_inner());
if version.is_empty() {
*version = "99.0.0".to_string();
}
version
}
/// One test at a time, since they share the endpoint variable, the served version and the
/// downloads directory.
fn serial() -> std::sync::MutexGuard<'static, ()> {
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
*served_version() = "99.0.0".to_string();
guard
}
fn mock_app(port: u16) -> tauri::App<MockRuntime> {
// SAFETY: only this module's tests read or write it, one at a time
unsafe {
std::env::set_var(
"YAAK_PROXY_UPDATE_ENDPOINT",
format!(
"http://127.0.0.1:{port}/proxy/check/{{{{target}}}}/{{{{arch}}}}/{{{{current_version}}}}"
),
);
}
let mut context = mock_context(noop_assets());
context.config_mut().identifier = TEST_IDENTIFIER.to_string();
context.config_mut().plugins.0.insert(
"updater".to_string(),
serde_json::json!({ "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVGRkFGMjQxRUNEOTQ3MzAKUldRd1I5bnNRZkw2NzRtMnRlWTN3R24xYUR3aGRsUjJzWGwvdHdEcGljb3ZJMUNlMjFsaHlqVU4K" }),
);
mock_builder()
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(Mutex::new(ProxyUpdater::default()))
.manage(UpdateGate::default())
.build(context)
.unwrap()
}
/// Removes the test app's downloads once the test ends, passing or not.
struct CleanUp(tauri::AppHandle<MockRuntime>);
impl Drop for CleanUp {
fn drop(&mut self) {
if let Ok(dir) = self.0.path().app_cache_dir()
&& dir.ends_with(TEST_IDENTIFIER)
{
let _ = std::fs::remove_dir_all(dir);
}
}
}
fn window(app: &tauri::App<MockRuntime>, label: &str) -> WebviewWindow<MockRuntime> {
WebviewWindowBuilder::new(app, label, WebviewUrl::default()).build().unwrap()
}
/// Something the app showed a window about its update.
#[derive(Debug, Clone)]
struct Shown {
window: String,
event: &'static str,
payload: serde_json::Value,
}
/// Records what the app shows each of `windows` about its update.
fn record(windows: &[&WebviewWindow<MockRuntime>]) -> mpsc::Receiver<Shown> {
let (tx, rx) = mpsc::channel();
for w in windows {
for event in [
yaak_updater::UPDATE_AVAILABLE_EVENT,
yaak_updater::UPDATE_STATE_EVENT,
yaak_updater::UPDATE_INSTALLED_EVENT,
] {
let tx = tx.clone();
let window = w.label().to_string();
w.listen(event, move |e| {
let payload = serde_json::from_str(e.payload()).unwrap();
let _ = tx.send(Shown { window: window.clone(), event, payload });
});
}
}
rx
}
/// Everything shown until nothing more comes for a moment, well inside the 3 seconds a
/// prompt waits for its window to answer.
fn drain(rx: &mpsc::Receiver<Shown>) -> Vec<Shown> {
let mut all = Vec::new();
while let Ok(shown) = rx.recv_timeout(Duration::from_millis(700)) {
all.push(shown);
}
all
}
fn prompts(shown: &[Shown]) -> Vec<&Shown> {
shown.iter().filter(|s| s.event == yaak_updater::UPDATE_AVAILABLE_EVENT).collect()
}
fn reply_id(prompt: &Shown) -> String {
prompt.payload["replyEventId"].as_str().unwrap().to_string()
}
/// Answers a prompt as its window's buttons do.
fn answer(app: &tauri::App<MockRuntime>, reply: &str, response: serde_json::Value) {
app.emit(reply, response).unwrap();
}
fn ack(app: &tauri::App<MockRuntime>, reply: &str) {
answer(app, reply, serde_json::json!({ "type": "ack" }));
}
fn user_check(window: &WebviewWindow<MockRuntime>) -> Result<bool, String> {
tauri::async_runtime::block_on(check(window, UpdateMode::Beta, UpdateTrigger::User, false))
}
fn background_check(window: &WebviewWindow<MockRuntime>) -> Result<bool, String> {
tauri::async_runtime::block_on(check(
window,
UpdateMode::Beta,
UpdateTrigger::Background,
false,
))
}
/// Waits for the gate to come back to idle, as a run ends.
fn wait_for_idle(app: &tauri::App<MockRuntime>) {
let gate = app.state::<UpdateGate>();
for _ in 0..100 {
if gate.state() == yaak_updater::UpdateState::Idle {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("the update run never ended: {:?}", gate.state());
}
/// The main window and Settings each check, as their Check for Updates… and Settings button
/// do, at the same moment: only one prompt, and so one download and install, may start. The
/// other window is told a check is under way.
#[test]
fn overlapping_checks_from_two_windows_start_one_update() {
let _serial = serial();
let app = mock_app(serve_update());
let _clean = CleanUp(app.handle().clone());
let main = window(&app, "main_0");
let settings = window(&app, "settings");
let rx = record(&[&main, &settings]);
let state = app.state::<Mutex<ProxyUpdater>>();
let (a, b) = tauri::async_runtime::block_on(async {
tokio::join!(
cmd_check_for_updates(main.clone(), state.clone(), "beta".into(), false, false),
cmd_check_for_updates(settings.clone(), state.clone(), "beta".into(), false, false),
)
});
let shown = drain(&rx);
// Settle every prompt, so no task is left waiting on a window
for prompt in prompts(&shown) {
answer(
&app,
&reply_id(prompt),
serde_json::json!({ "type": "action", "action": "skip" }),
);
}
assert_eq!(a, Ok(true));
assert_eq!(b, Ok(true));
let started = prompts(&shown);
assert_eq!(started.len(), 1, "two checks started {} prompts: {shown:?}", started.len());
let told: Vec<_> =
shown.iter().filter(|s| s.event == yaak_updater::UPDATE_STATE_EVENT).collect();
assert_eq!(told.len(), 1, "{shown:?}");
assert_eq!(told[0].payload, serde_json::json!({ "state": "checking" }));
assert_ne!(told[0].window, started[0].window, "the window that lost the race is told");
wait_for_idle(&app);
}
/// A check from another window, or a later background check, while a prompt waits on the
/// user shows that same prompt, answered by the same run, rather than starting another.
#[test]
fn a_check_while_a_prompt_waits_shows_the_same_prompt() {
let _serial = serial();
let app = mock_app(serve_update());
let _clean = CleanUp(app.handle().clone());
let main = window(&app, "main_0");
let settings = window(&app, "settings");
let rx = record(&[&main, &settings]);
assert_eq!(user_check(&main), Ok(true));
let first = drain(&rx);
assert_eq!(prompts(&first).len(), 1, "{first:?}");
let reply = reply_id(prompts(&first)[0]);
ack(&app, &reply);
// The Settings button, then a background check as the main window comes forward
assert_eq!(user_check(&settings), Ok(true));
assert_eq!(background_check(&main), Ok(true));
let again = drain(&rx);
let shown_again = prompts(&again);
assert_eq!(shown_again.len(), 2, "{again:?}");
assert_eq!(shown_again[0].window, "settings");
assert_eq!(shown_again[1].window, "main_0");
for prompt in &shown_again {
assert_eq!(reply_id(prompt), reply, "the same run answers it");
}
answer(&app, &reply, serde_json::json!({ "type": "action", "action": "skip" }));
wait_for_idle(&app);
}
/// A newer version found from Settings, while the main window's prompt for the older one
/// waits, replaces that prompt in both windows, so neither keeps an Install button whose
/// run is gone.
#[test]
fn a_newer_version_replaces_the_prompt_in_every_window() {
let _serial = serial();
let app = mock_app(serve_update());
let _clean = CleanUp(app.handle().clone());
let main = window(&app, "main_0");
let settings = window(&app, "settings");
let rx = record(&[&main, &settings]);
assert_eq!(user_check(&main), Ok(true));
let first = drain(&rx);
let old_reply = reply_id(prompts(&first)[0]);
ack(&app, &old_reply);
*served_version() = "99.1.0".to_string();
assert_eq!(user_check(&settings), Ok(true));
let replaced = drain(&rx);
let new_prompts = prompts(&replaced);
assert_eq!(new_prompts.len(), 2, "{replaced:?}");
let mut windows: Vec<_> = new_prompts.iter().map(|p| p.window.as_str()).collect();
windows.sort();
assert_eq!(windows, vec!["main_0", "settings"]);
let new_reply = reply_id(new_prompts[0]);
for prompt in &new_prompts {
assert_eq!(prompt.payload["version"], "99.1.0");
assert_eq!(reply_id(prompt), new_reply, "one run answers both");
}
assert_ne!(new_reply, old_reply);
ack(&app, &new_reply);
answer(&app, &new_reply, serde_json::json!({ "type": "action", "action": "skip" }));
wait_for_idle(&app);
}
/// While the update downloads (and so installs), no check starts another: the user who asks
/// is told where it stands, and a background check shows nothing.
#[test]
fn checks_while_downloading_start_nothing() {
let _serial = serial();
let app = mock_app(serve_update());
let _clean = CleanUp(app.handle().clone());
let main = window(&app, "main_0");
let settings = window(&app, "settings");
let rx = record(&[&main, &settings]);
assert_eq!(user_check(&main), Ok(true));
let first = drain(&rx);
let reply = reply_id(prompts(&first)[0]);
ack(&app, &reply);
answer(&app, &reply, serde_json::json!({ "type": "action", "action": "install" }));
std::thread::sleep(Duration::from_millis(300));
assert_eq!(
app.state::<UpdateGate>().state(),
yaak_updater::UpdateState::Downloading { version: "99.0.0".into() }
);
assert_eq!(user_check(&settings), Ok(true));
assert_eq!(background_check(&main), Ok(false));
let during = drain(&rx);
assert!(prompts(&during).is_empty(), "no new prompt: {during:?}");
let told: Vec<_> =
during.iter().filter(|s| s.event == yaak_updater::UPDATE_STATE_EVENT).collect();
assert_eq!(told.len(), 1, "only the user who asked is told: {during:?}");
assert_eq!(told[0].window, "settings");
assert_eq!(
told[0].payload,
serde_json::json!({ "state": "downloading", "version": "99.0.0" })
);
// The download isn't a signed archive, so the install fails and the run ends
wait_for_idle(&app);
}
}
@@ -11,6 +11,12 @@ pub const EXPORT_HAR_ID: &str = "file.export_har";
/// The event the window hears File's Export All Flows as HAR by.
pub const EXPORT_HAR_EVENT: &str = "menu_export_har";
/// The app menu's Check for Updates…, in a build with the updater.
pub const CHECK_FOR_UPDATES_ID: &str = "app.check_for_updates";
/// The event the window hears Check for Updates… by, and checks on.
pub const CHECK_FOR_UPDATES_EVENT: &str = "menu_check_for_updates";
/// Go's Back and Forward, which the window carries out along its own places, and enables while
/// there is somewhere to go.
pub const GO_BACK_ID: &str = "go.back";
@@ -98,6 +104,9 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
true,
&[
&PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?,
#[cfg(feature = "updater")]
&MenuItemBuilder::with_id(CHECK_FOR_UPDATES_ID, "Check for Updates…")
.build(app_handle)?,
&PredefinedMenuItem::separator(app_handle)?,
&PredefinedMenuItem::services(app_handle, None)?,
&PredefinedMenuItem::separator(app_handle)?,
@@ -1,5 +1,17 @@
{
"build": {
"features": []
"features": ["updater"]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVGRkFGMjQxRUNEOTQ3MzAKUldRd1I5bnNRZkw2NzRtMnRlWTN3R24xYUR3aGRsUjJzWGwvdHdEcGljb3ZJMUNlMjFsaHlqVU4K"
}
},
"bundle": {
"publisher": "Yaak",
"copyright": "Yaak",
"homepage": "https://yaak.app",
"category": "DeveloperTool",
"createUpdaterArtifacts": true
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "yaak-updater"
version = "0.1.0"
edition = "2024"
publish = false
# The update prompt, download and install that Yaak and Yaak Proxy share, and the macOS-safe
# restart that follows an install. Each app checks for updates its own way and hands what it
# finds to this crate.
[dependencies]
log = { workspace = true }
md5 = "0.8.0"
nanoid = "0.5.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-updater = "2.10.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync", "time", "macros"] }
ts-rs = { workspace = true }
+32
View File
@@ -0,0 +1,32 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type UpdateInfo = {
replyEventId: string;
version: string;
downloaded: boolean;
/**
* How this update gets applied. Anything but `Integrated` means the app can't do it
* itself and the user is told how to update instead.
*/
install: UpdateInstall;
};
/**
* How an update can be applied to this install.
*/
export type UpdateInstall = "integrated" | "flatpak" | "manual";
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
export type UpdateResponseAction = "install" | "skip";
/**
* Where this process's update stands.
*/
export type UpdateState =
| { state: "idle" }
| { state: "checking" }
| { state: "offered"; version: string }
| { state: "downloading"; version: string }
| { state: "installing"; version: string }
| { state: "installed"; version: string };
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more