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