mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-15 21:22:18 +01:00
Compare commits
15
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
+64
-19
@@ -7208,7 +7208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3a6e4decc9842a6638f85d95c25c6ad95e65367a9ad8845b7cae7241a7d9aa1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"ts-rs",
|
||||
"ts-rs 11.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9923,11 +9923,21 @@ name = "ts-rs"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"ts-rs-macros 11.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"ts-rs-macros",
|
||||
"ts-rs-macros 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9942,6 +9952,18 @@ dependencies = [
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"termcolor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.30.0"
|
||||
@@ -11479,7 +11501,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"url",
|
||||
"uuid",
|
||||
"yaak",
|
||||
@@ -11507,6 +11529,7 @@ dependencies = [
|
||||
"yaak-tauri-utils",
|
||||
"yaak-templates",
|
||||
"yaak-tls",
|
||||
"yaak-updater",
|
||||
"yaak-window",
|
||||
"yaak-ws",
|
||||
]
|
||||
@@ -11522,9 +11545,12 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"yaak-mac-window",
|
||||
"yaak-proxy-lib",
|
||||
"yaak-rpc",
|
||||
"yaak-updater",
|
||||
"yaak-window",
|
||||
]
|
||||
|
||||
@@ -11642,7 +11668,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -11666,7 +11692,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11682,7 +11708,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"url",
|
||||
"yaak-common",
|
||||
"yaak-models",
|
||||
@@ -11710,7 +11736,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tonic",
|
||||
"tonic-reflection",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"uuid",
|
||||
"yaak-common",
|
||||
"yaak-tls",
|
||||
@@ -11761,7 +11787,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.21",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"yaak-api",
|
||||
"yaak-common",
|
||||
"yaak-models",
|
||||
@@ -11826,7 +11852,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.21",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"urlencoding",
|
||||
"yaak-core",
|
||||
"yaak-database",
|
||||
@@ -11871,7 +11897,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"yaak-common",
|
||||
"yaak-crypto",
|
||||
"yaak-models",
|
||||
@@ -11918,7 +11944,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11948,15 +11974,17 @@ dependencies = [
|
||||
"r2d2_sqlite",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"sea-query",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"system-configuration",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tonic",
|
||||
"tonic-reflection",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"url",
|
||||
"yaak-common",
|
||||
"yaak-crypto",
|
||||
@@ -11980,7 +12008,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -11991,7 +12019,7 @@ dependencies = [
|
||||
"log 0.4.34",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11999,7 +12027,7 @@ name = "yaak-rpc-schema"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"yaak-git",
|
||||
"yaak-grpc",
|
||||
"yaak-models",
|
||||
@@ -12015,7 +12043,7 @@ name = "yaak-sse"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12034,7 +12062,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
@@ -12070,7 +12098,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -12091,6 +12119,23 @@ dependencies = [
|
||||
"yasna 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-updater"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.34",
|
||||
"md5",
|
||||
"nanoid",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-updater",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"ts-rs 12.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-wasm"
|
||||
version = "0.1.0"
|
||||
@@ -12129,7 +12174,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"ts-rs",
|
||||
"ts-rs 12.0.1",
|
||||
"url",
|
||||
"uuid",
|
||||
"yaak-http",
|
||||
|
||||
+3
-1
@@ -44,6 +44,7 @@ members = [
|
||||
"crates-tauri/yaak-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-updater",
|
||||
"crates-tauri/yaak-window",
|
||||
]
|
||||
|
||||
@@ -76,7 +77,7 @@ tauri-plugin-dialog = { version = "2.7.1", default-features = false }
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.48.0"
|
||||
ts-rs = "11.1.0"
|
||||
ts-rs = "12.0.1"
|
||||
|
||||
# Internal crates - common/foundation
|
||||
yaak-database = { path = "crates/common/yaak-database" }
|
||||
@@ -116,6 +117,7 @@ yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-updater = { path = "crates-tauri/yaak-updater" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
@@ -27,12 +25,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
onPinnedResponseId,
|
||||
}: Props) {
|
||||
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
||||
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
|
||||
const { value: dismissedMovedActions } = useKeyValue<boolean>({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", movedActionsBannerId],
|
||||
fallback: false,
|
||||
});
|
||||
const latestResponseId = responses[0]?.id ?? "n/a";
|
||||
const responseHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
@@ -123,20 +115,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
hidden: latestResponseId === activeResponse.id,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: dismissedMovedActions === true,
|
||||
label: (
|
||||
<DismissibleBanner
|
||||
id={movedActionsBannerId}
|
||||
color="info"
|
||||
size="xs"
|
||||
className="max-w-72"
|
||||
>
|
||||
<p>Copy and save actions moved to the Response tab menu.</p>
|
||||
</DismissibleBanner>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
label: "Recent",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
@@ -14,12 +14,7 @@ import {
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { Link } from "../core/Link";
|
||||
import {
|
||||
SettingCommand,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
@@ -56,13 +51,10 @@ export function SettingsAgents() {
|
||||
const cli = cliSetup(client);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Agents</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Connect AI coding agents like Claude Code and Cursor to Yaak.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsPage
|
||||
title="Agents"
|
||||
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
|
||||
>
|
||||
<SettingsList>
|
||||
<SettingsSection title={null}>
|
||||
<div className="pb-4 border-b border-border-subtle">
|
||||
@@ -134,6 +126,6 @@ export function SettingsAgents() {
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ClientCertificate } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRef } from "react";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<div className="mb-3">
|
||||
<HStack justifyContent="between" alignItems="start">
|
||||
<div>
|
||||
<Heading>Client Certificates</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Add and manage TLS certificates on a per domain basis
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
|
||||
{certificates.length > 0 && (
|
||||
<SettingsPage
|
||||
title="Client Certificates"
|
||||
description="Add and manage TLS certificates on a per domain basis"
|
||||
actions={
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
}
|
||||
banner={
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
}
|
||||
>
|
||||
{certificates.length > 0 ? (
|
||||
<VStack space={3}>
|
||||
{certificates.map((cert, index) => (
|
||||
<CertificateEditor
|
||||
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
SettingsPage,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
<SettingsPage
|
||||
title="Keyboard Shortcuts"
|
||||
description="Click the menu button to add, remove, or reset keyboard shortcuts."
|
||||
>
|
||||
<VStack space={3}>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import {
|
||||
@@ -26,15 +26,11 @@ export function SettingsProxy() {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
|
||||
<SettingsPage
|
||||
title="Proxy"
|
||||
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
|
||||
banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
|
||||
>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Proxy">
|
||||
<SettingRowSelect
|
||||
@@ -160,7 +156,7 @@ export function SettingsProxy() {
|
||||
</>
|
||||
)}
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +1,205 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { type EditorView, keymap } from "@codemirror/view";
|
||||
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
|
||||
import { CONTROL_TOOLTIP_DELAY, Icon, IconButton, Input, Tooltip } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
Button,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { decryptFilterOptions, entryLabel, normalizeEntry } from "../lib/decrypt";
|
||||
import {
|
||||
decryptFilterOptions,
|
||||
decryptsEverything,
|
||||
entryLabel,
|
||||
entrySubject,
|
||||
EVERYTHING,
|
||||
everythingEdit,
|
||||
normalizeEntry,
|
||||
} from "../lib/decrypt";
|
||||
import { revealPath } from "../lib/opener";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/**
|
||||
* What HTTPS is decrypted: one row per entry, the user's in order and then the built-in
|
||||
* exclusions, each removable, and an input that adds one, suggesting `host:`, `app:` and `!`,
|
||||
* then the hosts and apps seen in the capture. Saves as it changes. Placed by Settings' Proxy
|
||||
* tab.
|
||||
* Settings' Decryption section: the certificate, Decrypt everything as a switch over `*`, then
|
||||
* the person's own hosts and apps in order, each removable, an input that adds one (suggesting
|
||||
* `host:`, `app:` and `!`, then the hosts and apps seen), and the built-in exclusions folded
|
||||
* into one row. Every change goes through Decrypt, so the first that decrypts something trusts
|
||||
* the certificate first.
|
||||
*/
|
||||
export function DecryptListEditor({ className }: { className?: string }) {
|
||||
export function DecryptionSettings() {
|
||||
const list = useDecryptList();
|
||||
const save = useEditDecryptList();
|
||||
const { apply } = useDecrypt();
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Read when the input opens its suggestions, so new traffic shows without rebuilding it.
|
||||
const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" });
|
||||
const seenRef = useRef<SeenTargets | undefined>(seen.data);
|
||||
seenRef.current = seen.data;
|
||||
const everything = decryptsEverything(list);
|
||||
const own = list.filter((e) => !e.builtIn && e.entry !== EVERYTHING);
|
||||
const builtIns = list.filter((e) => e.builtIn);
|
||||
|
||||
// Each change replaces the whole list, so one waits for the last to land rather than undo it.
|
||||
const remove = (entry: string) => {
|
||||
if (save.isPending) return;
|
||||
save.mutate({ add: [], remove: [entry] });
|
||||
// One change at a time, each applied to the list as it is when it lands.
|
||||
const change = async (
|
||||
edit: { add: string[]; remove: string[] },
|
||||
message: string,
|
||||
sub?: string,
|
||||
) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apply({ edit, message, sub });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const remove = (entry: string) =>
|
||||
void change(
|
||||
{ add: [], remove: [entry] },
|
||||
entry.startsWith("!")
|
||||
? `Stopped excluding ${entrySubject(entry.slice(1))}`
|
||||
: `Stopped decrypting ${entrySubject(entry)}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(className, "grid gap-2")}>
|
||||
<EntryInput
|
||||
seenRef={seenRef}
|
||||
list={list}
|
||||
disabled={save.isPending}
|
||||
onAdd={(entry) => save.mutate({ add: [entry], remove: [] })}
|
||||
<>
|
||||
<CertificateRow />
|
||||
<SettingRowBoolean
|
||||
title="Decrypt everything"
|
||||
description="Except built-in exclusions"
|
||||
checked={everything}
|
||||
disabled={busy}
|
||||
onChange={(on) =>
|
||||
void change(
|
||||
everythingEdit(on),
|
||||
on ? "Decrypting everything" : "Stopped decrypting everything",
|
||||
on ? "Except Apple services and apps that pin" : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
|
||||
{list.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-subtle">Nothing decrypted</div>
|
||||
<div className="py-4 grid gap-2">
|
||||
<div className="flex items-center text-text">
|
||||
Hosts and apps
|
||||
<span className="ml-auto text-sm text-text-subtlest">New connections</span>
|
||||
</div>
|
||||
<EntryInput
|
||||
seenRef={seenRef}
|
||||
list={list}
|
||||
disabled={busy}
|
||||
onAdd={(entry) =>
|
||||
void change(
|
||||
{ add: [entry], remove: [] },
|
||||
entry.startsWith("!")
|
||||
? `Excluding ${entrySubject(entry.slice(1))}`
|
||||
: `Decrypting ${entrySubject(entry)}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
|
||||
{own.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-subtle">Nothing listed</div>
|
||||
)}
|
||||
{own.map((e) => (
|
||||
<EntryRow key={e.entry} entry={e} disabled={busy} onRemove={() => remove(e.entry)} />
|
||||
))}
|
||||
{builtIns.length > 0 && <BuiltIns entries={builtIns} disabled={busy} onRemove={remove} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The certificate: trusted, with Reveal for devices and tools with their own stores, or Trust */
|
||||
function CertificateRow() {
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const trust = useTrustCertificate();
|
||||
const trusting = useTrusting();
|
||||
const showToast = useShowToast();
|
||||
const status = caStatus.data;
|
||||
if (status == null)
|
||||
return (
|
||||
<SettingRow title="Certificate" description="Yaak Proxy CA">
|
||||
{null}
|
||||
</SettingRow>
|
||||
);
|
||||
return (
|
||||
<SettingRow title="Certificate" description="Yaak Proxy CA">
|
||||
<div className="flex items-center gap-2">
|
||||
{status.trusted ? (
|
||||
<span className="flex items-center gap-1 text-sm text-success">
|
||||
<Icon icon="badge_check" size="sm" />
|
||||
Trusted
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-text-subtle">Not trusted</span>
|
||||
)}
|
||||
{list.map((e) => (
|
||||
{!status.trusted && canTrustHere() ? (
|
||||
<Button size="2xs" color="primary" isLoading={trusting} onClick={() => trust.mutate({})}>
|
||||
{trusting ? "Waiting for macOS" : "Trust"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
onClick={() =>
|
||||
void revealPath(status.certPath, (message) =>
|
||||
showToast({ id: "reveal-ca", color: "danger", message }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Reveal
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** The built-in exclusions, folded into one row that opens to each, with its kind */
|
||||
function BuiltIns({
|
||||
entries,
|
||||
disabled,
|
||||
onRemove,
|
||||
}: {
|
||||
entries: DecryptListEntry[];
|
||||
disabled: boolean;
|
||||
onRemove: (entry: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const kinds = new Set(entries.map((e) => e.kind));
|
||||
const summary = [kinds.has("apple") ? "Apple" : null, kinds.has("pinned") ? "pinned apps" : null]
|
||||
.filter((k) => k != null)
|
||||
.join(", ");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 h-xs pl-3 pr-2 text-sm text-text-subtle hover:bg-surface-highlight/50"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Icon icon={open ? "chevron_down" : "chevron_right"} size="sm" className="shrink-0" />
|
||||
{entries.length} built-in exclusions
|
||||
<span className="ml-auto text-xs text-text-subtlest">{summary}</span>
|
||||
</button>
|
||||
{open &&
|
||||
entries.map((e) => (
|
||||
<EntryRow
|
||||
key={e.entry}
|
||||
entry={e}
|
||||
disabled={save.isPending}
|
||||
onRemove={() => remove(e.entry)}
|
||||
disabled={disabled}
|
||||
onRemove={() => onRemove(e.entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,12 +230,10 @@ function EntryRow({
|
||||
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
|
||||
{entryLabel(entry.entry)}
|
||||
</span>
|
||||
{entry.builtIn && (
|
||||
<Tooltip wrap content="Ships with Yaak Proxy" delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
|
||||
built-in
|
||||
</span>
|
||||
</Tooltip>
|
||||
{entry.builtIn && entry.kind != null && (
|
||||
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
|
||||
{entry.kind === "apple" ? "Apple" : "Pinned"}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
size="xs"
|
||||
@@ -160,7 +307,7 @@ function EntryInput({
|
||||
size="sm"
|
||||
stateKey={null}
|
||||
language={null}
|
||||
placeholder="Add host, app or *"
|
||||
placeholder="Add host or app"
|
||||
extensions={extensions}
|
||||
onChange={() => setError(null)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Flow, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
formatMillis,
|
||||
Icon,
|
||||
IconButton,
|
||||
type IconProps,
|
||||
InlineCode,
|
||||
Tooltip,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { canTrustHere, useDecrypt, usePassthrough, useTrustCertificate } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import {
|
||||
type CardAction,
|
||||
connectionOf,
|
||||
encryptedCard,
|
||||
isOpen,
|
||||
isRejection,
|
||||
type Line,
|
||||
} from "../lib/encryptedCard";
|
||||
import { useFlowActions } from "../lib/flowActions";
|
||||
import { utcTimestamp } from "../lib/flowViews";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
import { useCopyEnvironment } from "./ProxyStatus";
|
||||
|
||||
/**
|
||||
* The card for HTTPS the proxy did not decrypt, or a client's refused handshake: the flow card's
|
||||
* header lines, with ENCRYPTED or REJECTED where the status goes, then why in one line and the
|
||||
* changes that would decrypt the next connection like it, each showing the entry it writes.
|
||||
*/
|
||||
export function EncryptedCard({ flow }: { flow: Flow }) {
|
||||
const list = useDecryptList();
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const passthrough = usePassthrough();
|
||||
const rejection = isRejection(flow);
|
||||
// A tunnel passed through after a rejection names the client that refused.
|
||||
const rejectedById = flow.tlsRejected == null ? flow.tunnelRejectedBy : null;
|
||||
const rejectedBy = useRpcQuery(
|
||||
"get_flows",
|
||||
{ ids: rejectedById == null ? [] : [rejectedById] },
|
||||
{ enabled: rejectedById != null },
|
||||
).data?.[0];
|
||||
const model = encryptedCard({
|
||||
flow,
|
||||
list,
|
||||
trusted: caStatus.data?.trusted ?? null,
|
||||
canTrust: canTrustHere(),
|
||||
passthrough,
|
||||
rejectedBy,
|
||||
});
|
||||
const { apply, trusting } = useDecrypt();
|
||||
const trust = useTrustCertificate();
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const showToast = useShowToast();
|
||||
const fail = (err: Error) =>
|
||||
showToast({ id: "encrypted-card", color: "danger", message: err.message });
|
||||
const queryClient = useQueryClient();
|
||||
// Read from the answer too: an engine the CLI owns tells this app nothing of the change.
|
||||
const forget = useRpcMutation("forget_passthrough", {
|
||||
onError: fail,
|
||||
onSuccess: ({ addresses }) =>
|
||||
queryClient.setQueryData<PassthroughResponse>(["list_passthrough", {}], { addresses }),
|
||||
});
|
||||
const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail });
|
||||
const connection = connectionOf(flow);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const run = async (action: CardAction) => {
|
||||
switch (action.kind) {
|
||||
case "decrypt":
|
||||
case "stop":
|
||||
if (action.edit == null) return;
|
||||
setBusy(action.label);
|
||||
await apply({
|
||||
edit: action.edit,
|
||||
message: action.toast?.message ?? action.label,
|
||||
sub: action.toast?.sub,
|
||||
});
|
||||
setBusy(null);
|
||||
return;
|
||||
case "reconnect":
|
||||
reconnect.mutate({ flowId: flow.id });
|
||||
return;
|
||||
case "trust":
|
||||
trust.mutate({});
|
||||
return;
|
||||
case "try_again":
|
||||
forget.mutate({ address: connection.address });
|
||||
return;
|
||||
case "copy_environment":
|
||||
await copyEnvironment();
|
||||
forget.mutate({ address: connection.address });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center gap-2 px-3 min-h-[calc(var(--height-sm)+0.25rem)]",
|
||||
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={rejection ? "shield_off" : "lock"}
|
||||
size="sm"
|
||||
className={rejection ? "text-warning" : "text-text-subtlest"}
|
||||
/>
|
||||
<span className="text-text select-text cursor-text">
|
||||
{connection.host.includes(":") ? `[${connection.host}]` : connection.host}
|
||||
<span className="text-text-subtlest">:{connection.port}</span>
|
||||
</span>
|
||||
</div>
|
||||
<StatusLine flow={flow} rejected={flow.tlsRejected != null} />
|
||||
<div className="mx-3 border-t border-border-subtle overflow-auto">
|
||||
<div className="mx-auto max-w-[27rem] py-10 grid gap-2">
|
||||
<div className="text-sm text-text mb-1">
|
||||
<LineText line={model.why} />
|
||||
{model.whySub != null && <span className="text-text-subtle"> · {model.whySub}</span>}
|
||||
<Tooltip wrap content={ABOUT} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<IconButton
|
||||
size="2xs"
|
||||
icon="info"
|
||||
iconColor="secondary"
|
||||
title="About decryption"
|
||||
className="ml-1 align-middle inline-flex!"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{model.fact != null && (
|
||||
<div className="text-sm text-text-subtle -mt-1 mb-1">
|
||||
<LineText line={model.fact} />
|
||||
</div>
|
||||
)}
|
||||
{model.actions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.label}
|
||||
action={
|
||||
busy === action.label && trusting
|
||||
? { ...action, label: "Waiting for macOS" }
|
||||
: action
|
||||
}
|
||||
flow={flow}
|
||||
loading={
|
||||
busy === action.label ||
|
||||
(action.kind === "trust" && trust.isPending) ||
|
||||
(action.kind === "try_again" && forget.isPending) ||
|
||||
(action.kind === "reconnect" && reconnect.isPending)
|
||||
}
|
||||
disabled={busy != null && busy !== action.label}
|
||||
onClick={() => void run(action)}
|
||||
/>
|
||||
))}
|
||||
{model.foot.length > 0 && (
|
||||
<div className="text-xs text-text-subtlest mt-0.5">{model.foot.join(" · ")}</div>
|
||||
)}
|
||||
{flow.error != null && flow.tlsRejected == null && (
|
||||
<div className="text-xs text-danger mt-1">{flow.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the info button beside the why line says */
|
||||
const ABOUT =
|
||||
"HTTPS is encrypted between the app and the server. Yaak Proxy shows requests only for hosts and apps you decrypt, from their next connection on.";
|
||||
|
||||
/** A why or fact line, its hosts, apps and entries shown as code */
|
||||
function LineText({ line }: { line: Line }) {
|
||||
return (
|
||||
<>
|
||||
{line.map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<span key={i}>{part}</span>
|
||||
) : "bold" in part ? (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<span key={i} className="font-semibold">
|
||||
{part.bold}
|
||||
</span>
|
||||
) : (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<InlineCode key={i}>{part.code}</InlineCode>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS: Record<Exclude<CardAction["icon"], "app">, IconProps["icon"]> = {
|
||||
host: "globe",
|
||||
everything: "layers",
|
||||
lock: "lock",
|
||||
lock_open: "lock_open",
|
||||
refresh: "refresh",
|
||||
shield: "shield",
|
||||
terminal: "square_terminal",
|
||||
};
|
||||
|
||||
function ActionButton({
|
||||
action,
|
||||
flow,
|
||||
loading,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
action: CardAction;
|
||||
flow: Flow;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const icon: ReactNode =
|
||||
action.icon === "app" ? (
|
||||
flow.clientPath == null ? (
|
||||
<Icon icon="box" size="sm" />
|
||||
) : (
|
||||
<AppIcon path={flow.clientPath} className="size-4 min-w-4" />
|
||||
)
|
||||
) : (
|
||||
<Icon icon={ICONS[action.icon]} size="sm" />
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color={action.primary ? "primary" : "default"}
|
||||
justify="start"
|
||||
className={classNames("w-full", action.dashed && "border-dashed!")}
|
||||
isLoading={loading}
|
||||
disabled={disabled}
|
||||
leftSlot={loading ? undefined : icon}
|
||||
rightSlot={
|
||||
action.hint == null ? undefined : (
|
||||
<span
|
||||
className={classNames(
|
||||
"block ml-auto pl-3 font-mono text-xs text-text-subtlest truncate max-w-[11rem]",
|
||||
action.removes && "line-through",
|
||||
)}
|
||||
>
|
||||
{action.hint}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** ENCRYPTED or REJECTED, the duration, or how long it has been open, and the bytes each way */
|
||||
function StatusLine({ flow, rejected }: { flow: Flow; rejected: boolean }) {
|
||||
const flowActions = useFlowActions();
|
||||
const open = isOpen(flow);
|
||||
const openFor = useOpenFor(flow, open);
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] items-center pl-3",
|
||||
"min-h-[calc(var(--height-sm)+0.25rem)] font-mono text-sm text-text-subtle whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbars">
|
||||
<span className={rejected ? "text-warning" : undefined}>
|
||||
{rejected ? "REJECTED" : "ENCRYPTED"}
|
||||
</span>
|
||||
<span>•</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, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { everythingEdit } from "../lib/decrypt";
|
||||
import { decryptStep } from "../lib/firstRun";
|
||||
import { encryptingPercent } from "../lib/encrypting";
|
||||
import { proxyStateAtom } from "../lib/store";
|
||||
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
|
||||
@@ -20,49 +10,35 @@ import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./Proxy
|
||||
type StepState = "todo" | "next" | "wait" | "done" | "fail";
|
||||
|
||||
/**
|
||||
* The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
|
||||
* choose what to decrypt, then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
|
||||
* environment beside it serves someone who wants one terminal proxied and nothing system-wide.
|
||||
* The list's empty state until traffic arrives: turn on the system proxy, then see traffic. The
|
||||
* certificate is trusted at the first Decrypt, not here, as nothing needs it before. Not
|
||||
* dismissable and nothing remembered; the first flow replaces it. Copy environment beside it
|
||||
* serves someone who wants one terminal proxied and nothing system-wide.
|
||||
*/
|
||||
export function FirstRun() {
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const setup = useProxySetup();
|
||||
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||
const { systemProxy, setSystemProxy } = setup;
|
||||
const systemSwitch = useSystemProxySwitch(setup);
|
||||
const execute = useRpcMutation("execute_action");
|
||||
const retry = useRpcMutation("take_over");
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const decryptList = useDecryptList();
|
||||
const saveDecryptList = useEditDecryptList();
|
||||
|
||||
const running = proxyState?.state === "running";
|
||||
// Changed only by the process that owns the engine, so not from here while the CLI does.
|
||||
const otherOwner = proxyState?.otherOwner != null;
|
||||
const recording = proxyState?.recording !== false;
|
||||
const trusted = caStatus.data?.trusted === true;
|
||||
const systemSupported = systemProxy.data?.supported !== false;
|
||||
const systemOn = systemProxy.data?.enabled === true;
|
||||
|
||||
const trustState: StepState = trusted
|
||||
? "done"
|
||||
: trust.isPending
|
||||
? "wait"
|
||||
: trust.error != null
|
||||
? "fail"
|
||||
: "next";
|
||||
const systemState: StepState = setSystemProxy.isPending
|
||||
? "wait"
|
||||
: systemOn
|
||||
? "done"
|
||||
: systemSwitch.failed
|
||||
? "fail"
|
||||
: trusted
|
||||
? "next"
|
||||
: "todo";
|
||||
const decrypt = decryptStep(decryptList, trusted);
|
||||
const everything = decrypt.choice === "everything";
|
||||
const setEverything = (on: boolean) => saveDecryptList.mutate(everythingEdit(on));
|
||||
const ready = trusted && (systemOn || !systemSupported);
|
||||
: "next";
|
||||
const ready = systemOn || !systemSupported;
|
||||
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
|
||||
const trafficState: StepState =
|
||||
encrypting != null ? "wait" : !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
|
||||
@@ -72,39 +48,14 @@ export function FirstRun() {
|
||||
<div className="w-full max-w-[40rem]">
|
||||
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
|
||||
<div className="text-text-subtle text-sm leading-relaxed mb-4">
|
||||
{systemSupported ? "Four" : "Three"} steps, once. Then every app on this Mac sends its
|
||||
traffic here.
|
||||
{systemSupported
|
||||
? "Two steps, once. Then every app on this Mac sends its traffic here."
|
||||
: "Traffic appears here as apps send it."}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
|
||||
<Step
|
||||
n={1}
|
||||
state={trustState}
|
||||
title={trusted ? "Certificate trusted" : "Trust the certificate"}
|
||||
description={
|
||||
trusted
|
||||
? "Yaak Proxy CA, in your login keychain."
|
||||
: trustState === "wait"
|
||||
? "Waiting for your password in the macOS prompt."
|
||||
: trustState === "fail"
|
||||
? (trust.error?.message ?? "The certificate was not trusted.")
|
||||
: "So HTTPS reads as plain text. macOS asks for your login password once."
|
||||
}
|
||||
action={
|
||||
trustState === "next" || trustState === "fail" ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant={trustState === "fail" ? "border" : "solid"}
|
||||
color={trustState === "fail" ? "default" : "primary"}
|
||||
onClick={() => trust.mutate({})}
|
||||
>
|
||||
{trustState === "fail" ? "Try Again" : "Trust Certificate"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{systemSupported && (
|
||||
<Step
|
||||
n={2}
|
||||
n={1}
|
||||
state={systemState}
|
||||
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
|
||||
description={
|
||||
@@ -129,49 +80,7 @@ export function FirstRun() {
|
||||
/>
|
||||
)}
|
||||
<Step
|
||||
n={systemSupported ? 3 : 2}
|
||||
state={decrypt.state}
|
||||
title="What to decrypt"
|
||||
description={
|
||||
everything ? (
|
||||
<Tooltip
|
||||
wrap
|
||||
content="Except the built-in exclusions"
|
||||
delay={CONTROL_TOOLTIP_DELAY}
|
||||
>
|
||||
<span>Everything</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip wrap content="From a flow or the sidebar" delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<span>Pick as they appear</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
action={
|
||||
everything ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
isLoading={saveDecryptList.isPending}
|
||||
onClick={() => setEverything(false)}
|
||||
>
|
||||
Pick As They Appear
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={decrypt.state === "next" ? "primary" : "default"}
|
||||
isLoading={saveDecryptList.isPending}
|
||||
onClick={() => setEverything(true)}
|
||||
>
|
||||
Everything
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
n={systemSupported ? 4 : 3}
|
||||
n={systemSupported ? 2 : 1}
|
||||
state={trafficState}
|
||||
title={
|
||||
encrypting != null
|
||||
@@ -184,12 +93,12 @@ export function FirstRun() {
|
||||
encrypting != null
|
||||
? "Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
|
||||
: trafficState === "fail"
|
||||
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
|
||||
: !recording
|
||||
? "Recording is paused, so traffic passes through without being stored."
|
||||
: trafficState === "wait"
|
||||
? "Open a website or send a request, and its flow appears here."
|
||||
: "Flows appear here as apps send requests."
|
||||
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
|
||||
: !recording
|
||||
? "Recording is paused, so traffic passes through without being stored."
|
||||
: trafficState === "wait"
|
||||
? "Open a website or send a request, and its flow appears here."
|
||||
: "Flows appear here as apps send requests."
|
||||
}
|
||||
action={
|
||||
trafficState === "fail" ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
@@ -72,10 +82,55 @@ export function FlowInfo({ flow }: { flow: Flow }) {
|
||||
row("saved", "Saved As", flow.savedRequestId, { copy: true }),
|
||||
]}
|
||||
</Section>
|
||||
<Timeline flow={flow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
|
||||
start: { icon: "send_horizontal", color: "secondary" },
|
||||
step: { icon: "arrow_right", color: "secondary" },
|
||||
end: { icon: "check", color: "success" },
|
||||
error: { icon: "alert_triangle", color: "danger" },
|
||||
cancelled: { icon: "circle_off", color: "secondary" },
|
||||
};
|
||||
|
||||
/** The flow's phases, each with how long it took and when, from the start */
|
||||
function Timeline({ flow }: { flow: Flow }) {
|
||||
const phases = useMemo(() => flowPhases(flow), [flow]);
|
||||
if (phases.length === 0) return null;
|
||||
return (
|
||||
<DetailsBanner defaultOpen summary={<h2>Timeline</h2>}>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] gap-x-3 gap-y-1.5 items-center text-sm py-1">
|
||||
{phases.map((p) => {
|
||||
const { icon, color } = PHASE_ICONS[p.kind];
|
||||
return (
|
||||
<Fragment key={p.key}>
|
||||
<Icon icon={icon} color={color} size="sm" />
|
||||
{p.detail == null ? (
|
||||
<span className="truncate">{p.label}</span>
|
||||
) : (
|
||||
<Tooltip wrap content={p.detail} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<span className="truncate">
|
||||
{p.label}
|
||||
<span className="text-text-subtlest">{` ${p.detail}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="font-mono text-xs tabular-nums text-right">
|
||||
{p.kind === "start" ? "" : `+${formatMillis(p.tookMs)}`}
|
||||
</span>
|
||||
<span className="font-mono text-xs tabular-nums text-right text-text-subtlest">
|
||||
{formatMillis(p.atMs)}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row as `KeyValueRows` takes it */
|
||||
type Row = ReactElement<HTMLAttributes<HTMLTableColElement>>;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import { compareFlows } from "../lib/flowWindow";
|
||||
import { countNewFlows, isAtEnd, resumesFollowing } from "../lib/follow";
|
||||
import { applyFilter, goToPlace } from "../lib/navigation";
|
||||
import { asOneTerm } from "../lib/query";
|
||||
import { connectionOf } from "../lib/encryptedCard";
|
||||
import { folderOf } from "../lib/search";
|
||||
import {
|
||||
columnPrefsAtom,
|
||||
@@ -853,6 +854,9 @@ const FlowRow = memo(function FlowRow({
|
||||
// A failure's reason, or Cancelled, takes the type and size cells together, side by side.
|
||||
const typeAt = ids.indexOf("type");
|
||||
const spanReason = noResponse && typeAt !== -1 && ids[typeAt + 1] === "size";
|
||||
// An encrypted connection has no path, so its host takes the room when Path is beside it.
|
||||
const hostAt = ids.indexOf("host");
|
||||
const spanHost = flow.tunnelled && hostAt !== -1 && ids[hostAt + 1] === "path";
|
||||
const sinceArrival = arrivedAt == null ? null : Date.now() - arrivedAt;
|
||||
|
||||
return (
|
||||
@@ -885,10 +889,16 @@ const FlowRow = memo(function FlowRow({
|
||||
<FlowMark flow={flow} />
|
||||
{columns.map((c) => {
|
||||
if (spanReason && c.id === "size") return null;
|
||||
if (spanHost && c.id === "path") return null;
|
||||
return (
|
||||
// Names the cell's column for its menu, without a box of its own in the row's grid
|
||||
<div key={c.id} data-column={c.id} className="contents">
|
||||
<FlowCell column={c.id} flow={flow} span={spanReason && c.id === "type"} />
|
||||
<FlowCell
|
||||
column={c.id}
|
||||
flow={flow}
|
||||
selected={selected}
|
||||
span={(spanReason && c.id === "type") || (spanHost && c.id === "host")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -896,27 +906,46 @@ const FlowRow = memo(function FlowRow({
|
||||
);
|
||||
});
|
||||
|
||||
/** Why an encrypted row is, for its mark's tooltip */
|
||||
function encryptedReason(flow: Flow): string {
|
||||
switch (flow.tunnelReason) {
|
||||
case "excluded":
|
||||
return flow.tunnelExcludedBuiltIn || flow.tunnelExcludedBy == null
|
||||
? "Encrypted: excluded by default"
|
||||
: `Encrypted: excluded by ${flow.tunnelExcludedBy}`;
|
||||
case "rejected":
|
||||
return "Encrypted: passed through after a client rejected the certificate";
|
||||
default:
|
||||
return "Encrypted: not on the decrypt list";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One mark per row, by a fixed priority: failed, cancelled, not decrypted, streaming, cut, saved.
|
||||
* Its tooltip lists every state the flow has. Agent reads join it once the engine records them.
|
||||
* One mark per row, by a fixed priority: rejected, failed, cancelled, encrypted, streaming, cut,
|
||||
* saved. Its tooltip lists every state the flow has. Agent reads join it once the engine records
|
||||
* them.
|
||||
*/
|
||||
function FlowMark({ flow }: { flow: Flow }) {
|
||||
const open = isOpenStream(flow);
|
||||
const rejected = flow.tlsRejected != null;
|
||||
const states: string[] = [];
|
||||
if (flow.error != null) states.push(`Failed: ${flow.error}`);
|
||||
if (rejected) states.push("Rejected the proxy's certificate");
|
||||
else if (flow.error != null) states.push(`Failed: ${flow.error}`);
|
||||
if (flow.cancelled) states.push("Cancelled by the app");
|
||||
if (flow.tunnelled) states.push("Not decrypted");
|
||||
if (flow.tunnelled) states.push(encryptedReason(flow));
|
||||
if (open) states.push("Streaming");
|
||||
if (flow.responseTruncated) states.push("Body cut at the capture limit");
|
||||
if (flow.savedRequestId != null) states.push("Saved to Yaak");
|
||||
const mark = (
|
||||
<span className="flex items-center justify-center">
|
||||
{flow.error != null ? (
|
||||
{rejected ? (
|
||||
<Icon icon="shield_off" size="sm" color="warning" />
|
||||
) : flow.error != null ? (
|
||||
<Icon icon="circle_alert" size="sm" color="danger" />
|
||||
) : flow.cancelled ? (
|
||||
<Icon icon="circle_off" size="sm" className="text-text-subtlest" />
|
||||
) : flow.tunnelled ? (
|
||||
<Icon icon="lock" size="sm" className="text-text-subtlest" />
|
||||
<Icon icon="lock" size="sm" className="text-text-subtle" />
|
||||
) : open ? (
|
||||
<span aria-hidden className="w-[7px] h-[7px] rounded-full bg-info ring-3 ring-info/20" />
|
||||
) : flow.responseTruncated ? (
|
||||
@@ -958,7 +987,141 @@ function isOpenStream(flow: Flow): boolean {
|
||||
return flow.streamKind != null && flow.elapsedMs == null && flow.error == null && !flow.cancelled;
|
||||
}
|
||||
|
||||
function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span: boolean }) {
|
||||
interface CellProps {
|
||||
column: ColumnId;
|
||||
flow: Flow;
|
||||
/** Takes the next cell's room too */
|
||||
span: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of an encrypted or refused row: told apart by colour alone, the faintest that stays
|
||||
* readable on the row (subtle, 4.5:1 on the surface in both default themes), and the full text
|
||||
* colour while selected, as subtle falls under that on the dark theme's selection. No opacity,
|
||||
* so the app icon and the mark show as on any row.
|
||||
*/
|
||||
function quietText(selected: boolean): string {
|
||||
return selected ? "text-text" : "text-text-subtle";
|
||||
}
|
||||
|
||||
/** A row's number, in the quiet colour */
|
||||
function quietNumber(flow: Flow, selected: boolean): ReactElement {
|
||||
return <span className={classNames(cell, mono, quietText(selected))}>{flow.number || ""}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An encrypted connection's cell: quiet, and only what is known, so its host, how much it
|
||||
* carried and for how long; nothing read from inside it, so no method, path, status, type,
|
||||
* query or field. Null for the cells that show as on any row, the app icon among them.
|
||||
*/
|
||||
function encryptedCell({ column, flow, span, selected }: CellProps): ReactElement | null {
|
||||
const quiet = classNames(cell, mono, quietText(selected));
|
||||
switch (column) {
|
||||
case "app":
|
||||
case "started":
|
||||
return null;
|
||||
case "number":
|
||||
return quietNumber(flow, selected);
|
||||
case "host": {
|
||||
const { label } = connectionOf(flow);
|
||||
return (
|
||||
<Truncated text={label}>
|
||||
<span
|
||||
className={classNames(cell, "rtl text-left", span && "col-span-2", quietText(selected))}
|
||||
>
|
||||
<bdi>{label}</bdi>
|
||||
</span>
|
||||
</Truncated>
|
||||
);
|
||||
}
|
||||
case "type":
|
||||
// A tunnel that failed to reach the server keeps the failure look, across Type and Size
|
||||
if (flow.error == null) return <span className={cell} />;
|
||||
return (
|
||||
<Tooltip wrap content={flow.error} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<span className={classNames(cell, "text-danger text-xs", span && "col-span-2")}>
|
||||
{flow.error}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
case "size":
|
||||
return (
|
||||
<span className={classNames(quiet, "text-right")}>
|
||||
{isOpenTunnel(flow) ? "" : formatSize(flow.responseSize)}
|
||||
</span>
|
||||
);
|
||||
case "requestSize":
|
||||
return (
|
||||
<span className={classNames(quiet, "text-right")}>
|
||||
{isOpenTunnel(flow) ? "" : formatSize(flow.requestSize)}
|
||||
</span>
|
||||
);
|
||||
case "time": {
|
||||
const ms = isOpenTunnel(flow)
|
||||
? Date.now() - Date.parse(utcTimestamp(flow.startedAt))
|
||||
: flow.elapsedMs;
|
||||
return (
|
||||
<span className={classNames(quiet, "text-right")}>
|
||||
{ms == null || !Number.isFinite(ms) ? "" : formatMillis(Math.max(0, ms))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <span className={cell} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A refused handshake's cell: its host, number and time in the quiet colour, the refusal across
|
||||
* Type and Size in warning, and nothing for the request it never made. Null for the cells that
|
||||
* show as on any row, the app icon among them.
|
||||
*/
|
||||
function rejectedCell({ column, flow, span, selected }: CellProps): ReactElement | null {
|
||||
switch (column) {
|
||||
case "method":
|
||||
case "path":
|
||||
case "status":
|
||||
case "query":
|
||||
case "size":
|
||||
return <span className={cell} />;
|
||||
case "number":
|
||||
return quietNumber(flow, selected);
|
||||
case "host": {
|
||||
const host = hostOf(flow.url);
|
||||
return (
|
||||
<Truncated text={host}>
|
||||
<span className={classNames(cell, "rtl text-left", quietText(selected))}>
|
||||
<bdi>{host}</bdi>
|
||||
</span>
|
||||
</Truncated>
|
||||
);
|
||||
}
|
||||
case "time":
|
||||
return (
|
||||
<span className={classNames(cell, mono, "text-right", quietText(selected))}>
|
||||
{flow.elapsedMs == null ? "" : formatMillis(flow.elapsedMs)}
|
||||
</span>
|
||||
);
|
||||
case "type":
|
||||
return (
|
||||
<span className={classNames(cell, "text-warning text-xs", span && "col-span-2")}>
|
||||
Rejected certificate
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FlowCell(props: CellProps) {
|
||||
const { column, flow, span } = props;
|
||||
const special = flow.tunnelled
|
||||
? encryptedCell(props)
|
||||
: flow.tlsRejected != null
|
||||
? rejectedCell(props)
|
||||
: null;
|
||||
if (special != null) return special;
|
||||
const failed = flow.error != null;
|
||||
const noResponse = (failed || flow.cancelled) && flow.status == null;
|
||||
switch (column) {
|
||||
@@ -1005,10 +1168,6 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
|
||||
);
|
||||
}
|
||||
case "path": {
|
||||
// A tunnel's only path is its host; what it carried is unread.
|
||||
if (flow.tunnelled) {
|
||||
return <span className={classNames(cell, "text-text-subtlest")}>Not decrypted</span>;
|
||||
}
|
||||
const path = pathOf(flow.url);
|
||||
if (flow.streamKind == null) {
|
||||
return (
|
||||
@@ -1034,7 +1193,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
|
||||
);
|
||||
}
|
||||
case "status":
|
||||
return (flow.cancelled || flow.tunnelled) && flow.status == null ? (
|
||||
return flow.cancelled && flow.status == null ? (
|
||||
<span className={classNames(cell, mono, "text-text-subtlest")}>--</span>
|
||||
) : (
|
||||
<HttpStatusTagRaw status={flow.status ?? 0} short className={classNames(cell, mono)} />
|
||||
@@ -1104,7 +1263,7 @@ function FlowCell({ column, flow, span }: { column: ColumnId; flow: Flow; span:
|
||||
</span>
|
||||
);
|
||||
case "query": {
|
||||
const query = flow.tunnelled ? "" : (urlParts(flow.url).query ?? "");
|
||||
const query = urlParts(flow.url).query ?? "";
|
||||
return (
|
||||
<Truncated text={query}>
|
||||
<span className={classNames(cell, "text-text-subtle")}>{query}</span>
|
||||
|
||||
@@ -6,7 +6,13 @@ import { appliedFilterAtom } from "../lib/atoms";
|
||||
import { applyFilter } from "../lib/navigation";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { activeQuery, widerQueries } from "../lib/query";
|
||||
import { hideSystemAtom, pausedAtAtom, proxyStateAtom, searchWindowAtom } from "../lib/store";
|
||||
import {
|
||||
hideEncryptedAtom,
|
||||
hideSystemAtom,
|
||||
pausedAtAtom,
|
||||
proxyStateAtom,
|
||||
searchWindowAtom,
|
||||
} from "../lib/store";
|
||||
|
||||
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
|
||||
export function FlowListEmpty() {
|
||||
@@ -64,6 +70,7 @@ export function PausedRow({ style }: { style?: React.CSSProperties }) {
|
||||
*/
|
||||
function NoResults({ text }: { text: string }) {
|
||||
const hideSystem = useAtomValue(hideSystemAtom);
|
||||
const hideEncrypted = useAtomValue(hideEncryptedAtom);
|
||||
const store = useStore();
|
||||
const setText = (wider: string) => applyFilter(store, wider);
|
||||
const [wider, setWider] = useState<{ text: string; count: number }[]>([]);
|
||||
@@ -73,7 +80,7 @@ function NoResults({ text }: { text: string }) {
|
||||
const candidates = widerQueries(text);
|
||||
void Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
const query = activeQuery(candidate, hideSystem);
|
||||
const query = activeQuery(candidate, hideSystem, hideEncrypted);
|
||||
const res = await rpc("list_flows", { ...query, limit: 0 });
|
||||
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
|
||||
}),
|
||||
@@ -87,7 +94,7 @@ function NoResults({ text }: { text: string }) {
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [text, hideSystem]);
|
||||
}, [text, hideSystem, hideEncrypted]);
|
||||
|
||||
return (
|
||||
<EmptyStateText
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
resetWidth,
|
||||
resetWidths,
|
||||
resizeColumn,
|
||||
resizeDistance,
|
||||
toggleColumn,
|
||||
unavailableColumns,
|
||||
} from "../lib/columns";
|
||||
@@ -264,7 +265,10 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
onResizeStart={() => {
|
||||
startWidth.current = c.current;
|
||||
}}
|
||||
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
|
||||
onResizeMove={(dx) => {
|
||||
const by = resizeDistance(c, dx, rootFontSize());
|
||||
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + by));
|
||||
}}
|
||||
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
|
||||
/>
|
||||
))}
|
||||
@@ -279,6 +283,11 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
);
|
||||
});
|
||||
|
||||
/** The interface font size now, which numeric columns' widths grow with */
|
||||
function rootFontSize(): number {
|
||||
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
}
|
||||
|
||||
interface HeaderCellProps {
|
||||
column: Column;
|
||||
sort: FlowSortKey[];
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
EventViewer,
|
||||
EventViewerRow,
|
||||
formatMillis,
|
||||
Icon,
|
||||
type IconProps,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useMemo } from "react";
|
||||
import { type FlowPhase, flowPhases, type PhaseKind } from "../lib/phases";
|
||||
|
||||
const ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
|
||||
start: { icon: "send_horizontal", color: "secondary" },
|
||||
step: { icon: "arrow_right", color: "secondary" },
|
||||
end: { icon: "check", color: "success" },
|
||||
error: { icon: "alert_triangle", color: "danger" },
|
||||
cancelled: { icon: "circle_off", color: "secondary" },
|
||||
};
|
||||
|
||||
/** Yaak's event list with the flow's phases as rows, each saying how long it took */
|
||||
export function FlowTimeline({ flow }: { flow: Flow }) {
|
||||
const phases = useMemo(() => flowPhases(flow), [flow]);
|
||||
return (
|
||||
<EventViewer<FlowPhase>
|
||||
events={phases}
|
||||
getEventKey={(p) => p.key}
|
||||
splitLayoutStorageKey="proxy_flow_timeline"
|
||||
enableKeyboardNav={false}
|
||||
emptyMessage="No timings recorded"
|
||||
renderRow={({ event, isActive, onClick }) => {
|
||||
const { icon, color } = ICONS[event.kind];
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon icon={icon} color={color} size="sm" />}
|
||||
content={
|
||||
event.detail == null ? (
|
||||
<span>{event.label}</span>
|
||||
) : (
|
||||
<Tooltip
|
||||
wrap
|
||||
content={event.detail}
|
||||
delay={CONTROL_TOOLTIP_DELAY}
|
||||
>
|
||||
<span>
|
||||
{event.label}
|
||||
<span className="text-text-subtlest">{` ${event.detail}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
columns="auto minmax(0,1fr) 6rem 6rem"
|
||||
cells={{
|
||||
trailing: [
|
||||
<span key="took" className="tabular-nums">
|
||||
{event.kind === "start" ? "" : `+${formatMillis(event.tookMs)}`}
|
||||
</span>,
|
||||
<span key="at" className="tabular-nums opacity-50">
|
||||
{formatMillis(event.atMs)}
|
||||
</span>,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Button,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
Icon,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { everythingEdit, listEdit, setDecrypted } from "../lib/decrypt";
|
||||
import { chooseOptions, connectionCount, stripState } from "../lib/httpsStrip";
|
||||
import { sidebarFlowsAtom } from "../lib/store";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
import { SETTINGS_TAB_PROXY, useOpenSettings } from "./Settings";
|
||||
|
||||
const STRIP =
|
||||
"flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0";
|
||||
|
||||
/**
|
||||
* Above the list while nothing is decrypted: says once that HTTPS is encrypted, and offers
|
||||
* Decrypt Everything, or Choose… for apps and sites one at a time. Goes once anything is on
|
||||
* the list, or when dismissed; comes back, with Trust, while the list decrypts something the
|
||||
* certificate isn't trusted for.
|
||||
*/
|
||||
export function HttpsStrip() {
|
||||
const list = useDecryptList();
|
||||
const flows = useAtomValue(sidebarFlowsAtom);
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const [dismissed, setDismissed] = useLocalStorage("https_strip_dismissed", false);
|
||||
// Kept while Choose… is open, so checking a row there leaves the menu where it is.
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const { apply } = useDecrypt();
|
||||
const trusting = useTrusting();
|
||||
const trust = useTrustCertificate();
|
||||
const openSettings = useOpenSettings();
|
||||
const encrypted = useMemo(() => flows.filter((f) => f.tunnelled).length, [flows]);
|
||||
const state = stripState({
|
||||
list,
|
||||
encrypted,
|
||||
dismissed: dismissed ?? false,
|
||||
trusted: caStatus.data?.trusted ?? null,
|
||||
canTrust: canTrustHere(),
|
||||
});
|
||||
const options = useMemo(() => chooseOptions(flows, list), [flows, list]);
|
||||
|
||||
if (state === "hidden" && !choosing) return null;
|
||||
|
||||
if (state === "broken") {
|
||||
return (
|
||||
<div className={STRIP}>
|
||||
<Icon icon="shield_off" size="sm" color="warning" className="shrink-0" />
|
||||
<span className="text-text shrink-0">Certificate not trusted</span>
|
||||
<span className="truncate">Required to decrypt</span>
|
||||
<Button
|
||||
size="2xs"
|
||||
color="primary"
|
||||
className="ml-auto shrink-0"
|
||||
isLoading={trusting}
|
||||
onClick={() => trust.mutate({})}
|
||||
>
|
||||
{trusting ? "Waiting for macOS" : "Trust"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ type: "separator", label: "Apps", hidden: options.apps.length === 0 },
|
||||
...options.apps.map((app): DropdownItem => ({
|
||||
label:
|
||||
app.path == null ? (
|
||||
app.name
|
||||
) : (
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<AppIcon path={app.path} className="size-4 min-w-4" />
|
||||
<span className="truncate">{app.name}</span>
|
||||
</span>
|
||||
),
|
||||
keepOpenOnSelect: true,
|
||||
leftSlot: <Check on={app.checked} />,
|
||||
rightSlot: <Count n={app.count} />,
|
||||
onSelect: async () => {
|
||||
const subject = { kind: "app", name: app.name, bundleId: app.bundleId } as const;
|
||||
await apply({
|
||||
edit: listEdit(list, setDecrypted(list, [subject], !app.checked)),
|
||||
message: `${app.checked ? "Stopped decrypting" : "Decrypting"} ${app.name}`,
|
||||
});
|
||||
},
|
||||
})),
|
||||
{ type: "separator", label: "Sites", hidden: options.sites.length === 0 },
|
||||
...options.sites.map((site): DropdownItem => ({
|
||||
label: <span className="font-mono text-xs">*.{site.site}</span>,
|
||||
keepOpenOnSelect: true,
|
||||
leftSlot: <Check on={site.checked} />,
|
||||
rightSlot: <Count n={site.count} />,
|
||||
onSelect: async () => {
|
||||
const subject = { kind: "site", site: site.site } as const;
|
||||
await apply({
|
||||
edit: listEdit(list, setDecrypted(list, [subject], !site.checked)),
|
||||
message: `${site.checked ? "Stopped decrypting" : "Decrypting"} *.${site.site}`,
|
||||
});
|
||||
},
|
||||
})),
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Edit List in Settings…",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openSettings(SETTINGS_TAB_PROXY),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className={STRIP}>
|
||||
<Icon icon="lock" size="sm" className="shrink-0 text-text-subtle" />
|
||||
<span className="text-text shrink-0">HTTPS is encrypted</span>
|
||||
<span className="font-mono text-xs text-text-subtlest truncate">
|
||||
{connectionCount(encrypted)}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<Tooltip
|
||||
wrap
|
||||
content="New connections, except Apple services and apps that pin"
|
||||
delay={CONTROL_TOOLTIP_DELAY}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant={trusting ? "border" : "solid"}
|
||||
color={trusting ? "default" : "primary"}
|
||||
isLoading={trusting}
|
||||
onClick={() =>
|
||||
void apply({
|
||||
edit: everythingEdit(true),
|
||||
message: "Decrypting everything",
|
||||
sub: "Except Apple services and apps that pin",
|
||||
})
|
||||
}
|
||||
>
|
||||
{trusting ? "Waiting for macOS" : "Decrypt Everything"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown items={items} onOpen={() => setChoosing(true)} onClose={() => setChoosing(false)}>
|
||||
<Button size="2xs" variant="border">
|
||||
Choose…
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
iconColor="secondary"
|
||||
title="Not now"
|
||||
tooltip
|
||||
onClick={() => setDismissed(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ on }: { on: boolean }) {
|
||||
return on ? (
|
||||
<Icon icon="check" size="sm" />
|
||||
) : (
|
||||
<span aria-hidden className="inline-block w-4 h-4" />
|
||||
);
|
||||
}
|
||||
|
||||
function Count({ n }: { n: number }) {
|
||||
return <span className="pl-4 font-mono text-xs text-text-subtlest">{n.toLocaleString()}</span>;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import { Button, CONTROL_TOOLTIP_DELAY, formatMillis, Icon, Tooltip } from "@yaakapp-internal/ui";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { type DecryptSubject, detailActions, listEdit, setDecrypted } from "../lib/decrypt";
|
||||
import { sidebarPath } from "../lib/search";
|
||||
import { EmptyState } from "./FlowBodyViewer";
|
||||
|
||||
/**
|
||||
* The detail of HTTPS passed through undecrypted: its sizes and time, and buttons that add its
|
||||
* host or its app to the decrypt list, from the next connection on.
|
||||
*/
|
||||
export function NotDecrypted({ flow }: { flow: Flow }) {
|
||||
const list = useDecryptList();
|
||||
const save = useEditDecryptList();
|
||||
const host = sidebarPath(flow.url).split("/")[0] ?? flow.url;
|
||||
const hostSubject: DecryptSubject = { kind: "host", host };
|
||||
const appSubject: DecryptSubject | null =
|
||||
flow.clientName == null
|
||||
? null
|
||||
: { kind: "app", name: flow.clientName, bundleId: flow.clientBundleId };
|
||||
const app = flow.clientName;
|
||||
const actions = detailActions(
|
||||
list,
|
||||
host,
|
||||
app == null ? null : { name: app, bundleId: flow.clientBundleId },
|
||||
);
|
||||
const open = flow.elapsedMs == null && flow.error == null;
|
||||
|
||||
const decrypt = (subject: DecryptSubject) =>
|
||||
save.mutate(listEdit(list, setDecrypted(list, [subject], true)));
|
||||
|
||||
const facts = [
|
||||
`${formatSize(Number(flow.requestSize))} sent`,
|
||||
`${formatSize(Number(flow.responseSize))} received`,
|
||||
open ? "open" : flow.elapsedMs == null ? null : formatMillis(flow.elapsedMs),
|
||||
].filter((f) => f != null);
|
||||
|
||||
return (
|
||||
<EmptyState>
|
||||
<div className="grid justify-items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-text-subtle">
|
||||
<Icon icon="lock" size="sm" />
|
||||
Not decrypted
|
||||
</div>
|
||||
<div className="font-mono text-xs">{facts.join(" · ")}</div>
|
||||
{flow.error != null && <div className="text-danger">{flow.error}</div>}
|
||||
{actions.listed ? (
|
||||
<Tooltip
|
||||
wrap
|
||||
content="Still tunnelled? The app may pin its certificate"
|
||||
delay={CONTROL_TOOLTIP_DELAY}
|
||||
>
|
||||
<div>Decrypting new connections</div>
|
||||
</Tooltip>
|
||||
) : actions.blocked != null ? (
|
||||
<Tooltip
|
||||
wrap
|
||||
content="Remove it from the decrypt list to decrypt"
|
||||
delay={CONTROL_TOOLTIP_DELAY}
|
||||
>
|
||||
<div>
|
||||
Excluded by <span className="font-mono text-xs">{actions.blocked}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex gap-2 mt-1">
|
||||
{actions.host && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title={`Decrypt new connections to ${host}`}
|
||||
tooltip
|
||||
disabled={save.isPending}
|
||||
onClick={() => decrypt(hostSubject)}
|
||||
>
|
||||
Decrypt Host
|
||||
</Button>
|
||||
)}
|
||||
{actions.app && appSubject != null && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title={`Decrypt new connections from ${app}`}
|
||||
tooltip
|
||||
disabled={save.isPending}
|
||||
onClick={() => decrypt(appSubject)}
|
||||
>
|
||||
Decrypt App
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -108,7 +119,7 @@ export function ProxyStatus() {
|
||||
const address = port == null ? null : `127.0.0.1:${port}`;
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
label: <span className="font-mono">{address}</span>,
|
||||
label: <span className="font-mono text-editor">{address}</span>,
|
||||
hidden: address == null,
|
||||
keepOpenOnSelect: true,
|
||||
rightSlot: copied ? (
|
||||
@@ -166,44 +177,7 @@ export function ProxyStatus() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
label: caStatus.data?.trusted ? (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_check" color="success" size="sm" />}
|
||||
title="Certificate trusted"
|
||||
description="Yaak Proxy CA, in your login keychain."
|
||||
/>
|
||||
) : (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_off" color="warning" size="sm" />}
|
||||
title="Certificate not trusted"
|
||||
description={
|
||||
trust.error != null ? (
|
||||
<span className="text-danger">{trust.error.message}</span>
|
||||
) : trust.isPending ? (
|
||||
"Waiting for your password in the macOS prompt."
|
||||
) : (
|
||||
"So HTTPS reads as plain text. macOS asks for your login password once."
|
||||
)
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="primary"
|
||||
isLoading={trust.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
trust.mutate({});
|
||||
}}
|
||||
>
|
||||
{trust.error != null ? "Try again" : "Trust certificate"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: "content", label: <DecryptEverythingRow broken={broken} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -235,7 +209,7 @@ export function ProxyStatus() {
|
||||
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
|
||||
</Pill>
|
||||
</Dropdown>
|
||||
{untrusted && (
|
||||
{broken && (
|
||||
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
|
||||
<Icon icon="shield_off" size="xs" />
|
||||
Certificate not trusted
|
||||
@@ -245,6 +219,73 @@ export function ProxyStatus() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt everything, `*` on the list, as a switch: what else is listed when off, and Trust
|
||||
* while something is listed and the certificate isn't trusted.
|
||||
*/
|
||||
function DecryptEverythingRow({ broken }: { broken: boolean }) {
|
||||
const list = useDecryptList();
|
||||
const { apply, trusting } = useDecrypt();
|
||||
const trust = useTrustCertificate();
|
||||
const on = decryptsEverything(list);
|
||||
if (broken) {
|
||||
return (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_off" size="sm" color="warning" />}
|
||||
title="Decrypt everything"
|
||||
description={<span className="text-warning">Certificate not trusted</span>}
|
||||
action={
|
||||
<Button
|
||||
size="2xs"
|
||||
color="primary"
|
||||
isLoading={trusting}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
trust.mutate({});
|
||||
}}
|
||||
>
|
||||
{trusting ? "Waiting for macOS" : "Trust"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MenuRow
|
||||
icon={<Icon icon="lock_open" size="sm" color="secondary" />}
|
||||
title="Decrypt everything"
|
||||
description={
|
||||
trusting
|
||||
? "Waiting for macOS"
|
||||
: on
|
||||
? "Except Apple services and apps that pin"
|
||||
: (listSummary(list) ?? "Nothing listed")
|
||||
}
|
||||
action={
|
||||
<KeepMenuOpen>
|
||||
<Checkbox
|
||||
title="Decrypt everything"
|
||||
hideLabel
|
||||
checked={on}
|
||||
disabled={trusting}
|
||||
onChange={(checked) =>
|
||||
void apply(
|
||||
checked
|
||||
? {
|
||||
edit: everythingEdit(true),
|
||||
message: "Decrypting everything",
|
||||
sub: "Except Apple services and apps that pin",
|
||||
}
|
||||
: { edit: everythingEdit(false), message: "Stopped decrypting everything" },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The certificate and the system proxy, read once and shared by the header and the first-run
|
||||
* card, with the actions that change them.
|
||||
@@ -265,10 +306,6 @@ export function useProxySetup() {
|
||||
// The user comes back from System Settings, maybe having just allowed the helper.
|
||||
const { refetch: refetchSystemProxy } = systemProxy;
|
||||
useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]);
|
||||
const trust = useRpcMutation("ca_trust", {
|
||||
mutationKey: ["ca_trust"],
|
||||
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
|
||||
});
|
||||
const setSystemProxy = useRpcMutation("system_proxy_set", {
|
||||
mutationKey: ["system_proxy_set"],
|
||||
onSuccess: (status) => {
|
||||
@@ -284,7 +321,7 @@ export function useProxySetup() {
|
||||
useEffect(() => {
|
||||
if (on && !everOn) setEverOn(true);
|
||||
}, [on, everOn, setEverOn]);
|
||||
return { caStatus, trust, systemProxy, setSystemProxy };
|
||||
return { caStatus, systemProxy, setSystemProxy };
|
||||
}
|
||||
|
||||
/** The system proxy switch's state, from where it points and the change in flight */
|
||||
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
type SettingsTitlebar,
|
||||
Toasts,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { settingsValuesAtom } from "../lib/interfaceSettings";
|
||||
import { openLink, revealPath } from "../lib/opener";
|
||||
import { proxySettingsView } from "../lib/settings";
|
||||
import { closeWindow, command, currentWindowLabel, getOsType, subscribe } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { checkForUpdatesNow, updateCheckingAtom, updaterEnabled } from "../lib/updates";
|
||||
import {
|
||||
openSettingsVia,
|
||||
requestedSettingsTab,
|
||||
@@ -125,9 +126,16 @@ export function SettingsContent({
|
||||
const [values, change] = useAtom(settingsValuesAtom);
|
||||
const app = useAppInfo();
|
||||
const systemAppearance = useSystemAppearance();
|
||||
const store = useStore();
|
||||
const checking = useAtomValue(updateCheckingAtom);
|
||||
const { data: hasUpdater = false } = useQuery({
|
||||
queryKey: ["updater_enabled"],
|
||||
queryFn: updaterEnabled,
|
||||
retry: false,
|
||||
});
|
||||
const view = useMemo(
|
||||
() => proxySettingsView(values, app, systemAppearance),
|
||||
[values, app, systemAppearance],
|
||||
() => proxySettingsView(values, app, systemAppearance, hasUpdater ? { checking } : null),
|
||||
[values, app, systemAppearance, hasUpdater, checking],
|
||||
);
|
||||
const showToast = useShowToast();
|
||||
const callbacks = useMemo<SettingsCallbacks>(() => {
|
||||
@@ -136,14 +144,14 @@ export function SettingsContent({
|
||||
onChange: (patch) => change(patch),
|
||||
// The proxy draws its own title bar always; the row isn't shown
|
||||
onApplyNativeTitlebar: (useNativeTitlebar) => change({ useNativeTitlebar }),
|
||||
onCheckForUpdates: () => undefined,
|
||||
onCheckForUpdates: () => void checkForUpdatesNow(store),
|
||||
onActivateLicense: async () => undefined,
|
||||
onDeactivateLicense: () => undefined,
|
||||
onOpenUrl: (url) => void openLink(url, fail),
|
||||
onRevealPath: (path) => void revealPath(path, fail),
|
||||
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
|
||||
};
|
||||
}, [change, showToast]);
|
||||
}, [change, showToast, store]);
|
||||
const props = { view, callbacks };
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogFooter,
|
||||
Heading,
|
||||
Icon,
|
||||
RadioDropdown,
|
||||
SettingRow,
|
||||
@@ -15,8 +14,8 @@ import {
|
||||
SettingSelectControl,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { useState } from "react";
|
||||
@@ -41,7 +40,7 @@ import { helperRow } from "../lib/systemProxy";
|
||||
import { proxyStateAtom, refreshProxyState } from "../lib/store";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { DecryptListEditor } from "./DecryptListEditor";
|
||||
import { DecryptionSettings } from "./DecryptListEditor";
|
||||
|
||||
/**
|
||||
* The proxy's own settings tab: how it listens and the system proxy, what it decrypts, what
|
||||
@@ -59,11 +58,7 @@ export function SettingsProxy() {
|
||||
const elsewhere = proxyState?.otherOwner != null;
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">Listening, decryption, traffic and terminals</p>
|
||||
</div>
|
||||
<SettingsPage title="Proxy" description="Listening, decryption, traffic and terminals">
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Proxy">
|
||||
<PortRow disabled={elsewhere} />
|
||||
@@ -94,7 +89,7 @@ export function SettingsProxy() {
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Decryption">
|
||||
<DecryptListEditor className="py-4" />
|
||||
<DecryptionSettings />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Traffic">
|
||||
@@ -108,7 +103,7 @@ export function SettingsProxy() {
|
||||
<TerminalRow />
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,30 @@ import {
|
||||
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { type PointerEvent, type SyntheticEvent, useCallback, useEffect } from "react";
|
||||
import { useDecryptList, useEditDecryptList } from "../hooks/useDecryptList";
|
||||
import { useDecrypt } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { appliedFilterAtom, SIDEBAR_TREE_ID } from "../lib/atoms";
|
||||
import { blockerOf, decryptMenuAction, isDecrypted, listEdit, setDecrypted } from "../lib/decrypt";
|
||||
import {
|
||||
blockerOf,
|
||||
decryptMenuAction,
|
||||
decryptsEverything,
|
||||
everythingEdit,
|
||||
listEdit,
|
||||
setDecrypted,
|
||||
} from "../lib/decrypt";
|
||||
import { revealLabel, revealPath } from "../lib/opener";
|
||||
import { applyCollapsed, type CollapsedMap, collapsedMap, folderIds } from "../lib/sidebarCollapse";
|
||||
import {
|
||||
copyRowsText,
|
||||
ENCRYPTED_ID,
|
||||
filterWithRows,
|
||||
type SidebarItem,
|
||||
SYSTEM_ID,
|
||||
valueRows,
|
||||
} from "../lib/sidebarTree";
|
||||
import { applyFilter, noteSidebarInput, noteSidebarMenu } from "../lib/navigation";
|
||||
import { hideSystemAtom, sidebarTreeAtom } from "../lib/store";
|
||||
import { hideEncryptedAtom, hideSystemAtom, sidebarTreeAtom } from "../lib/store";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
@@ -73,10 +82,12 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
{item.kind === "all" && <Icon icon="layers" size="sm" className="text-text-subtle" />}
|
||||
{item.kind === "tunnelled" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
|
||||
{item.kind === "encrypted" && <Icon icon="lock" size="sm" className="text-text-subtle" />}
|
||||
{item.kind === "saved" && <Icon icon="bookmark" size="sm" className="text-text-subtle" />}
|
||||
{item.kind === "folder" && <Icon icon="folder" size="sm" className="text-text-subtle" />}
|
||||
{item.appPath != null && <AppIcon path={item.appPath} className="size-[1.125rem]" />}
|
||||
{item.appPath != null && (
|
||||
<AppIcon path={item.appPath} className="size-[1.125rem] min-w-[1.125rem]" />
|
||||
)}
|
||||
{item.kind === "folder" && item.path != null ? (
|
||||
// Named by its last part; the whole path on hover
|
||||
<Tooltip wrap content={item.path} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
@@ -88,7 +99,11 @@ function ItemInner({ item }: { item: SidebarItem }) {
|
||||
{item.sublabel != null && (
|
||||
<span className="truncate text-xs text-text-subtlest">{item.sublabel}</span>
|
||||
)}
|
||||
{item.decrypt != null && <DecryptedMark item={item} />}
|
||||
{item.encryptedOnly && (
|
||||
<Tooltip wrap content="Encrypted" delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<Icon icon="lock" size="xs" className="shrink-0 text-text-subtlest" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Count item={item} />
|
||||
</div>
|
||||
);
|
||||
@@ -117,14 +132,12 @@ function CloseAtOnce({ onClose }: { onClose: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A small open lock on a host, site or app the decrypt list decrypts */
|
||||
function DecryptedMark({ item }: { item: SidebarItem }) {
|
||||
const list = useDecryptList();
|
||||
if (item.decrypt == null || !isDecrypted(list, item.decrypt)) return null;
|
||||
return (
|
||||
<Tooltip wrap content="Decrypted" delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<Icon icon="lock_open" size="xs" className="shrink-0 text-text-subtlest" />
|
||||
</Tooltip>
|
||||
/** A checkable menu item's mark: a check when on, the same room when off */
|
||||
function checkMark(on: boolean) {
|
||||
return on ? (
|
||||
<Icon icon="check" size="sm" />
|
||||
) : (
|
||||
<span aria-hidden className="inline-block w-4 h-4" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,9 +150,9 @@ export function Sidebar() {
|
||||
[],
|
||||
);
|
||||
const [hideSystem, setHideSystem] = useAtom(hideSystemAtom);
|
||||
const [hideEncrypted, setHideEncrypted] = useAtom(hideEncryptedAtom);
|
||||
const decryptList = useDecryptList();
|
||||
const setDecryptList = useEditDecryptList();
|
||||
const { mutate: saveDecryptList } = setDecryptList;
|
||||
const { apply: applyDecrypt } = useDecrypt();
|
||||
const showToast = useShowToast();
|
||||
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
|
||||
const { mutate: deleteSaved } = useRpcMutation("delete_saved_query", {
|
||||
@@ -210,15 +223,35 @@ export function Sidebar() {
|
||||
return [
|
||||
{
|
||||
label: "Show in All Traffic",
|
||||
leftSlot: hideSystem ? (
|
||||
<span aria-hidden className="inline-block w-4 h-4" />
|
||||
) : (
|
||||
<Icon icon="check" size="sm" />
|
||||
),
|
||||
leftSlot: checkMark(!hideSystem),
|
||||
onSelect: () => setHideSystem(!hideSystem),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (items.length === 1 && items[0]?.id === ENCRYPTED_ID) {
|
||||
return withSeparators([
|
||||
[
|
||||
{
|
||||
label: "Decrypt Everything",
|
||||
leftSlot: <Icon icon="lock_open" />,
|
||||
hidden: decryptsEverything(decryptList),
|
||||
onSelect: () =>
|
||||
void applyDecrypt({
|
||||
edit: everythingEdit(true),
|
||||
message: "Decrypting everything",
|
||||
sub: "Except Apple services and apps that pin",
|
||||
}),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: "Show in All Traffic",
|
||||
leftSlot: checkMark(!hideEncrypted),
|
||||
onSelect: () => setHideEncrypted(!hideEncrypted),
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
const paths = items.length === 1 ? pathItems(items[0]) : [];
|
||||
const saved = items.flatMap((i) => (i.savedId == null ? [] : [{ ...i, id: i.savedId }]));
|
||||
if (saved.length > 0) {
|
||||
@@ -265,7 +298,10 @@ export function Sidebar() {
|
||||
// Only what this changes, applied to the list as it is then, so a second change
|
||||
// made before this one lands keeps it.
|
||||
const next = setDecrypted(decryptList, subjects, action.on);
|
||||
saveDecryptList(listEdit(decryptList, next));
|
||||
void applyDecrypt({
|
||||
edit: listEdit(decryptList, next),
|
||||
message: `${action.on ? "Decrypting" : "Stopped decrypting"} ${action.subject}`,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -273,7 +309,7 @@ export function Sidebar() {
|
||||
},
|
||||
[
|
||||
decryptList,
|
||||
saveDecryptList,
|
||||
applyDecrypt,
|
||||
showToast,
|
||||
setSavedDialog,
|
||||
deleteSaved,
|
||||
@@ -281,6 +317,8 @@ export function Sidebar() {
|
||||
filterItems,
|
||||
hideSystem,
|
||||
setHideSystem,
|
||||
hideEncrypted,
|
||||
setHideEncrypted,
|
||||
],
|
||||
);
|
||||
const renderContextMenu = useCallback<NonNullable<TreeProps<SidebarItem>["renderContextMenu"]>>(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "Rejected the proxy's certificate",
|
||||
elapsedMs: 6,
|
||||
requestSize: 517,
|
||||
responseSize: 0,
|
||||
},
|
||||
tunnel(8, "sync.acme.dev", apps.acme, {
|
||||
tunnelReason: "rejected",
|
||||
tunnelRejectedBy: "fl_https_7",
|
||||
elapsedMs: null,
|
||||
}),
|
||||
decrypted(9, 0, "https://api.typefully.com/v1/drafts", apps.dia),
|
||||
tunnel(10, "www.google.com", apps.dia, { elapsedMs: null }),
|
||||
];
|
||||
}
|
||||
|
||||
export function httpsFlows(scene: string | null): Flow[] | null {
|
||||
if (scene === "encrypted") return encryptedScene();
|
||||
if (scene === "mixed") return mixedScene();
|
||||
return null;
|
||||
}
|
||||
@@ -51,6 +51,12 @@ const flowBase: Flow = {
|
||||
grpcMessage: null,
|
||||
graphqlOperation: null,
|
||||
tunnelled: false,
|
||||
tunnelReason: null,
|
||||
tunnelExcludedBy: null,
|
||||
tunnelExcludedBuiltIn: false,
|
||||
tunnelRejectedBy: null,
|
||||
reconnectRequested: false,
|
||||
tlsRejected: null,
|
||||
agentRead: false,
|
||||
clientAddr: null,
|
||||
elapsedConnectMs: null,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useIsMutating, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CaStatus, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
|
||||
import { Button } from "@yaakapp-internal/ui";
|
||||
import { useCallback } from "react";
|
||||
import { inverseEdit } from "../lib/decrypt";
|
||||
import {
|
||||
applyChoice,
|
||||
choiceToast,
|
||||
type DecryptChoice,
|
||||
type ListEdit,
|
||||
reconnectEntries,
|
||||
} from "../lib/decryptChoice";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { announceEngineChange } from "../lib/windowSync";
|
||||
import { useRpcEvent } from "./useRpcEvent";
|
||||
import { useRpcMutation } from "./useRpcMutation";
|
||||
import { useRpcQuery } from "./useRpcQuery";
|
||||
|
||||
const CA_KEY = ["ca_status", {}];
|
||||
const LIST_KEY = ["get_decrypt_list", {}];
|
||||
const PASSTHROUGH_KEY = ["list_passthrough", {}];
|
||||
const TRUST_KEY = ["ca_trust"];
|
||||
|
||||
/** The app trusts its certificate itself only on macOS; elsewhere it is installed by hand */
|
||||
export function canTrustHere(): boolean {
|
||||
return getOsType() === "macos";
|
||||
}
|
||||
|
||||
/** Whether macOS's password prompt is up, from any control that asked for it */
|
||||
export function useTrusting(): boolean {
|
||||
return useIsMutating({ mutationKey: TRUST_KEY }) > 0;
|
||||
}
|
||||
|
||||
/** Asks macOS to trust the certificate, the status read from its answer */
|
||||
export function useTrustCertificate() {
|
||||
const queryClient = useQueryClient();
|
||||
const showToast = useShowToast();
|
||||
return useRpcMutation("ca_trust", {
|
||||
mutationKey: TRUST_KEY,
|
||||
onSuccess: ({ trusted, installed, certPath }) =>
|
||||
queryClient.setQueryData<CaStatus>(CA_KEY, { trusted, installed, certPath }),
|
||||
// A cancelled prompt isn't an error, and says nothing; a failure says why.
|
||||
onError: (err) =>
|
||||
showToast({
|
||||
id: "decrypt",
|
||||
color: "danger",
|
||||
message: `Certificate not trusted: ${err.message}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Decrypt and Stop Decrypting in the app: trusts the certificate first when the change can
|
||||
* decrypt something and it isn't yet (macOS's password prompt), then writes the change, then a
|
||||
* toast with Undo, and with Reconnect while open connections the list now decrypts stay
|
||||
* encrypted. A cancelled prompt changes nothing and says nothing.
|
||||
*/
|
||||
export function useDecrypt() {
|
||||
const queryClient = useQueryClient();
|
||||
const showToast = useShowToast();
|
||||
const trusting = useTrusting();
|
||||
const { mutateAsync: trust } = useTrustCertificate();
|
||||
|
||||
const run = useCallback(
|
||||
async (edit: ListEdit) => {
|
||||
try {
|
||||
return await applyChoice(
|
||||
{
|
||||
canTrust: canTrustHere(),
|
||||
readTrusted: async () => {
|
||||
const status = await rpc("ca_status", {});
|
||||
queryClient.setQueryData<CaStatus>(CA_KEY, status);
|
||||
return status.trusted;
|
||||
},
|
||||
trust: () => trust({}),
|
||||
save: async (edit) => {
|
||||
try {
|
||||
return await rpc("edit_decrypt_list", edit);
|
||||
} finally {
|
||||
// Read again rather than taken from the answer, which can land after a newer
|
||||
// change's event and would put the older list back.
|
||||
void queryClient.invalidateQueries({ queryKey: LIST_KEY });
|
||||
announceEngineChange();
|
||||
}
|
||||
},
|
||||
},
|
||||
edit,
|
||||
);
|
||||
} catch (err) {
|
||||
showToast({
|
||||
id: "decrypt",
|
||||
color: "danger",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[queryClient, showToast, trust],
|
||||
);
|
||||
|
||||
const reconnect = useCallback(
|
||||
(entries: string[]) => {
|
||||
for (const entry of entries) {
|
||||
rpc("reconnect_tunnels", { entry }).catch((err) =>
|
||||
showToast({ id: "reconnect", color: "danger", message: String(err) }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
);
|
||||
|
||||
/** Applies a choice; true once it is written */
|
||||
const apply = useCallback(
|
||||
async (choice: DecryptChoice): Promise<boolean> => {
|
||||
const result = await run(choice.edit);
|
||||
if (result == null || result.status === "cancelled") return false;
|
||||
if (result.status === "untrusted") {
|
||||
showToast({
|
||||
id: "decrypt",
|
||||
color: "warning",
|
||||
message: "Certificate not trusted",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Counted within what Reconnect closes, not every open connection the list now decrypts
|
||||
const scoped =
|
||||
result.stillEncrypted > 0 ? await countReconnectable(reconnectEntries(choice.edit)) : 0;
|
||||
const toast = choiceToast(choice, scoped);
|
||||
showToast({
|
||||
id: "decrypt",
|
||||
message: (
|
||||
<div>
|
||||
<div>{toast.message}</div>
|
||||
{toast.sub != null && (
|
||||
<div className="text-xs text-text-subtle mt-0.5">{toast.sub}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
hide();
|
||||
void run(inverseEdit(choice.edit));
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</Button>
|
||||
{toast.reconnect.length > 0 && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
onClick={() => {
|
||||
hide();
|
||||
reconnect(toast.reconnect);
|
||||
}}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[run, reconnect, showToast],
|
||||
);
|
||||
|
||||
return { apply, trusting };
|
||||
}
|
||||
|
||||
/** Open tunnels the entries name that the list now decrypts, each counted once */
|
||||
async function countReconnectable(entries: string[]): Promise<number> {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
for (const t of await rpc("list_reconnectable_tunnels", { entry })) ids.add(t.id);
|
||||
} catch (err) {
|
||||
console.warn("Failed to list reconnectable tunnels", err);
|
||||
}
|
||||
}
|
||||
return ids.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* The addresses passed through after clients rejected the certificate, kept current as the
|
||||
* engine adds and forgets them. Null until read.
|
||||
*/
|
||||
export function usePassthrough(): string[] | null {
|
||||
const queryClient = useQueryClient();
|
||||
const onChanged = useCallback(
|
||||
(payload: PassthroughResponse) =>
|
||||
queryClient.setQueryData<PassthroughResponse>(PASSTHROUGH_KEY, payload),
|
||||
[queryClient],
|
||||
);
|
||||
useRpcEvent("passthrough_changed", onChanged);
|
||||
return useRpcQuery("list_passthrough", {}).data?.addresses ?? null;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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 Sites",
|
||||
);
|
||||
expect(decryptMenuAction([], [dia, a, site])?.label).toBe("Decrypt 3 Selected");
|
||||
});
|
||||
|
||||
test("applies to every selected row, leaving those already so", () => {
|
||||
const next = setDecrypted(user("host:a.com"), [a, b], true).map((e) => e.entry);
|
||||
expect(next).toEqual(["host:a.com", "host:b.com"]);
|
||||
@@ -225,8 +245,8 @@ describe("the Not decrypted detail's buttons", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the first-run step", () => {
|
||||
test("Everything adds *, and Pick as they appear takes it out", () => {
|
||||
describe("Decrypt Everything", () => {
|
||||
test("adds *, and the switch off takes it out", () => {
|
||||
expect(decryptsEverything(BUILT_INS)).toBe(false);
|
||||
expect(decryptsEverything([...user("*"), ...BUILT_INS])).toBe(true);
|
||||
expect(everythingEdit(true)).toEqual({ add: ["*"], remove: [] });
|
||||
@@ -303,3 +323,26 @@ describe("the list's autocomplete", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("what a change says", () => {
|
||||
test("the pill counts the person's own positive entries", () => {
|
||||
expect(listSummary([...user("app:Dia", "app:Slack", "host:*.google.com"), ...BUILT_INS])).toBe(
|
||||
"2 apps, 1 host",
|
||||
);
|
||||
expect(listSummary([...user("*", "!host:a.com"), ...BUILT_INS])).toBeNull();
|
||||
});
|
||||
|
||||
test("names an entry's subject", () => {
|
||||
expect(entrySubject("host:www.google.com")).toBe("www.google.com");
|
||||
expect(entrySubject("host:*.google.com")).toBe("*.google.com");
|
||||
expect(entrySubject("app:Dia")).toBe("Dia");
|
||||
expect(entrySubject("*")).toBe("Everything");
|
||||
});
|
||||
|
||||
test("an edit can decrypt when it adds a positive entry or drops an exclusion", () => {
|
||||
expect(editDecrypts({ add: ["host:a.com"], remove: [] })).toBe(true);
|
||||
expect(editDecrypts({ add: [], remove: ["!host:a.com"] })).toBe(true);
|
||||
expect(editDecrypts({ add: ["!app:Dia"], remove: ["host:a.com"] })).toBe(false);
|
||||
expect(inverseEdit({ add: ["a"], remove: ["b"] })).toEqual({ add: ["b"], remove: ["a"] });
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,12 @@ function matches(e: ParsedEntry, q: DecryptQuery): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one entry, as stored, names `q`, whichever way it points */
|
||||
export function entryMatches(entry: string, q: DecryptQuery): boolean {
|
||||
const parsed = parseEntry(entry);
|
||||
return parsed != null && matches(parsed, q);
|
||||
}
|
||||
|
||||
function specificity(e: ParsedEntry): number {
|
||||
if (e.kind === "everything") return 0;
|
||||
if (e.kind === "app") return Number.POSITIVE_INFINITY;
|
||||
@@ -193,6 +199,52 @@ export function decryptsAnything(list: DecryptListEntry[]): boolean {
|
||||
return list.some((e) => !e.entry.trim().startsWith("!"));
|
||||
}
|
||||
|
||||
/** What a list entry is called in a button: its host or pattern, its app, or Everything */
|
||||
export function entrySubject(entry: string): string {
|
||||
const parsed = parseEntry(entry);
|
||||
if (parsed == null) return entry;
|
||||
if (parsed.kind === "everything") return "Everything";
|
||||
if (parsed.kind === "app") return parsed.pattern;
|
||||
return formatEntry({ ...parsed, negated: false }).slice("host:".length);
|
||||
}
|
||||
|
||||
/**
|
||||
* The person's own positive entries, counted for the pill: `2 apps, 1 host`, sites counted as
|
||||
* hosts, `*` left out as the switch beside it shows it. Null when there are none.
|
||||
*/
|
||||
export function listSummary(list: DecryptListEntry[]): string | null {
|
||||
let apps = 0;
|
||||
let hosts = 0;
|
||||
for (const e of list) {
|
||||
if (e.builtIn) continue;
|
||||
const parsed = parseEntry(e.entry);
|
||||
if (parsed == null || parsed.negated) continue;
|
||||
if (parsed.kind === "app") apps++;
|
||||
if (parsed.kind === "host") hosts++;
|
||||
}
|
||||
const parts = [
|
||||
apps > 0 ? `${apps} ${apps === 1 ? "app" : "apps"}` : null,
|
||||
hosts > 0 ? `${hosts} ${hosts === 1 ? "host" : "hosts"}` : null,
|
||||
].filter((p) => p != null);
|
||||
return parts.length === 0 ? null : parts.join(", ");
|
||||
}
|
||||
|
||||
/** Whether an edit can make something decrypt: it adds a positive entry or drops an exclusion */
|
||||
export function editDecrypts(edit: { add: string[]; remove: string[] }): boolean {
|
||||
return (
|
||||
edit.add.some((e) => !e.trim().startsWith("!")) ||
|
||||
edit.remove.some((e) => e.trim().startsWith("!"))
|
||||
);
|
||||
}
|
||||
|
||||
/** The edit that puts back what `edit` changed, for Undo */
|
||||
export function inverseEdit(edit: { add: string[]; remove: string[] }): {
|
||||
add: string[];
|
||||
remove: string[];
|
||||
} {
|
||||
return { add: edit.remove, remove: edit.add };
|
||||
}
|
||||
|
||||
/** What a sidebar row or the detail's buttons decrypt: a host, a site, or an app */
|
||||
export type DecryptSubject =
|
||||
| { kind: "host"; host: string }
|
||||
@@ -335,10 +387,44 @@ export function setDecrypted(
|
||||
export function decryptMenuAction(
|
||||
list: DecryptListEntry[],
|
||||
subjects: DecryptSubject[],
|
||||
): { label: "Decrypt" | "Stop Decrypting"; on: boolean } | null {
|
||||
): { label: string; on: boolean; subject: string } | null {
|
||||
if (subjects.length === 0) return null;
|
||||
const on = !subjects.every((s) => isDecrypted(list, s));
|
||||
return { label: on ? "Decrypt" : "Stop Decrypting", on };
|
||||
const subject = subjectsName(subjects);
|
||||
return {
|
||||
label: `${on ? "Decrypt" : "Stop Decrypting"} ${subject.title}`,
|
||||
on,
|
||||
subject: subject.text,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a subject is called: its host, `*.site`, or its app */
|
||||
export function subjectName(subject: DecryptSubject): string {
|
||||
switch (subject.kind) {
|
||||
case "host":
|
||||
return subject.host;
|
||||
case "site":
|
||||
return `*.${subject.site}`;
|
||||
case "app":
|
||||
return subject.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several subjects named together: one by its name, several of a kind counted, `3 Sites` in a
|
||||
* menu and `3 sites` in a toast, and mixed kinds as `3 Selected`
|
||||
*/
|
||||
function subjectsName(subjects: DecryptSubject[]): { title: string; text: string } {
|
||||
const [first] = subjects;
|
||||
if (subjects.length === 1 && first != null) {
|
||||
const name = subjectName(first);
|
||||
return { title: name, text: name };
|
||||
}
|
||||
const kinds = new Set(subjects.map((s) => s.kind));
|
||||
const n = subjects.length;
|
||||
if (kinds.size > 1) return { title: `${n} Selected`, text: `${n} selected` };
|
||||
const noun = first?.kind === "app" ? "Apps" : first?.kind === "site" ? "Sites" : "Hosts";
|
||||
return { title: `${n} ${noun}`, text: `${n} ${noun.toLowerCase()}` };
|
||||
}
|
||||
|
||||
function builtIns(list: DecryptListEntry[]): DecryptListEntry[] {
|
||||
|
||||
@@ -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,353 @@
|
||||
import type { DecryptListEntry } from "@yaakapp-internal/proxy-lib";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
connectionOf,
|
||||
type EncryptedCardInput,
|
||||
encryptedCard,
|
||||
lineText,
|
||||
stopDecryptingConnection,
|
||||
} from "./encryptedCard";
|
||||
|
||||
const user = (...entries: string[]): DecryptListEntry[] =>
|
||||
entries.map((entry) => ({ entry, builtIn: false }));
|
||||
const BUILT_INS: DecryptListEntry[] = [
|
||||
{ entry: "!host:*.apple.com", builtIn: true, kind: "apple" },
|
||||
{ entry: "!host:*.1password.com", builtIn: true, kind: "pinned" },
|
||||
];
|
||||
|
||||
const dia = {
|
||||
clientName: "Dia",
|
||||
clientBundleId: "company.thebrowser.dia",
|
||||
clientPath: "/A/Dia.app",
|
||||
};
|
||||
const tunnel: EncryptedCardInput["flow"] = {
|
||||
url: "www.google.com:443",
|
||||
...dia,
|
||||
tunnelReason: "not_listed",
|
||||
tunnelExcludedBy: null,
|
||||
tunnelExcludedBuiltIn: false,
|
||||
tlsRejected: null,
|
||||
elapsedMs: 188,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function card(over: Partial<EncryptedCardInput> = {}) {
|
||||
return encryptedCard({
|
||||
flow: tunnel,
|
||||
list: BUILT_INS,
|
||||
trusted: true,
|
||||
canTrust: true,
|
||||
passthrough: [],
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
const summary = (c: ReturnType<typeof card>) =>
|
||||
c.actions.map((a) => [a.label, a.hint, a.edit ?? null]);
|
||||
|
||||
describe("connectionOf", () => {
|
||||
test("reads a tunnel's authority and a refused handshake's URL alike", () => {
|
||||
expect(
|
||||
connectionOf({ url: "www.google.com:443", clientName: null, clientBundleId: null }),
|
||||
).toEqual({
|
||||
host: "www.google.com",
|
||||
port: 443,
|
||||
label: "www.google.com",
|
||||
address: "www.google.com:443",
|
||||
app: null,
|
||||
});
|
||||
const c = connectionOf({ url: "https://home.ts.net:8317", ...dia });
|
||||
expect(c.label).toBe("home.ts.net:8317");
|
||||
expect(c.app).toEqual({ name: "Dia", bundleId: "company.thebrowser.dia" });
|
||||
expect(
|
||||
connectionOf({ url: "[::1]:8443", clientName: null, clientBundleId: null }),
|
||||
).toMatchObject({ host: "::1", port: 8443, address: "[::1]:8443", label: "[::1]:8443" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the card of an encrypted connection", () => {
|
||||
test("nothing decrypted yet: the host, the app and everything, each with what it writes", () => {
|
||||
const c = card();
|
||||
expect(lineText(c.why)).toBe("Nothing is decrypted yet");
|
||||
expect(summary(c)).toEqual([
|
||||
[
|
||||
"Decrypt www.google.com",
|
||||
"host:www.google.com",
|
||||
{ add: ["host:www.google.com"], remove: [] },
|
||||
],
|
||||
["Decrypt Dia", "app:Dia", { add: ["app:Dia"], remove: [] }],
|
||||
["Decrypt Everything", "*", { add: ["*"], remove: [] }],
|
||||
]);
|
||||
// Nothing under the buttons while the certificate is trusted
|
||||
expect(c.foot).toEqual([]);
|
||||
expect(c.fact).toBeNull();
|
||||
});
|
||||
|
||||
test("asks for the password once while the certificate isn't trusted, where it can be", () => {
|
||||
expect(card({ trusted: false }).foot).toEqual(["Asks for your password once"]);
|
||||
expect(card({ trusted: false, canTrust: false }).foot).toEqual([]);
|
||||
});
|
||||
|
||||
test("something else listed: not on the list, with the same choices", () => {
|
||||
const c = card({ list: [...user("host:api.stripe.com"), ...BUILT_INS] });
|
||||
expect(lineText(c.why)).toBe(
|
||||
"**Encrypted Content:** Enable decryption to see requests to `www.google.com`",
|
||||
);
|
||||
expect(c.actions.map((a) => a.label)).toEqual([
|
||||
"Decrypt www.google.com",
|
||||
"Decrypt Dia",
|
||||
"Decrypt Everything",
|
||||
]);
|
||||
});
|
||||
|
||||
test("no app button for a connection no app is named for", () => {
|
||||
const c = card({
|
||||
flow: { ...tunnel, clientName: null, clientBundleId: null, clientPath: null },
|
||||
});
|
||||
expect(c.actions.map((a) => a.label)).toEqual(["Decrypt www.google.com", "Decrypt Everything"]);
|
||||
});
|
||||
|
||||
test("names the host nothing on the list covers, its port only when not 443", () => {
|
||||
const list = [...user("host:api.stripe.com"), ...BUILT_INS];
|
||||
expect(lineText(card({ list, flow: { ...tunnel, url: "t.yaak.app:443" } }).why)).toBe(
|
||||
"**Encrypted Content:** Enable decryption to see requests to `t.yaak.app`",
|
||||
);
|
||||
expect(lineText(card({ list, flow: { ...tunnel, url: "home.ts.net:8317" } }).why)).toBe(
|
||||
"**Encrypted Content:** Enable decryption to see requests to `home.ts.net:8317`",
|
||||
);
|
||||
});
|
||||
|
||||
test("a port off 443 is part of the host it writes", () => {
|
||||
const c = card({ flow: { ...tunnel, url: "home.ts.net:8317" } });
|
||||
expect(c.actions[0]?.hint).toBe("host:home.ts.net:8317");
|
||||
expect(c.actions[0]?.label).toBe("Decrypt home.ts.net:8317");
|
||||
});
|
||||
|
||||
test("Decrypt Everything is offered only where * would decrypt it", () => {
|
||||
const flow = { ...tunnel, url: "my.1password.com:443", clientName: "1Password" };
|
||||
expect(card({ flow }).actions.map((a) => a.label)).not.toContain("Decrypt Everything");
|
||||
});
|
||||
|
||||
test("excluded by default: the host alone, dashed, and who may refuse it", () => {
|
||||
const pinned = card({
|
||||
flow: {
|
||||
...tunnel,
|
||||
url: "my.1password.com:443",
|
||||
clientName: "1Password",
|
||||
clientBundleId: "com.1password",
|
||||
tunnelReason: "excluded",
|
||||
tunnelExcludedBy: "!host:*.1password.com",
|
||||
tunnelExcludedBuiltIn: true,
|
||||
},
|
||||
});
|
||||
expect([lineText(pinned.why), pinned.whySub]).toEqual([
|
||||
"Excluded by default",
|
||||
"pins its certificate",
|
||||
]);
|
||||
expect(summary(pinned)).toEqual([
|
||||
[
|
||||
"Decrypt my.1password.com",
|
||||
"host:my.1password.com",
|
||||
{ add: ["host:my.1password.com"], remove: [] },
|
||||
],
|
||||
]);
|
||||
expect(pinned.actions[0]?.dashed).toBe(true);
|
||||
expect(pinned.foot).toEqual(["1Password may refuse it"]);
|
||||
|
||||
const apple = card({
|
||||
flow: {
|
||||
...tunnel,
|
||||
url: "gateway.icloud.apple.com:443",
|
||||
clientName: null,
|
||||
clientBundleId: null,
|
||||
tunnelReason: "excluded",
|
||||
tunnelExcludedBy: "!host:*.apple.com",
|
||||
tunnelExcludedBuiltIn: true,
|
||||
},
|
||||
});
|
||||
expect(apple.whySub).toBe("Apple service");
|
||||
expect(apple.foot).toEqual(["macOS may refuse it"]);
|
||||
});
|
||||
|
||||
test("excluded by the person: names it, and Stop Excluding when that alone decrypts it", () => {
|
||||
const list = [...user("*", "!host:*.google.com"), ...BUILT_INS];
|
||||
const c = card({
|
||||
list,
|
||||
flow: { ...tunnel, tunnelReason: "excluded", tunnelExcludedBy: "!host:*.google.com" },
|
||||
});
|
||||
expect(lineText(c.why)).toBe("Excluded by `!host:*.google.com`");
|
||||
// Nothing added wins over the person's own exclusion, so taking it off is the way in
|
||||
expect(summary(c)).toEqual([
|
||||
[
|
||||
"Stop Excluding *.google.com",
|
||||
"!host:*.google.com",
|
||||
{ add: [], remove: ["!host:*.google.com"] },
|
||||
],
|
||||
]);
|
||||
// Their exact exclusion of the host is one Decrypt takes off itself
|
||||
const exact = card({ list: [...user("*", "!host:www.google.com"), ...BUILT_INS] });
|
||||
expect(summary(exact)[0]).toEqual([
|
||||
"Decrypt www.google.com",
|
||||
"host:www.google.com",
|
||||
{ add: [], remove: ["!host:www.google.com"] },
|
||||
]);
|
||||
|
||||
// Every way in blocked but taking the exclusions off
|
||||
const both = [...user("*", "!host:*.google.com", "!app:Dia"), ...BUILT_INS];
|
||||
const blocked = card({ list: both });
|
||||
expect(summary(blocked)).toEqual([
|
||||
[
|
||||
"Stop Excluding 2 Entries",
|
||||
"!host:*.google.com, !app:Dia",
|
||||
{ add: [], remove: ["!host:*.google.com", "!app:Dia"] },
|
||||
],
|
||||
]);
|
||||
expect(blocked.actions[0]?.removes).toBe(true);
|
||||
});
|
||||
|
||||
test("once the list decrypts it: opened before, the opposite, and Reconnect while open", () => {
|
||||
const list = [...user("host:www.google.com"), ...BUILT_INS];
|
||||
const closed = card({ list });
|
||||
expect(lineText(closed.why)).toBe("Not on the decrypt list");
|
||||
expect(closed.fact && lineText(closed.fact)).toBe(
|
||||
"Opened before `www.google.com` was decrypted",
|
||||
);
|
||||
expect(summary(closed)).toEqual([
|
||||
[
|
||||
"Stop Decrypting www.google.com",
|
||||
"host:www.google.com",
|
||||
{ add: [], remove: ["host:www.google.com"] },
|
||||
],
|
||||
]);
|
||||
expect(closed.foot).toEqual([]);
|
||||
|
||||
const open = card({ list, flow: { ...tunnel, elapsedMs: null } });
|
||||
expect(open.actions.map((a) => a.kind)).toEqual(["reconnect", "stop"]);
|
||||
|
||||
const everything = card({ list: [...user("*"), ...BUILT_INS] });
|
||||
expect(everything.fact && lineText(everything.fact)).toBe(
|
||||
"Opened before everything was decrypted",
|
||||
);
|
||||
expect(everything.actions[0]?.label).toBe("Stop Decrypting Everything");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the card of a refused handshake", () => {
|
||||
const refused: EncryptedCardInput["flow"] = {
|
||||
...tunnel,
|
||||
url: "https://sync.acme.dev",
|
||||
clientName: "Acme",
|
||||
clientBundleId: "dev.acme",
|
||||
clientPath: "/A/Acme.app",
|
||||
tunnelReason: null,
|
||||
tlsRejected: "bad_certificate",
|
||||
elapsedMs: 6,
|
||||
};
|
||||
const listed = [...user("*"), ...BUILT_INS];
|
||||
|
||||
test("an app: likely pinned, Stop Decrypting first, Try Again while passed through", () => {
|
||||
const c = card({ flow: refused, list: listed, passthrough: ["sync.acme.dev:443"] });
|
||||
expect(c.rejected).toBe(true);
|
||||
expect([lineText(c.why), c.whySub]).toEqual(["Acme rejected the certificate", "likely pinned"]);
|
||||
expect(summary(c)).toEqual([
|
||||
["Stop Decrypting Acme", "!app:Acme", { add: ["!app:Acme"], remove: [] }],
|
||||
["Try Again", "next connection", null],
|
||||
]);
|
||||
expect(c.actions[0]?.primary).toBe(true);
|
||||
expect(c.foot).toEqual(["Passed through until relaunch"]);
|
||||
});
|
||||
|
||||
test("once no longer passed through, Try Again goes and the foot says so", () => {
|
||||
const c = card({ flow: refused, list: listed, passthrough: [] });
|
||||
expect(c.actions.map((a) => a.kind)).toEqual(["stop"]);
|
||||
expect(c.foot).toEqual(["No longer passed through"]);
|
||||
});
|
||||
|
||||
test("a program: its own trust store, Copy Environment first", () => {
|
||||
const python = {
|
||||
...refused,
|
||||
url: "https://pypi.org",
|
||||
clientName: "python3",
|
||||
clientBundleId: null,
|
||||
clientPath: "/opt/homebrew/bin/python3",
|
||||
};
|
||||
const c = card({ flow: python, list: listed, passthrough: ["pypi.org:443"] });
|
||||
expect([lineText(c.why), c.whySub]).toEqual([
|
||||
"python3 rejected the certificate",
|
||||
"uses its own trust store",
|
||||
]);
|
||||
expect(c.actions.map((a) => [a.kind, a.label])).toEqual([
|
||||
["copy_environment", "Copy Environment"],
|
||||
["stop", "Stop Decrypting python3"],
|
||||
["try_again", "Try Again"],
|
||||
]);
|
||||
expect(c.foot).toEqual(["Then rerun python3"]);
|
||||
});
|
||||
|
||||
test("an unknown client: the host", () => {
|
||||
const c = card({
|
||||
flow: { ...refused, clientName: null, clientBundleId: null, clientPath: null },
|
||||
list: listed,
|
||||
});
|
||||
expect(lineText(c.why)).toBe("A client rejected the certificate");
|
||||
expect(summary(c)[0]).toEqual([
|
||||
"Stop Decrypting sync.acme.dev",
|
||||
"!host:sync.acme.dev",
|
||||
{ add: ["!host:sync.acme.dev"], remove: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("an untrusted certificate explains it: Trust Certificate first", () => {
|
||||
const c = card({
|
||||
flow: refused,
|
||||
list: listed,
|
||||
trusted: false,
|
||||
passthrough: ["sync.acme.dev:443"],
|
||||
});
|
||||
expect(lineText(c.why)).toBe("Certificate not trusted");
|
||||
expect(c.actions.map((a) => a.kind)).toEqual(["trust", "stop"]);
|
||||
expect(c.foot).toEqual(["Passed through until trusted"]);
|
||||
});
|
||||
|
||||
test("a tunnel after a refusal names the client that refused", () => {
|
||||
const after = {
|
||||
...tunnel,
|
||||
url: "sync.acme.dev:443",
|
||||
tunnelReason: "rejected" as const,
|
||||
};
|
||||
const c = card({
|
||||
flow: after,
|
||||
list: listed,
|
||||
rejectedBy: { clientName: "Acme", clientBundleId: "dev.acme", clientPath: null },
|
||||
});
|
||||
expect(lineText(c.why)).toBe("Acme rejected the certificate for this host");
|
||||
// Stop Decrypting acts on this connection, so on Dia's own
|
||||
expect(c.actions[0]?.hint).toBe("!app:Dia");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stop Decrypting a refused connection", () => {
|
||||
const c = connectionOf({ url: "sync.acme.dev:443", clientName: "Acme", clientBundleId: null });
|
||||
|
||||
test("takes off the entry naming exactly this host when that alone stops it", () => {
|
||||
const action = stopDecryptingConnection(user("host:sync.acme.dev"), c);
|
||||
expect(action?.edit).toEqual({ add: [], remove: ["host:sync.acme.dev"] });
|
||||
expect(action?.label).toBe("Stop Decrypting sync.acme.dev");
|
||||
});
|
||||
|
||||
test("else excludes the app, or the host without one, which always wins", () => {
|
||||
expect(stopDecryptingConnection(user("*"), c)?.edit).toEqual({
|
||||
add: ["!app:Acme"],
|
||||
remove: [],
|
||||
});
|
||||
const anonymous = { ...c, app: null };
|
||||
expect(stopDecryptingConnection(user("*", "host:sync.acme.dev"), anonymous)?.edit).toEqual({
|
||||
add: ["!host:sync.acme.dev"],
|
||||
remove: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("nothing when the list does not decrypt it", () => {
|
||||
expect(stopDecryptingConnection(BUILT_INS, c)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
import type { DecryptListEntry, Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
type DecryptQuery,
|
||||
type DecryptSubject,
|
||||
decrypts,
|
||||
decryptsAnything,
|
||||
detailActions,
|
||||
EVERYTHING,
|
||||
entryMatches,
|
||||
entrySubject,
|
||||
listEdit,
|
||||
setDecrypted,
|
||||
subjectEntry,
|
||||
} from "./decrypt";
|
||||
import { hostnameOf } from "./search";
|
||||
|
||||
/** Where an encrypted connection went, and the app that opened it, as the list judges it */
|
||||
export interface Connection {
|
||||
/** The host alone, an IPv6 address without brackets */
|
||||
host: string;
|
||||
port: number;
|
||||
/** The host as a button names it: with its port when that is not 443 */
|
||||
label: string;
|
||||
/** `host:port`, an IPv6 host in brackets, as the engine's pass-through set lists it */
|
||||
address: string;
|
||||
app: { name: string; bundleId: string | null } | null;
|
||||
}
|
||||
|
||||
type ConnectionFlow = Pick<Flow, "url" | "clientName" | "clientBundleId">;
|
||||
|
||||
/** The connection a tunnel or a refused handshake records: `https://host:port` or `host:port` */
|
||||
export function connectionOf(flow: ConnectionFlow): Connection {
|
||||
const scheme = flow.url.indexOf("://");
|
||||
const rest = scheme === -1 ? flow.url : flow.url.slice(scheme + 3);
|
||||
const authority = (rest.split(/[/?#]/)[0] ?? "").toLowerCase();
|
||||
const name = hostnameOf(authority);
|
||||
const portText = authority.slice(name.length + 1);
|
||||
const port = /^\d+$/.test(portText) ? Number(portText) : 443;
|
||||
const host = name.replace(/^\[|\]$/g, "");
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
label: port === 443 ? name : `${name}:${port}`,
|
||||
address: `${name}:${port}`,
|
||||
app: flow.clientName == null ? null : { name: flow.clientName, bundleId: flow.clientBundleId },
|
||||
};
|
||||
}
|
||||
|
||||
function queryOf(c: Connection): DecryptQuery {
|
||||
return { host: c.host, port: c.port, app: c.app };
|
||||
}
|
||||
|
||||
/** What a button on the card does */
|
||||
export type CardActionKind =
|
||||
| "decrypt"
|
||||
| "stop"
|
||||
| "reconnect"
|
||||
| "trust"
|
||||
| "try_again"
|
||||
| "copy_environment";
|
||||
|
||||
export interface CardAction {
|
||||
kind: CardActionKind;
|
||||
label: string;
|
||||
/** What it writes, shown beside it: an entry, or a hint like `next connection` */
|
||||
hint: string | null;
|
||||
/** The hint is an entry it takes off the list, so it is struck through */
|
||||
removes?: boolean;
|
||||
/** The list change, for `decrypt` and `stop` */
|
||||
edit?: { add: string[]; remove: string[] };
|
||||
/** What the toast says was chosen */
|
||||
toast?: { message: string; sub?: string };
|
||||
/** The suggested action */
|
||||
primary?: boolean;
|
||||
/** The narrowest override of an exclusion that ships for a reason */
|
||||
dashed?: boolean;
|
||||
/** What the icon shows: a host, the app, everything, or the action's own */
|
||||
icon: "host" | "app" | "everything" | "lock" | "lock_open" | "refresh" | "shield" | "terminal";
|
||||
}
|
||||
|
||||
/**
|
||||
* A line of text, the hosts, apps and entries in it shown as code, as Yaak shows literal values,
|
||||
* and a lead in bold
|
||||
*/
|
||||
export type Line = (string | { code: string } | { bold: string })[];
|
||||
|
||||
/** A line as plain text, its code in backticks and its bold in asterisks */
|
||||
export function lineText(line: Line): string {
|
||||
return line
|
||||
.map((part) =>
|
||||
typeof part === "string" ? part : "code" in part ? `\`${part.code}\`` : `**${part.bold}**`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export interface EncryptedCardModel {
|
||||
rejected: boolean;
|
||||
/** Why the connection is encrypted, in one line */
|
||||
why: Line;
|
||||
/** Quieter, after it */
|
||||
whySub: string | null;
|
||||
/** A fact about the connection since the list changed: when it opened */
|
||||
fact: Line | null;
|
||||
actions: CardAction[];
|
||||
/** Small lines under the buttons */
|
||||
foot: string[];
|
||||
}
|
||||
|
||||
export interface EncryptedCardInput {
|
||||
flow: Pick<
|
||||
Flow,
|
||||
| "url"
|
||||
| "clientName"
|
||||
| "clientBundleId"
|
||||
| "clientPath"
|
||||
| "tunnelReason"
|
||||
| "tunnelExcludedBy"
|
||||
| "tunnelExcludedBuiltIn"
|
||||
| "tlsRejected"
|
||||
| "elapsedMs"
|
||||
| "error"
|
||||
>;
|
||||
list: DecryptListEntry[];
|
||||
/** The certificate is trusted, null until read */
|
||||
trusted: boolean | null;
|
||||
/** The app can trust the certificate itself, as on macOS */
|
||||
canTrust: boolean;
|
||||
/** The addresses passed through after a rejection, null until read */
|
||||
passthrough: string[] | null;
|
||||
/** For a tunnel passed through after a rejection: the client that refused, when known */
|
||||
rejectedBy?: Pick<Flow, "clientName" | "clientBundleId" | "clientPath"> | null;
|
||||
}
|
||||
|
||||
/** A refused handshake, or a tunnel passed through because of one */
|
||||
export function isRejection(flow: Pick<Flow, "tlsRejected" | "tunnelReason">): boolean {
|
||||
return flow.tlsRejected != null || flow.tunnelReason === "rejected";
|
||||
}
|
||||
|
||||
/** A tunnel still open: written as it opens, done once it closes */
|
||||
export function isOpen(flow: Pick<Flow, "elapsedMs" | "error">): boolean {
|
||||
return flow.elapsedMs == null && flow.error == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The card for an HTTPS connection the proxy did not decrypt: why, from what the engine recorded
|
||||
* as it opened, and the changes to the list that would decrypt the next one like it, judged
|
||||
* against the list as it is now. Once the list decrypts it, the card says it opened before and
|
||||
* offers the opposite, and Reconnect while it is open. Nothing on it narrates what happens next.
|
||||
*/
|
||||
export function encryptedCard(input: EncryptedCardInput): EncryptedCardModel {
|
||||
return isRejection(input.flow) ? rejectedCard(input) : tunnelCard(input);
|
||||
}
|
||||
|
||||
function tunnelCard({ flow, list, trusted, canTrust }: EncryptedCardInput): EncryptedCardModel {
|
||||
const c = connectionOf(flow);
|
||||
const q = queryOf(c);
|
||||
const decryptsNow = decrypts(list, q);
|
||||
const { why, whySub } = whyLine(flow, list, c, decryptsNow);
|
||||
const foot: string[] = [];
|
||||
|
||||
if (decryptsNow) {
|
||||
// Decrypted from the next connection on: the entries that do it, each to take back.
|
||||
const decrypting = list.filter(
|
||||
(e) => !e.builtIn && !e.entry.startsWith("!") && entryMatches(e.entry, q),
|
||||
);
|
||||
const actions: CardAction[] = decrypting.map((e) => stopEntry(e.entry));
|
||||
if (isOpen(flow)) {
|
||||
actions.unshift({ kind: "reconnect", label: "Reconnect", hint: null, icon: "refresh" });
|
||||
}
|
||||
const first = decrypting[0]?.entry;
|
||||
const what: Line[number] =
|
||||
first === EVERYTHING ? "everything" : { code: first == null ? c.label : entrySubject(first) };
|
||||
return {
|
||||
rejected: false,
|
||||
why,
|
||||
whySub,
|
||||
fact: ["Opened before ", what, " was decrypted"],
|
||||
actions,
|
||||
foot: [],
|
||||
};
|
||||
}
|
||||
|
||||
const actions: CardAction[] = [];
|
||||
const builtInBlock = list.find(
|
||||
(e) => e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q),
|
||||
);
|
||||
const hostSubject: DecryptSubject = { kind: "host", host: c.label };
|
||||
const judged = detailActions(list, c.label, c.app);
|
||||
if (builtInBlock != null) {
|
||||
// Kept out by default for a reason: only the host itself, the narrowest override.
|
||||
if (judged.host) {
|
||||
actions.push({ ...decryptSubject(list, hostSubject, c.label, "host"), dashed: true });
|
||||
foot.length = 0;
|
||||
foot.push(
|
||||
builtInBlock.kind === "apple" || c.app == null
|
||||
? "macOS may refuse it"
|
||||
: `${c.app.name} may refuse it`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (judged.host) actions.push(decryptSubject(list, hostSubject, c.label, "host"));
|
||||
if (judged.app && c.app != null) {
|
||||
actions.push(decryptSubject(list, { kind: "app", ...c.app }, c.app.name, "app"));
|
||||
}
|
||||
const everything = list.some((e) => e.entry === EVERYTHING);
|
||||
if (!everything && decrypts([{ entry: EVERYTHING, builtIn: false }, ...list], q)) {
|
||||
actions.push({
|
||||
kind: "decrypt",
|
||||
label: "Decrypt Everything",
|
||||
hint: EVERYTHING,
|
||||
edit: { add: [EVERYTHING], remove: [] },
|
||||
toast: { message: "Decrypting everything", sub: "Except Apple services and apps that pin" },
|
||||
icon: "everything",
|
||||
});
|
||||
}
|
||||
if (actions.length === 0) {
|
||||
// The person's own exclusions keep it out: taking them off, when that alone decrypts it.
|
||||
const own = list.filter(
|
||||
(e) => !e.builtIn && e.entry.startsWith("!") && entryMatches(e.entry, q),
|
||||
);
|
||||
const without = list.filter((e) => !own.includes(e));
|
||||
if (own.length > 0 && decrypts(without, q)) {
|
||||
const [only] = own;
|
||||
const label =
|
||||
own.length === 1 && only != null
|
||||
? `Stop Excluding ${entrySubject(only.entry.slice(1))}`
|
||||
: `Stop Excluding ${own.length} Entries`;
|
||||
actions.push({
|
||||
kind: "decrypt",
|
||||
label,
|
||||
hint: own.map((e) => e.entry).join(", "),
|
||||
removes: true,
|
||||
edit: { add: [], remove: own.map((e) => e.entry) },
|
||||
toast: { message: label.replace("Stop Excluding", "Stopped excluding") },
|
||||
icon: "lock_open",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (actions.length > 0 && canTrust && trusted === false) foot.push("Asks for your password once");
|
||||
return {
|
||||
rejected: false,
|
||||
why,
|
||||
whySub,
|
||||
fact: null,
|
||||
actions,
|
||||
foot: actions.length === 0 ? [] : foot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The why line, from the reason the engine recorded as the tunnel opened. One that nothing on
|
||||
* the list covers names its host; once the list covers it, it opened before that.
|
||||
*/
|
||||
function whyLine(
|
||||
flow: EncryptedCardInput["flow"],
|
||||
list: DecryptListEntry[],
|
||||
c: Connection,
|
||||
decryptsNow: boolean,
|
||||
): { why: Line; whySub: string | null } {
|
||||
if (flow.tunnelReason === "excluded" && flow.tunnelExcludedBy != null) {
|
||||
if (flow.tunnelExcludedBuiltIn) {
|
||||
const kind = list.find((e) => e.entry === flow.tunnelExcludedBy)?.kind;
|
||||
const whySub =
|
||||
kind === "apple" ? "Apple service" : kind === "pinned" ? "pins its certificate" : null;
|
||||
return { why: ["Excluded by default"], whySub };
|
||||
}
|
||||
return { why: ["Excluded by ", { code: flow.tunnelExcludedBy }], whySub: null };
|
||||
}
|
||||
return {
|
||||
why: !decryptsAnything(list)
|
||||
? ["Nothing is decrypted yet"]
|
||||
: decryptsNow
|
||||
? ["Not on the decrypt list"]
|
||||
: [
|
||||
{ bold: "Encrypted Content:" },
|
||||
" Enable decryption to see requests to ",
|
||||
{ code: c.label },
|
||||
],
|
||||
whySub: null,
|
||||
};
|
||||
}
|
||||
|
||||
function decryptSubject(
|
||||
list: DecryptListEntry[],
|
||||
subject: DecryptSubject,
|
||||
name: string,
|
||||
icon: CardAction["icon"],
|
||||
): CardAction {
|
||||
return {
|
||||
kind: "decrypt",
|
||||
label: `Decrypt ${name}`,
|
||||
hint: subjectEntry(subject),
|
||||
edit: listEdit(list, setDecrypted(list, [subject], true)),
|
||||
toast: { message: `Decrypting ${name}` },
|
||||
icon,
|
||||
};
|
||||
}
|
||||
|
||||
function stopEntry(entry: string): CardAction {
|
||||
const name = entrySubject(entry);
|
||||
return {
|
||||
kind: "stop",
|
||||
label: `Stop Decrypting ${name}`,
|
||||
hint: entry,
|
||||
removes: true,
|
||||
edit: { add: [], remove: [entry] },
|
||||
toast: { message: `Stopped decrypting ${entry === EVERYTHING ? "everything" : name}` },
|
||||
icon: "lock",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Decrypting on a refused connection, judged with its host, port and app together: takes
|
||||
* off the person's entry naming exactly this host when that alone stops it, else excludes the
|
||||
* app when it is known, else the host, which the list always honours. Null when the list does
|
||||
* not decrypt it.
|
||||
*/
|
||||
export function stopDecryptingConnection(
|
||||
list: DecryptListEntry[],
|
||||
connection: Connection,
|
||||
): CardAction | null {
|
||||
const q = queryOf(connection);
|
||||
if (!decrypts(list, q)) return null;
|
||||
const hostEntry = subjectEntry({ kind: "host", host: connection.label });
|
||||
const own = list.find((e) => !e.builtIn && e.entry === hostEntry);
|
||||
if (
|
||||
own != null &&
|
||||
!decrypts(
|
||||
list.filter((e) => e !== own),
|
||||
q,
|
||||
)
|
||||
) {
|
||||
return { ...stopEntry(own.entry), label: `Stop Decrypting ${connection.label}` };
|
||||
}
|
||||
const name = connection.app?.name ?? connection.label;
|
||||
const add = connection.app == null ? `!${hostEntry}` : `!app:${connection.app.name}`;
|
||||
return {
|
||||
kind: "stop",
|
||||
label: `Stop Decrypting ${name}`,
|
||||
hint: add,
|
||||
edit: { add: [add], remove: [] },
|
||||
toast: { message: `Stopped decrypting ${name}` },
|
||||
icon: "lock",
|
||||
};
|
||||
}
|
||||
|
||||
/** Who refused: an app with a bundle, a program without one, or a client the app couldn't name */
|
||||
type Refuser =
|
||||
| { kind: "app"; name: string }
|
||||
| { kind: "program"; name: string }
|
||||
| { kind: "unknown" };
|
||||
|
||||
function refuserOf(
|
||||
client: Pick<Flow, "clientName" | "clientBundleId" | "clientPath"> | null | undefined,
|
||||
): Refuser {
|
||||
if (client?.clientName == null) return { kind: "unknown" };
|
||||
if (client.clientBundleId != null) return { kind: "app", name: client.clientName };
|
||||
return { kind: "program", name: client.clientName };
|
||||
}
|
||||
|
||||
function rejectedCard(input: EncryptedCardInput): EncryptedCardModel {
|
||||
const { flow, list, trusted, canTrust, passthrough } = input;
|
||||
const c = connectionOf(flow);
|
||||
// The refused handshake names its own client; a tunnel after it, the one that refused.
|
||||
const own = flow.tlsRejected != null;
|
||||
const refuser = refuserOf(own ? flow : input.rejectedBy);
|
||||
const stop = stopDecryptingConnection(list, c);
|
||||
const passedThrough = passthrough == null ? null : passthrough.includes(c.address);
|
||||
const tryAgain: CardAction | null =
|
||||
passedThrough === false
|
||||
? null
|
||||
: { kind: "try_again", label: "Try Again", hint: "next connection", icon: "refresh" };
|
||||
const until = passedThrough === false ? "No longer passed through" : null;
|
||||
|
||||
if (canTrust && trusted === false && decryptsAnything(list)) {
|
||||
return {
|
||||
rejected: true,
|
||||
why: ["Certificate not trusted"],
|
||||
whySub: null,
|
||||
fact: null,
|
||||
actions: [
|
||||
{ kind: "trust", label: "Trust Certificate", hint: null, primary: true, icon: "shield" },
|
||||
...(stop == null ? [] : [stop]),
|
||||
],
|
||||
foot: [until ?? "Passed through until trusted"],
|
||||
};
|
||||
}
|
||||
|
||||
const forHost = own ? "" : " for this host";
|
||||
if (refuser.kind === "program") {
|
||||
return {
|
||||
rejected: true,
|
||||
why: [`${refuser.name} rejected the certificate${forHost}`],
|
||||
whySub: "uses its own trust store",
|
||||
fact: null,
|
||||
actions: [
|
||||
{
|
||||
kind: "copy_environment",
|
||||
label: "Copy Environment",
|
||||
hint: "SSL_CERT_FILE, …",
|
||||
primary: true,
|
||||
icon: "terminal",
|
||||
},
|
||||
...(stop == null ? [] : [stop]),
|
||||
...(tryAgain == null ? [] : [tryAgain]),
|
||||
],
|
||||
foot: until == null ? [`Then rerun ${refuser.name}`] : [until],
|
||||
};
|
||||
}
|
||||
return {
|
||||
rejected: true,
|
||||
why: [
|
||||
refuser.kind === "app"
|
||||
? `${refuser.name} rejected the certificate${forHost}`
|
||||
: "A client rejected the certificate",
|
||||
],
|
||||
whySub: refuser.kind === "app" ? "likely pinned" : null,
|
||||
fact: null,
|
||||
actions: [
|
||||
...(stop == null ? [] : [{ ...stop, primary: true }]),
|
||||
...(tryAgain == null ? [] : [tryAgain]),
|
||||
],
|
||||
foot: [until ?? "Passed through until relaunch"],
|
||||
};
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -141,16 +141,20 @@ export function useFlowActions(): (flow: Flow) => DropdownItem[] {
|
||||
}
|
||||
};
|
||||
const noResponse = (flow.error != null || flow.cancelled) && flow.status == null;
|
||||
// A failed TLS handshake, recorded without a request, has nothing to replay.
|
||||
const noRequest = flow.method === "";
|
||||
return [
|
||||
{
|
||||
label: flow.savedRequestId == null ? "Save as Yaak Request" : "Save to Yaak Again",
|
||||
icon: "bookmark",
|
||||
disabled: noRequest,
|
||||
onSelect: () => openSave(flow),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Copy as cURL",
|
||||
icon: "square_terminal",
|
||||
disabled: noRequest,
|
||||
onSelect: async () => {
|
||||
let body: string | null = null;
|
||||
if (flow.requestSize > 0) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) };
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { appKeyOf, folderOf, isSystemFlow, runtimeFolderOf, sidebarPath, siteOf
|
||||
|
||||
export type SidebarKind =
|
||||
| "all"
|
||||
| "tunnelled"
|
||||
| "encrypted"
|
||||
| "saved"
|
||||
| "group"
|
||||
| "app"
|
||||
@@ -48,6 +48,11 @@ export interface SidebarItem {
|
||||
value?: string;
|
||||
/** What Decrypt in the row's menu decrypts: its host, site or app */
|
||||
decrypt?: DecryptSubject;
|
||||
/**
|
||||
* Every flow under the row is encrypted, marked once something in the capture is decrypted,
|
||||
* as in the first minute every row would be
|
||||
*/
|
||||
encryptedOnly?: boolean;
|
||||
/** The saved search a Saved row is, for renaming and deleting it */
|
||||
savedId?: string;
|
||||
/**
|
||||
@@ -71,7 +76,7 @@ export const SAVED_ID = "saved";
|
||||
export const APPS_ID = "apps";
|
||||
export const DOMAINS_ID = "domains";
|
||||
export const SYSTEM_ID = "system";
|
||||
export const TUNNELLED_ID = "tunnelled";
|
||||
export const ENCRYPTED_ID = "encrypted";
|
||||
|
||||
/** A row a flow sits under, with what makes it, so a rebuild only looks it up */
|
||||
interface PlaceRow {
|
||||
@@ -196,12 +201,14 @@ function programPath(flow: Flow): string | undefined {
|
||||
interface Trie {
|
||||
item: SidebarItem;
|
||||
children: Map<string, Trie>;
|
||||
/** Encrypted flows under it */
|
||||
encrypted?: number;
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
/** Counts a flow under `parent`'s child `row`, making the child the first time */
|
||||
function child(parent: Trie, row: PlaceRow): Trie {
|
||||
function child(parent: Trie, row: PlaceRow, encrypted: boolean): Trie {
|
||||
let node = parent.children.get(row.id);
|
||||
if (node == null) {
|
||||
node = {
|
||||
@@ -224,13 +231,25 @@ function child(parent: Trie, row: PlaceRow): Trie {
|
||||
delete node.item.path;
|
||||
}
|
||||
node.item.count++;
|
||||
if (encrypted) node.encrypted = (node.encrypted ?? 0) + 1;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Adds a flow under `parent` by where it went: its site, then its host, then its path */
|
||||
function addPlace(parent: Trie, rows: PlaceRow[]) {
|
||||
function addPlace(parent: Trie, rows: PlaceRow[], encrypted: boolean) {
|
||||
let node = parent;
|
||||
for (const row of rows) node = child(node, row);
|
||||
for (const row of rows) node = child(node, row, encrypted);
|
||||
}
|
||||
|
||||
/** Marks the apps, sites and hosts whose every flow is encrypted */
|
||||
function markEncrypted(trie: Trie) {
|
||||
for (const c of trie.children.values()) {
|
||||
const kind = c.item.kind;
|
||||
if ((kind === "app" || kind === "site" || kind === "host") && c.encrypted === c.item.count) {
|
||||
c.item.encryptedOnly = true;
|
||||
}
|
||||
markEncrypted(c);
|
||||
}
|
||||
}
|
||||
|
||||
function toNodes(
|
||||
@@ -264,9 +283,8 @@ function group(id: string, label: string): Trie {
|
||||
/**
|
||||
* The sidebar: All traffic, then Apps, each app opening to its own sites, hosts and paths, then
|
||||
* Domains by site, host and path, then System, macOS's daemons, apart from the apps. A group with
|
||||
* nothing in it is left out. Not decrypted, after All traffic, gathers the HTTPS passed through
|
||||
* undecrypted, when there is any, and Saved lists the saved searches, each with its count from
|
||||
* the database. `total` is All traffic's count: every flow it covers in the database.
|
||||
* nothing in it is left out. Encrypted, last, gathers the HTTPS passed through undecrypted,
|
||||
* when there is any, and Saved lists the saved searches, each with its count from the database. `total` is All traffic's count: every flow it covers in the database.
|
||||
*/
|
||||
export function buildSidebarTree(
|
||||
flows: Flow[],
|
||||
@@ -284,12 +302,12 @@ export function buildSidebarTree(
|
||||
const domains = group(DOMAINS_ID, "Domains");
|
||||
const system = { ...group(SYSTEM_ID, "System") };
|
||||
system.item.query = fieldTerm("is", "system");
|
||||
let tunnelled = 0;
|
||||
let encrypted = 0;
|
||||
|
||||
for (const flow of flows) {
|
||||
const place = placeOf(flow);
|
||||
if (flow.tunnelled) tunnelled++;
|
||||
addPlace(domains, place.rows);
|
||||
if (flow.tunnelled) encrypted++;
|
||||
addPlace(domains, place.rows, flow.tunnelled);
|
||||
if (place.app == null) continue;
|
||||
const parent = place.system ? system : apps;
|
||||
const id = `app:${place.app}`;
|
||||
@@ -318,8 +336,13 @@ export function buildSidebarTree(
|
||||
delete app.item.path;
|
||||
}
|
||||
app.item.count++;
|
||||
if (flow.tunnelled) app.encrypted = (app.encrypted ?? 0) + 1;
|
||||
parent.item.count++;
|
||||
addPlace(app, place.appRows);
|
||||
addPlace(app, place.appRows, flow.tunnelled);
|
||||
}
|
||||
// Once something is decrypted: a refused handshake isn't, so it doesn't count
|
||||
if (encrypted > 0 && flows.some((f) => !f.tunnelled && f.tlsRejected == null)) {
|
||||
for (const g of [apps, domains, system]) markEncrypted(g);
|
||||
}
|
||||
for (const site of domains.children.values()) domains.item.count += site.children.size;
|
||||
|
||||
@@ -329,20 +352,6 @@ export function buildSidebarTree(
|
||||
depth: 1,
|
||||
draggable: false,
|
||||
});
|
||||
if (tunnelled > 0) {
|
||||
root.children?.push({
|
||||
item: {
|
||||
id: TUNNELLED_ID,
|
||||
kind: "tunnelled",
|
||||
label: "Not decrypted",
|
||||
count: tunnelled,
|
||||
query: fieldTerm("is", "tunnelled"),
|
||||
},
|
||||
parent: root,
|
||||
depth: 1,
|
||||
draggable: false,
|
||||
});
|
||||
}
|
||||
if (saved.length > 0) {
|
||||
const node: TreeNode<SidebarItem> = {
|
||||
item: { id: SAVED_ID, kind: "group", label: "Saved", count: 0, query: "" },
|
||||
@@ -378,6 +387,20 @@ export function buildSidebarTree(
|
||||
node.children = toNodes(g.children.values(), node);
|
||||
root.children?.push(node);
|
||||
}
|
||||
if (encrypted > 0) {
|
||||
root.children?.push({
|
||||
item: {
|
||||
id: ENCRYPTED_ID,
|
||||
kind: "encrypted",
|
||||
label: "Encrypted",
|
||||
count: encrypted,
|
||||
query: fieldTerm("is", "encrypted"),
|
||||
},
|
||||
parent: root,
|
||||
depth: 1,
|
||||
draggable: false,
|
||||
});
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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";
|
||||
+147
-6
@@ -1,5 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { FilterHistoryEntry, Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery } from "./gen_models";
|
||||
import type { FilterHistoryEntry, Flow, FlowBody, FlowMessage, GrpcSchema, ModelPayload, SavedQuery, TunnelReason } from "./gen_models";
|
||||
|
||||
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
|
||||
|
||||
@@ -35,6 +35,23 @@ export type CaStatusRequest = Record<string, never>;
|
||||
|
||||
export type CaTrustRequest = Record<string, never>;
|
||||
|
||||
/**
|
||||
* What came of asking to trust the CA, with its status after.
|
||||
*/
|
||||
export type CaTrustResult = {
|
||||
/**
|
||||
* The person cancelled the password prompt, so nothing changed. Not an error.
|
||||
*/
|
||||
cancelled: boolean,
|
||||
/**
|
||||
* macOS trusts this app's CA, so HTTPS flows can be decrypted.
|
||||
*/
|
||||
trusted: boolean,
|
||||
/**
|
||||
* A certificate named "Yaak Proxy CA" is in a keychain: this one, or an older one.
|
||||
*/
|
||||
installed: boolean, certPath: string, };
|
||||
|
||||
export type ClearFilterHistoryRequest = Record<string, never>;
|
||||
|
||||
export type ClearFlowsRequest = Record<string, never>;
|
||||
@@ -60,7 +77,7 @@ path: string,
|
||||
*/
|
||||
cwd: string | null, };
|
||||
|
||||
export type CountSavedQueriesRequest = { hideSystem?: boolean, };
|
||||
export type CountSavedQueriesRequest = { hideSystem?: boolean, hideEncrypted?: boolean, };
|
||||
|
||||
export type CreateSavedQueryRequest = { name: string, query: string, };
|
||||
|
||||
@@ -115,7 +132,22 @@ entry: string,
|
||||
/**
|
||||
* Shipped with the app: an exclusion of Apple's services or a pinned app's hosts.
|
||||
*/
|
||||
builtIn: boolean, };
|
||||
builtIn: boolean,
|
||||
/**
|
||||
* Why a built-in exclusion ships. None for the user's own entries.
|
||||
*/
|
||||
kind?: ExclusionKind, };
|
||||
|
||||
/**
|
||||
* The decrypt list as saved, and what it means for connections already open.
|
||||
*/
|
||||
export type DecryptListSaved = {
|
||||
/**
|
||||
* Open connections the list now decrypts that stay encrypted until their clients
|
||||
* reconnect: `reconnect_tunnels` closes them so they do. Closed by nothing else. Zero from
|
||||
* an owner too old to count them.
|
||||
*/
|
||||
stillEncrypted: number, entries: Array<DecryptListEntry>, };
|
||||
|
||||
export type DeleteFilterHistoryRequest = { filter: string, };
|
||||
|
||||
@@ -131,6 +163,11 @@ export type DroppedHeader = { name: string, value: string, reason: DropReason, }
|
||||
*/
|
||||
export type EditDecryptListRequest = { add: Array<string>, remove: Array<string>, };
|
||||
|
||||
/**
|
||||
* Why a built-in exclusion ships.
|
||||
*/
|
||||
export type ExclusionKind = "apple" | "pinned";
|
||||
|
||||
export type ExportHarRequest = {
|
||||
/**
|
||||
* The file to write.
|
||||
@@ -171,6 +208,11 @@ query?: string,
|
||||
* `@is:system` itself.
|
||||
*/
|
||||
hideSystem?: boolean,
|
||||
/**
|
||||
* Leaves out HTTPS connections passed through undecrypted, as hiding Encrypted does,
|
||||
* unless the query says `@is:encrypted` itself.
|
||||
*/
|
||||
hideEncrypted?: boolean,
|
||||
/**
|
||||
* At most this many flows, the newest.
|
||||
*/
|
||||
@@ -216,6 +258,22 @@ export type FolderSummary = { id: string, name: string,
|
||||
*/
|
||||
folderId: string | null, };
|
||||
|
||||
/**
|
||||
* An address to intercept again: `host`, `host:port` or `[::1]:8443`, as a flow's URL has
|
||||
* it, the port 443 when left out.
|
||||
*/
|
||||
export type ForgetPassthroughRequest = { address: string, };
|
||||
|
||||
export type ForgetPassthroughResponse = {
|
||||
/**
|
||||
* It was passed through.
|
||||
*/
|
||||
forgotten: boolean,
|
||||
/**
|
||||
* The addresses still passed through.
|
||||
*/
|
||||
addresses: Array<string>, };
|
||||
|
||||
export type FormatBodyRequest = { text: string, };
|
||||
|
||||
export type GetDecryptListRequest = Record<string, never>;
|
||||
@@ -391,6 +449,8 @@ messages: Array<FlowMessage>,
|
||||
*/
|
||||
total: number, };
|
||||
|
||||
export type ListPassthroughRequest = Record<string, never>;
|
||||
|
||||
export type ListSavedQueriesRequest = Record<string, never>;
|
||||
|
||||
export type ListSeenRequest = Record<string, never>;
|
||||
@@ -419,6 +479,28 @@ export type OpenTerminalRequest = {
|
||||
*/
|
||||
vars: Array<[string, string]>, };
|
||||
|
||||
/**
|
||||
* An open tunnel, as the app is told of it.
|
||||
*/
|
||||
export type OpenTunnel = {
|
||||
/**
|
||||
* Its id for closing it, unique to this run of the engine, so one listed before the
|
||||
* engine restarted closes nothing after.
|
||||
*/
|
||||
id: string,
|
||||
/**
|
||||
* The host it leads to, without an IPv6 address's brackets.
|
||||
*/
|
||||
host: string, port: number,
|
||||
/**
|
||||
* The app that opened it, when known.
|
||||
*/
|
||||
appName: string | null, appBundleId: string | null,
|
||||
/**
|
||||
* Its flow, when it was recorded.
|
||||
*/
|
||||
flowId: string | null, reason: TunnelReason, };
|
||||
|
||||
export type OtherOwner = { owner: Owner, pid: number, startedAt: string,
|
||||
/**
|
||||
* The engine's port while it runs.
|
||||
@@ -430,6 +512,25 @@ proxyPort: number | null, };
|
||||
*/
|
||||
export type Owner = "app" | "cli";
|
||||
|
||||
/**
|
||||
* The addresses passed through after clients rejected the CA changed: one was added or
|
||||
* forgotten.
|
||||
*/
|
||||
export type PassthroughChangedPayload = {
|
||||
/**
|
||||
* Each `host:port`, an IPv6 host in brackets, sorted.
|
||||
*/
|
||||
addresses: Array<string>, };
|
||||
|
||||
/**
|
||||
* The addresses passed through after clients rejected the CA.
|
||||
*/
|
||||
export type PassthroughResponse = {
|
||||
/**
|
||||
* Each `host:port`, an IPv6 host in brackets, sorted.
|
||||
*/
|
||||
addresses: Array<string>, };
|
||||
|
||||
export type PreviewSaveRequest = { flowId: string, };
|
||||
|
||||
/**
|
||||
@@ -476,6 +577,31 @@ export type QueryValuesRequest = {
|
||||
*/
|
||||
path: string, };
|
||||
|
||||
/**
|
||||
* Tunnels to close: those in `ids`, or, without them, every one `list_reconnectable_tunnels`
|
||||
* returns for the same `entry` and `flowId`.
|
||||
*/
|
||||
export type ReconnectTunnelsRequest = {
|
||||
/**
|
||||
* `OpenTunnel` ids. Ones from an earlier run of the engine close nothing.
|
||||
*/
|
||||
ids?: Array<string>,
|
||||
/**
|
||||
* An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
|
||||
* names. An exclusion, `!host:*.chase.com`, names those it would keep out.
|
||||
*/
|
||||
entry?: string,
|
||||
/**
|
||||
* Only the tunnel this flow records.
|
||||
*/
|
||||
flowId?: string, };
|
||||
|
||||
export type ReconnectTunnelsResponse = {
|
||||
/**
|
||||
* Tunnels closed: those still open of the ones asked for.
|
||||
*/
|
||||
closed: number, };
|
||||
|
||||
/**
|
||||
* A filter applied from the search box, or from the history itself, put at its top.
|
||||
*/
|
||||
@@ -494,9 +620,9 @@ export type RenameSavedQueryRequest = { id: string, name: string, };
|
||||
|
||||
export type ResetCapturedDataRequest = Record<string, never>;
|
||||
|
||||
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, };
|
||||
export type RpcEventSchema = { decrypt_list_changed: DecryptListChangedPayload, filter_history_changed: FilterHistoryChangedPayload, flows_cleared: FlowsClearedPayload, flows_removed: FlowsRemovedPayload, message_write: MessagePayload, model_write: ModelPayload, passthrough_changed: PassthroughChangedPayload, proxy_state_changed: ProxyStatePayload, saved_queries_changed: SavedQueriesChangedPayload, };
|
||||
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaStatus], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptList], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptList], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
|
||||
export type RpcSchema = { execute_action: [ActionInvocation, boolean], format_body: [FormatBodyRequest, string], get_decrypt_list: [GetDecryptListRequest, DecryptList], get_message_payload: [GetMessagePayloadRequest, GetMessagePayloadResponse], list_actions: [ListActionsRequest, ListActionsResponse], list_flows: [FlowFilter, ListFlowsResponse], query_schema: [QuerySchemaRequest, QuerySchemaResponse], validate_query: [ValidateQueryRequest, Array<QueryDiagnostic>], watch_query: [WatchQueryRequest, boolean], list_saved_queries: [ListSavedQueriesRequest, Array<SavedQuery>], create_saved_query: [CreateSavedQueryRequest, SavedQuery], rename_saved_query: [RenameSavedQueryRequest, SavedQuery], delete_saved_query: [DeleteSavedQueryRequest, boolean], list_filter_history: [ListFilterHistoryRequest, Array<FilterHistoryEntry>], record_filter: [RecordFilterRequest, boolean], delete_filter_history: [DeleteFilterHistoryRequest, boolean], clear_filter_history: [ClearFilterHistoryRequest, number], get_last_filter: [GetLastFilterRequest, string], set_last_filter: [SetLastFilterRequest, boolean], get_window_places: [GetWindowPlacesRequest, string | null], set_window_places: [SetWindowPlacesRequest, boolean], list_grpc_schemas: [ListGrpcSchemasRequest, Array<GrpcSchema>], list_messages: [ListMessagesRequest, ListMessagesResponse], remove_grpc_schema: [RemoveGrpcSchemaRequest, boolean], set_recording: [SetRecordingRequest, boolean], set_system_proxy_at_launch: [SetSystemProxyAtLaunchRequest, boolean], set_http_version: [SetHttpVersionRequest, HttpVersion], set_capture_body_limit: [SetCaptureBodyLimitRequest, number], set_traffic: [SetTrafficRequest, TrafficRetention], set_keep_traffic: [SetKeepTrafficRequest, number | null], add_grpc_schema: [AddGrpcSchemaRequest, GrpcSchema], app_icon: [AppIconRequest, string | null], ca_status: [CaStatusRequest, CaStatus], ca_trust: [CaTrustRequest, CaTrustResult], forget_passthrough: [ForgetPassthroughRequest, ForgetPassthroughResponse], list_passthrough: [ListPassthroughRequest, PassthroughResponse], list_reconnectable_tunnels: [TunnelScope, Array<OpenTunnel>], reconnect_tunnels: [ReconnectTunnelsRequest, ReconnectTunnelsResponse], clear_flows: [ClearFlowsRequest, number], decode_grpc_message: [DecodeGrpcMessageRequest, DecodedGrpcMessage], export_har: [ExportHarRequest, number], filter_body: [FilterBodyRequest, FilterBodyResponse], get_flow_body: [GetFlowBodyRequest, GetFlowBodyResponse], get_proxy_state: [GetProxyStateRequest, GetProxyStateResponse], helper_register: [HelperRegisterRequest, SystemProxyStatus], helper_unregister: [HelperUnregisterRequest, SystemProxyStatus], edit_decrypt_list: [EditDecryptListRequest, DecryptListSaved], list_seen: [ListSeenRequest, SeenTargets], set_decrypt_list: [SetDecryptListRequest, DecryptListSaved], list_workspaces: [ListWorkspacesRequest, ListWorkspacesResponse], preview_save: [PreviewSaveRequest, SavePreview], reflect_grpc_host: [ReflectGrpcHostRequest, GrpcSchema], save_flow: [SaveFlowToYaakRequest, SavedFlow], save_flow_body: [SaveFlowBodyRequest, SaveFlowBodyResponse], system_proxy_set: [SystemProxySetRequest, SystemProxyStatus], system_proxy_status: [SystemProxyStatusRequest, SystemProxyStatus], take_over: [TakeOverRequest, boolean], set_port: [SetPortRequest, number], get_storage_size: [GetStorageSizeRequest, GetStorageSizeResponse], reset_captured_data: [ResetCapturedDataRequest, boolean], open_terminal: [OpenTerminalRequest, boolean], query_values: [QueryValuesRequest, Array<string>], match_flows: [MatchFlowsRequest, Array<string>], get_flows: [GetFlowsRequest, Array<Flow>], count_saved_queries: [CountSavedQueriesRequest, Array<SavedQueryCount>], };
|
||||
|
||||
export type SaveFlowBodyRequest = { flowId: string, which: FlowBody,
|
||||
/**
|
||||
@@ -672,12 +798,27 @@ export type TakeOverRequest = Record<string, never>;
|
||||
*/
|
||||
export type TrafficRetention = "clearOnLaunch" | "keep";
|
||||
|
||||
/**
|
||||
* Which open tunnels to consider: all of them, those one decrypt-list entry names, or the one
|
||||
* a flow records.
|
||||
*/
|
||||
export type TunnelScope = {
|
||||
/**
|
||||
* An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
|
||||
* names. An exclusion, `!host:*.chase.com`, names those it would keep out.
|
||||
*/
|
||||
entry?: string,
|
||||
/**
|
||||
* Only the tunnel this flow records.
|
||||
*/
|
||||
flowId?: string, };
|
||||
|
||||
export type ValidateQueryRequest = { query: string, };
|
||||
|
||||
/**
|
||||
* The search the app shows, which flows written from now on are matched against, their events
|
||||
* naming `key`. No query stops it.
|
||||
*/
|
||||
export type WatchQueryRequest = { key: string, query?: string, hideSystem?: boolean, };
|
||||
export type WatchQueryRequest = { key: string, query?: string, hideSystem?: boolean, hideEncrypted?: boolean, };
|
||||
|
||||
export type WorkspaceSummary = { id: string, name: string, folders: Array<FolderSummary>, };
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Why an HTTPS connection was passed through undecrypted: `not_listed`, `excluded` (with the
|
||||
-- exclusion that kept it out, and whether that one is built in) or `rejected` (with the flow of
|
||||
-- the refused handshake that started passing its address through, when that was recorded).
|
||||
ALTER TABLE flows ADD COLUMN tunnel_reason TEXT;
|
||||
ALTER TABLE flows ADD COLUMN tunnel_excluded_by TEXT;
|
||||
ALTER TABLE flows ADD COLUMN tunnel_excluded_built_in BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE flows ADD COLUMN tunnel_rejected_by TEXT;
|
||||
|
||||
-- A tunnel the proxy closed on request so its client reconnects.
|
||||
ALTER TABLE flows ADD COLUMN reconnect_requested BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- A client's refused TLS handshake with the proxy, recorded once as its address is passed
|
||||
-- through: the client's alert (`unknown_ca`, `bad_certificate`, ...) or `hang_up`.
|
||||
ALTER TABLE flows ADD COLUMN tls_rejected TEXT;
|
||||
@@ -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;
|
||||
@@ -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" {
|
||||
|
||||
@@ -618,7 +618,7 @@ mod tests {
|
||||
&applied[6..],
|
||||
[
|
||||
"00000007", "00000008", "00000009", "00000010", "00000011", "00000012", "00000013",
|
||||
"00000014"
|
||||
"00000014", "00000015"
|
||||
]
|
||||
);
|
||||
let mark_columns = columns(&conn, "flow_clear_mark");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::bodies;
|
||||
use crate::db::{ProxyQueryManager, Stores};
|
||||
use crate::models::{Flow, FlowBody};
|
||||
use crate::query::{FlowColumns, FlowQuery};
|
||||
use crate::query::{FlowColumns, FlowQuery, Hidden};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use rusqlite::types::Value;
|
||||
@@ -23,6 +23,10 @@ pub struct FlowFilter {
|
||||
/// `@is:system` itself.
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub hide_system: bool,
|
||||
/// Leaves out HTTPS connections passed through undecrypted, as hiding Encrypted does,
|
||||
/// unless the query says `@is:encrypted` itself.
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub hide_encrypted: bool,
|
||||
/// At most this many flows, the newest.
|
||||
#[ts(optional, type = "number")]
|
||||
pub limit: Option<usize>,
|
||||
@@ -338,7 +342,8 @@ impl FlowFilter {
|
||||
Self { query: Some(query.into()), ..Self::default() }
|
||||
}
|
||||
|
||||
/// The filter from a query string's `query`, `hide_system`, `limit`, `after` and `before`.
|
||||
/// The filter from a query string's `query`, `hide_system`, `hide_encrypted`, `limit`,
|
||||
/// `after` and `before`.
|
||||
pub fn from_query(query: &str) -> Result<Self, String> {
|
||||
let mut filter = Self::default();
|
||||
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
@@ -351,6 +356,12 @@ impl FlowFilter {
|
||||
.transpose()?
|
||||
.unwrap_or_default()
|
||||
}
|
||||
"hide_encrypted" => {
|
||||
filter.hide_encrypted = value
|
||||
.map(|v| v.parse().map_err(|_| format!("Invalid hide_encrypted {v:?}")))
|
||||
.transpose()?
|
||||
.unwrap_or_default()
|
||||
}
|
||||
"after" => filter.after = value,
|
||||
"before" => filter.before = value,
|
||||
"limit" => {
|
||||
@@ -368,9 +379,11 @@ impl FlowFilter {
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
let limit = self.limit.map(|l| l.to_string());
|
||||
let hide_system = self.hide_system.then(|| "true".to_string());
|
||||
let hide_encrypted = self.hide_encrypted.then(|| "true".to_string());
|
||||
for (key, value) in [
|
||||
("query", &self.query),
|
||||
("hide_system", &hide_system),
|
||||
("hide_encrypted", &hide_encrypted),
|
||||
("limit", &limit),
|
||||
("after", &self.after),
|
||||
("before", &self.before),
|
||||
@@ -384,7 +397,12 @@ impl FlowFilter {
|
||||
|
||||
/// The search, prepared to run now.
|
||||
pub fn flow_query(&self) -> FlowQuery {
|
||||
FlowQuery::new(self.query.as_deref().unwrap_or_default(), self.hide_system)
|
||||
FlowQuery::new(self.query.as_deref().unwrap_or_default(), self.hidden())
|
||||
}
|
||||
|
||||
/// What the filter leaves out unless its query names it.
|
||||
pub fn hidden(&self) -> Hidden {
|
||||
Hidden { system: self.hide_system, encrypted: self.hide_encrypted }
|
||||
}
|
||||
|
||||
/// Narrows more than `after`, `before` and `limit` do, so it covers fewer than all flows.
|
||||
@@ -1867,6 +1885,67 @@ mod tests {
|
||||
assert_eq!(hidden("@host:*.github.com OR @is:SYSTEM"), vec!["fl_daemon", "fl_node"]);
|
||||
}
|
||||
|
||||
/// Hiding encrypted connections leaves them out of the list and its count, the totals the
|
||||
/// sidebar reads, and saved searches' counts, unless the search names them.
|
||||
#[test]
|
||||
fn hides_encrypted_connections() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db =
|
||||
ProxyQueryManager::new(&dir.path().join("proxy.db"), &crate::db::test_key()).unwrap();
|
||||
let flow = |id: &str, url: &str, tunnelled: bool, path: Option<&str>| Flow {
|
||||
id: id.into(),
|
||||
url: url.into(),
|
||||
method: if tunnelled { "CONNECT" } else { "GET" }.into(),
|
||||
tunnelled,
|
||||
client_path: path.map(String::from),
|
||||
..Default::default()
|
||||
};
|
||||
for f in [
|
||||
flow("fl_get", "https://api.example.com/v1", false, None),
|
||||
flow("fl_tunnel", "https://bank.example.com", true, None),
|
||||
flow("fl_daemon_tunnel", "https://p01.icloud.com", true, Some("/usr/libexec/trustd")),
|
||||
] {
|
||||
db.with_tx(|tx| tx.upsert(&f, &yaak_database::UpdateSource::Background)).unwrap();
|
||||
}
|
||||
let filter = |query: &str, hide_system: bool| FlowFilter {
|
||||
hide_encrypted: true,
|
||||
hide_system,
|
||||
..FlowFilter::search(query)
|
||||
};
|
||||
let ids = |filter: FlowFilter| -> Vec<String> {
|
||||
let mut ids: Vec<String> =
|
||||
list(&db, &filter).unwrap().into_iter().map(|f| f.id).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
assert_eq!(ids(filter("", false)), vec!["fl_get"]);
|
||||
assert_eq!(ids(filter("example", false)), vec!["fl_get"]);
|
||||
assert_eq!(ids(filter("@is:encrypted", false)), vec!["fl_daemon_tunnel", "fl_tunnel"]);
|
||||
assert_eq!(ids(filter("@is:encrypted", true)), vec!["fl_tunnel"]);
|
||||
assert_eq!(ids(filter("@is:tunnelled", false)).len(), 2);
|
||||
|
||||
// The totals: everything stored, and what shows with encrypted connections hidden.
|
||||
let page = sorted_page(&db, &FlowFilter { limit: Some(0), ..filter("", false) }).unwrap();
|
||||
assert_eq!((page.total, page.matching), (3, Some(1)));
|
||||
let page = sorted_page(&db, &FlowFilter::default()).unwrap();
|
||||
assert_eq!((page.total, page.matching), (3, None));
|
||||
assert!(FlowFilter { hide_encrypted: true, ..Default::default() }.narrows());
|
||||
assert_eq!(
|
||||
count_each(&db, &[filter("", false), filter("@is:encrypted", true)]).unwrap(),
|
||||
[1, 1]
|
||||
);
|
||||
|
||||
// Saved searches count as the list would show them.
|
||||
let all = crate::saved_queries::create(&db, "All HTTPS", "@scheme:https").unwrap();
|
||||
let count = |hidden: Hidden| {
|
||||
let counts = crate::saved_queries::counts(&db, hidden).unwrap();
|
||||
counts.into_iter().find(|c| c.id == all.id).unwrap().count
|
||||
};
|
||||
assert_eq!(count(Hidden::default()), 3);
|
||||
assert_eq!(count(Hidden { system: false, encrypted: true }), 1);
|
||||
assert_eq!(count(Hidden { system: true, encrypted: false }), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tells_system_daemons_apart() {
|
||||
assert!(is_system(None, Some("/usr/libexec/nsurlsessiond")));
|
||||
@@ -1897,6 +1976,13 @@ mod tests {
|
||||
};
|
||||
assert_eq!(filter.to_query(), "query=post+%40status%3A4xx&hide_system=true&limit=20");
|
||||
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
|
||||
let filter = FlowFilter { hide_encrypted: true, ..filter };
|
||||
assert_eq!(
|
||||
filter.to_query(),
|
||||
"query=post+%40status%3A4xx&hide_system=true&hide_encrypted=true&limit=20"
|
||||
);
|
||||
assert_eq!(FlowFilter::from_query(&filter.to_query()).unwrap(), filter);
|
||||
assert!(FlowFilter::from_query("hide_encrypted=maybe").is_err());
|
||||
assert_eq!(FlowFilter::from_query("").unwrap(), FlowFilter::default());
|
||||
assert!(FlowFilter::from_query("limit=lots").is_err());
|
||||
assert!(FlowFilter::from_query("hide_system=maybe").is_err());
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod save;
|
||||
pub mod saved_queries;
|
||||
pub mod settings;
|
||||
pub mod terminal;
|
||||
pub mod tunnels;
|
||||
|
||||
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
|
||||
use crate::attribution::Attribution;
|
||||
@@ -32,7 +33,9 @@ use crate::grpc::{DecodedGrpcMessage, GrpcSchema, GrpcState};
|
||||
use crate::helper::HelperState;
|
||||
use crate::macos::{SystemProxyBackend, SystemProxyStatus};
|
||||
use crate::messages::FlowMessage;
|
||||
use crate::models::{Flow, FlowBody, ModelPayload, ProxyHeader, StreamKind, find_header};
|
||||
use crate::models::{
|
||||
Flow, FlowBody, ModelPayload, ProxyHeader, StreamKind, TunnelReason, find_header,
|
||||
};
|
||||
use crate::save::{ClientDb, SaveFlowRequest, SaveFlowResponse, WorkspaceSummary};
|
||||
use crate::settings::{DecryptList, HttpVersion, ProxySettings, TrafficRetention};
|
||||
use base64::Engine;
|
||||
@@ -130,6 +133,8 @@ pub struct ProxyCtx {
|
||||
pub grpc: GrpcState,
|
||||
/// The search the app shows, which written flows' events are matched against.
|
||||
live_search: Arc<query::LiveSearch>,
|
||||
/// The HTTPS connections the running engine passes through undecrypted now.
|
||||
tunnels: Arc<tunnels::OpenTunnels>,
|
||||
}
|
||||
|
||||
impl ProxyCtx {
|
||||
@@ -178,6 +183,7 @@ impl ProxyCtx {
|
||||
events,
|
||||
grpc: GrpcState::default(),
|
||||
live_search: Default::default(),
|
||||
tunnels: Default::default(),
|
||||
};
|
||||
// Unreadable settings keep the traffic: nothing is deleted on a guess. Nor is traffic
|
||||
// another process is capturing as this launches, which this one may take over later.
|
||||
@@ -513,6 +519,7 @@ impl ProxyCtx {
|
||||
let mut proxy_handle = yaak_proxy::start_proxy(config)?;
|
||||
let attribution = Attribution::new(proxy_handle.port);
|
||||
let _ = attribution_slot.set(attribution.clone());
|
||||
let tunnel_run = self.tunnels.begin_run(Some(attribution.clone()));
|
||||
|
||||
if let Some(event_rx) = proxy_handle.take_event_rx() {
|
||||
let sink = FlowSink {
|
||||
@@ -524,6 +531,10 @@ impl ProxyCtx {
|
||||
recording: self.recording.clone(),
|
||||
finished: Default::default(),
|
||||
clock: BatchClock::Real,
|
||||
tunnels: self.tunnels.clone(),
|
||||
tunnel_run,
|
||||
handle: Arc::downgrade(&self.handle),
|
||||
rejections: Default::default(),
|
||||
};
|
||||
std::thread::spawn(move || run_event_loop(event_rx, sink));
|
||||
}
|
||||
@@ -615,6 +626,9 @@ impl ProxyCtx {
|
||||
let mut handle = self.handle.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||
// Under the handle's lock, so a start waiting for the databases sees it.
|
||||
self.stops.fetch_add(1, Ordering::SeqCst);
|
||||
// Its tunnels go with it, in the same turn of the lock, so nothing reads them as open
|
||||
// once it is gone.
|
||||
self.tunnels.stop_run();
|
||||
// Dropping the handle stops the listener and joins its thread.
|
||||
let port = handle.take().map(|h| h.port);
|
||||
drop(handle);
|
||||
@@ -1098,7 +1112,7 @@ impl ProxyCtx {
|
||||
|
||||
/// Replaces the decrypt list, saved in `settings.json` and applied to the running engine's
|
||||
/// next connections. Connections already open stay as they were.
|
||||
pub fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptList, String> {
|
||||
pub fn set_decrypt_list(&self, entries: &[String]) -> Result<DecryptListSaved, String> {
|
||||
self.change_decrypt_list(|settings| settings.set_decrypt_list(entries))
|
||||
}
|
||||
|
||||
@@ -1108,38 +1122,167 @@ impl ProxyCtx {
|
||||
&self,
|
||||
add: &[String],
|
||||
remove: &[String],
|
||||
) -> Result<DecryptList, String> {
|
||||
) -> Result<DecryptListSaved, String> {
|
||||
self.change_decrypt_list(|settings| settings.edit_decrypt_list(add, remove))
|
||||
}
|
||||
|
||||
/// Changes the list in `settings.json`, one change at a time, applies it to the running
|
||||
/// engine, and tells the app.
|
||||
/// engine, and tells the app. Connections already open are left as they are; the reply
|
||||
/// counts those the new list would decrypt if they reconnected.
|
||||
fn change_decrypt_list(
|
||||
&self,
|
||||
change: impl FnOnce(&mut ProxySettings) -> Result<(), String>,
|
||||
) -> Result<DecryptList, String> {
|
||||
) -> Result<DecryptListSaved, String> {
|
||||
let _turn = self.decrypt_edits.lock().map_err(|_| "lock poisoned".to_string())?;
|
||||
let mut settings = self.settings()?;
|
||||
change(&mut settings)?;
|
||||
settings.save(&self.settings_path())?;
|
||||
if let Ok(handle) = self.handle.lock()
|
||||
&& let Some(handle) = handle.as_ref()
|
||||
{
|
||||
handle.set_decrypt_policy(settings.decrypt_policy());
|
||||
}
|
||||
let policy = settings.decrypt_policy();
|
||||
// Applied and counted against the one engine running, whose tunnels these are.
|
||||
let still_encrypted = self.with_engine(|engine| {
|
||||
let Some(engine) = engine else {
|
||||
return Ok::<_, String>(0);
|
||||
};
|
||||
engine.set_decrypt_policy(policy.clone());
|
||||
let all = tunnels::TunnelScope::default();
|
||||
Ok(self.reconnectable_in(engine, &policy, &all)?.len())
|
||||
})?;
|
||||
let list = settings.decrypt_list();
|
||||
self.events.emit("decrypt_list_changed", &DecryptListChangedPayload { list: list.clone() });
|
||||
Ok(list)
|
||||
Ok(DecryptListSaved { list, still_encrypted })
|
||||
}
|
||||
|
||||
/// `f` with the engine running now, if any, under its lock. Starting and stopping the engine
|
||||
/// swap it and the open-tunnel registry's run under this same lock, so what `f` reads of
|
||||
/// the engine's tunnels and pass-through set and what it does to them belong to one run.
|
||||
fn with_engine<T>(&self, f: impl FnOnce(Option<&ProxyHandle>) -> T) -> T {
|
||||
let handle = self.handle.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
f(handle.as_ref())
|
||||
}
|
||||
|
||||
/// The open tunnels of `engine` within `scope` that `policy` would decrypt if their clients
|
||||
/// reconnected. For a caller holding the engine's lock.
|
||||
fn reconnectable_in(
|
||||
&self,
|
||||
engine: &ProxyHandle,
|
||||
policy: &yaak_proxy::DecryptPolicy,
|
||||
scope: &tunnels::TunnelScope,
|
||||
) -> Result<Vec<tunnels::OpenTunnel>, String> {
|
||||
tunnels::reconnectable(&self.tunnels.list(), policy, &engine.passthrough(), scope)
|
||||
}
|
||||
|
||||
/// Intercepts again the hosts passed through after clients rejected the CA, as they no
|
||||
/// longer will once it is trusted.
|
||||
pub fn forget_passthrough(&self) {
|
||||
if let Ok(handle) = self.handle.lock()
|
||||
&& let Some(handle) = handle.as_ref()
|
||||
{
|
||||
handle.forget_passthrough();
|
||||
let addresses = self.with_engine(|engine| {
|
||||
let engine = engine?;
|
||||
engine.forget_passthrough();
|
||||
Some(engine.passthrough())
|
||||
});
|
||||
let addresses = addresses.unwrap_or_default();
|
||||
self.events.emit("passthrough_changed", &PassthroughChangedPayload { addresses });
|
||||
}
|
||||
|
||||
/// Intercepts again one address (`host:port`) passed through after a client rejected the
|
||||
/// CA. Returns whether it was passed through, and what still is, read from the same engine.
|
||||
pub fn forget_passthrough_address(&self, address: &str) -> (bool, Vec<String>) {
|
||||
let (forgotten, addresses) = self.with_engine(|engine| match engine {
|
||||
Some(e) => (e.forget_passthrough_address(address), e.passthrough()),
|
||||
None => (false, Vec::new()),
|
||||
});
|
||||
if forgotten {
|
||||
let payload = PassthroughChangedPayload { addresses: addresses.clone() };
|
||||
self.events.emit("passthrough_changed", &payload);
|
||||
}
|
||||
(forgotten, addresses)
|
||||
}
|
||||
|
||||
/// The addresses (`host:port`) the running engine passes through after clients rejected
|
||||
/// the CA. Empty while it is stopped.
|
||||
pub fn passthrough(&self) -> Vec<String> {
|
||||
self.with_engine(|engine| engine.map(ProxyHandle::passthrough).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// [`Self::forget_passthrough_address`] for an address as the app or the control API names
|
||||
/// it, with what is still passed through.
|
||||
pub fn forget_passthrough_request(
|
||||
&self,
|
||||
req: &ForgetPassthroughRequest,
|
||||
) -> Result<ForgetPassthroughResponse, String> {
|
||||
let address = passthrough_address(&req.address)
|
||||
.ok_or_else(|| format!("Invalid address {:?}", req.address))?;
|
||||
let (forgotten, addresses) = self.forget_passthrough_address(&address);
|
||||
Ok(ForgetPassthroughResponse { forgotten, addresses })
|
||||
}
|
||||
|
||||
/// Trusts the CA, made first when the engine has never run so there is one to trust. Once
|
||||
/// trusted, the addresses passed through after clients rejected it are intercepted again.
|
||||
pub async fn trust_ca(&self) -> Result<ca::CaTrustResult, String> {
|
||||
// Off the async threads: it may wait for the keychain or a conversion.
|
||||
let ca = {
|
||||
let ctx = self.clone();
|
||||
blocking(move || ctx.load_ca()).await.map_err(|e| e.message)?
|
||||
};
|
||||
let trusted = ca::trust(ca.cert_path()).await?;
|
||||
if trusted == ca::Trusted::Yes {
|
||||
self.forget_passthrough();
|
||||
}
|
||||
let status = ca::status(ca.cert_path()).await?;
|
||||
Ok(ca::CaTrustResult { status, cancelled: trusted == ca::Trusted::Cancelled })
|
||||
}
|
||||
|
||||
/// The process that owns the engine, while it is another one: commands about the running
|
||||
/// engine go there.
|
||||
fn engine_elsewhere(&self) -> Option<ControlInfo> {
|
||||
if self.port().is_some() {
|
||||
return None;
|
||||
}
|
||||
self.other_owner()
|
||||
}
|
||||
|
||||
/// The HTTPS connections open now that the decrypt list would decrypt if their clients
|
||||
/// reconnected, within `scope`. Not those passed through after a client rejected the CA.
|
||||
pub fn reconnectable_tunnels(
|
||||
&self,
|
||||
scope: &tunnels::TunnelScope,
|
||||
) -> Result<Vec<tunnels::OpenTunnel>, String> {
|
||||
let policy = self.settings()?.decrypt_policy();
|
||||
self.with_engine(|engine| match engine {
|
||||
Some(engine) => self.reconnectable_in(engine, &policy, scope),
|
||||
None => Ok(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Closes tunnels so their clients reconnect, decrypted as the list now says: those in
|
||||
/// `ids` this run of the engine gave out, or, without ids, those
|
||||
/// [`Self::reconnectable_tunnels`] finds in `scope`. Each ends with its flow marked
|
||||
/// `reconnectRequested`. Returns how many were open. Looked up and closed under the engine's
|
||||
/// lock, so a restart can't come between and have another run's tunnels closed.
|
||||
pub fn reconnect_tunnels(
|
||||
&self,
|
||||
ids: Option<&[String]>,
|
||||
scope: &tunnels::TunnelScope,
|
||||
) -> Result<usize, String> {
|
||||
let policy = match ids {
|
||||
Some(_) => None,
|
||||
None => Some(self.settings()?.decrypt_policy()),
|
||||
};
|
||||
self.with_engine(|engine| {
|
||||
let Some(engine) = engine else {
|
||||
return Ok(0);
|
||||
};
|
||||
let ids = match (ids, &policy) {
|
||||
(Some(ids), _) => self.tunnels.engine_ids(ids),
|
||||
(None, Some(policy)) => {
|
||||
let found = self.reconnectable_in(engine, policy, scope)?;
|
||||
found.iter().map(|t| t.engine_id).collect()
|
||||
}
|
||||
(None, None) => Vec::new(),
|
||||
};
|
||||
#[cfg(test)]
|
||||
tests::pause_between_lookup_and_close();
|
||||
Ok(engine.close_tunnels(&ids))
|
||||
})
|
||||
}
|
||||
|
||||
/// Flows are being stored.
|
||||
@@ -1630,6 +1773,51 @@ pub struct DecryptListChangedPayload {
|
||||
pub list: DecryptList,
|
||||
}
|
||||
|
||||
/// `host`, `host:port`, `[::1]` or `[::1]:8443` as the engine keys its pass-through set:
|
||||
/// `host:port`, an IPv6 host in brackets. None when it is none of these.
|
||||
fn passthrough_address(address: &str) -> Option<String> {
|
||||
let address = address.trim();
|
||||
let (host, port) = if let Some(rest) = address.strip_prefix('[') {
|
||||
let (host, after) = rest.split_once(']')?;
|
||||
let port = match after {
|
||||
"" => 443,
|
||||
p => p.strip_prefix(':')?.parse().ok()?,
|
||||
};
|
||||
(format!("[{host}]"), port)
|
||||
} else {
|
||||
match address.split_once(':') {
|
||||
Some((host, port)) if !port.contains(':') => (host.to_string(), port.parse().ok()?),
|
||||
Some(_) => (format!("[{address}]"), 443),
|
||||
None => (address.to_string(), 443),
|
||||
}
|
||||
};
|
||||
(!host.is_empty() && host != "[]").then(|| format!("{host}:{port}"))
|
||||
}
|
||||
|
||||
/// The decrypt list as saved, and what it means for connections already open.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct DecryptListSaved {
|
||||
#[serde(flatten)]
|
||||
pub list: DecryptList,
|
||||
/// Open connections the list now decrypts that stay encrypted until their clients
|
||||
/// reconnect: `reconnect_tunnels` closes them so they do. Closed by nothing else. Zero from
|
||||
/// an owner too old to count them.
|
||||
#[serde(default)]
|
||||
#[ts(type = "number")]
|
||||
pub still_encrypted: usize,
|
||||
}
|
||||
|
||||
/// The addresses passed through after clients rejected the CA changed: one was added or
|
||||
/// forgotten.
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct PassthroughChangedPayload {
|
||||
/// Each `host:port`, an IPv6 host in brackets, sorted.
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ProxyStatePayload {
|
||||
@@ -1702,6 +1890,9 @@ pub struct WatchQueryRequest {
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub hide_system: bool,
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub hide_encrypted: bool,
|
||||
}
|
||||
|
||||
/// Which of some flows a search covers, for flows whose events were matched against another.
|
||||
@@ -1744,6 +1935,9 @@ pub struct CountSavedQueriesRequest {
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub hide_system: bool,
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub hide_encrypted: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
@@ -2189,6 +2383,56 @@ pub struct CaStatusRequest {}
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CaTrustRequest {}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ListPassthroughRequest {}
|
||||
|
||||
/// The addresses passed through after clients rejected the CA.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct PassthroughResponse {
|
||||
/// Each `host:port`, an IPv6 host in brackets, sorted.
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
/// An address to intercept again: `host`, `host:port` or `[::1]:8443`, as a flow's URL has
|
||||
/// it, the port 443 when left out.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ForgetPassthroughRequest {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ForgetPassthroughResponse {
|
||||
/// It was passed through.
|
||||
pub forgotten: bool,
|
||||
/// The addresses still passed through.
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
/// Tunnels to close: those in `ids`, or, without them, every one `list_reconnectable_tunnels`
|
||||
/// returns for the same `entry` and `flowId`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ReconnectTunnelsRequest {
|
||||
/// `OpenTunnel` ids. Ones from an earlier run of the engine close nothing.
|
||||
#[ts(optional)]
|
||||
pub ids: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
pub scope: tunnels::TunnelScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct ReconnectTunnelsResponse {
|
||||
/// Tunnels closed: those still open of the ones asked for.
|
||||
#[ts(type = "number")]
|
||||
pub closed: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct SystemProxyStatusRequest {}
|
||||
@@ -2472,7 +2716,7 @@ fn get_decrypt_list(ctx: &ProxyCtx, _req: GetDecryptListRequest) -> Result<Decry
|
||||
async fn set_decrypt_list(
|
||||
ctx: ProxyCtx,
|
||||
req: SetDecryptListRequest,
|
||||
) -> Result<DecryptList, RpcError> {
|
||||
) -> Result<DecryptListSaved, RpcError> {
|
||||
if ctx.port().is_none()
|
||||
&& let Some(owner) = ctx.other_owner()
|
||||
{
|
||||
@@ -2485,7 +2729,7 @@ async fn set_decrypt_list(
|
||||
async fn edit_decrypt_list(
|
||||
ctx: ProxyCtx,
|
||||
req: EditDecryptListRequest,
|
||||
) -> Result<DecryptList, RpcError> {
|
||||
) -> Result<DecryptListSaved, RpcError> {
|
||||
if ctx.port().is_none()
|
||||
&& let Some(owner) = ctx.other_owner()
|
||||
{
|
||||
@@ -2524,6 +2768,9 @@ async fn list_workspaces(
|
||||
|
||||
async fn preview_save(ctx: ProxyCtx, req: PreviewSaveRequest) -> Result<SavePreview, RpcError> {
|
||||
let flow = find_flow(&ctx, &req.flow_id)?;
|
||||
if flow.is_handshake_only() {
|
||||
return Err(rpc_error(NOT_A_REQUEST));
|
||||
}
|
||||
let limit = settings(&ctx)?.capture_body_limit;
|
||||
let body = bodies::read_body(&ctx.db()?, &ctx.blobs()?, &flow, FlowBody::Request, limit)
|
||||
.await
|
||||
@@ -2683,16 +2930,59 @@ async fn ca_status(ctx: ProxyCtx, _req: CaStatusRequest) -> Result<CaStatus, Rpc
|
||||
ca::status(&ctx.ca_dir().join("ca.pem")).await.map_err(rpc_error)
|
||||
}
|
||||
|
||||
async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<CaStatus, RpcError> {
|
||||
// Creates the CA when the engine has never run, so there is something to trust. Off the
|
||||
// async threads: it may wait for the keychain or a conversion.
|
||||
let ca = {
|
||||
let ctx = ctx.clone();
|
||||
blocking(move || ctx.load_ca()).await?
|
||||
};
|
||||
ca::trust(ca.cert_path()).await.map_err(rpc_error)?;
|
||||
ctx.forget_passthrough();
|
||||
ca::status(ca.cert_path()).await.map_err(rpc_error)
|
||||
/// Asks macOS to trust the CA, whenever the app needs it, not only during setup. A cancelled
|
||||
/// password prompt is a result, `cancelled`, not an error.
|
||||
async fn ca_trust(ctx: ProxyCtx, _req: CaTrustRequest) -> Result<ca::CaTrustResult, RpcError> {
|
||||
ctx.trust_ca().await.map_err(rpc_error)
|
||||
}
|
||||
|
||||
/// The addresses passed through after clients rejected the CA, in the engine that runs, here
|
||||
/// or in the process that owns it.
|
||||
async fn list_passthrough(
|
||||
ctx: ProxyCtx,
|
||||
_req: ListPassthroughRequest,
|
||||
) -> Result<PassthroughResponse, RpcError> {
|
||||
if let Some(owner) = ctx.engine_elsewhere() {
|
||||
return ControlClient::new(owner).passthrough().await.map_err(rpc_error);
|
||||
}
|
||||
Ok(PassthroughResponse { addresses: ctx.passthrough() })
|
||||
}
|
||||
|
||||
/// Intercepts one passed-through address again from its next connection on.
|
||||
async fn forget_passthrough(
|
||||
ctx: ProxyCtx,
|
||||
req: ForgetPassthroughRequest,
|
||||
) -> Result<ForgetPassthroughResponse, RpcError> {
|
||||
if let Some(owner) = ctx.engine_elsewhere() {
|
||||
return ControlClient::new(owner).forget_passthrough(&req).await.map_err(rpc_error);
|
||||
}
|
||||
ctx.forget_passthrough_request(&req).map_err(rpc_error)
|
||||
}
|
||||
|
||||
/// The open tunnels the decrypt list would decrypt if their clients reconnected.
|
||||
async fn list_reconnectable_tunnels(
|
||||
ctx: ProxyCtx,
|
||||
req: tunnels::TunnelScope,
|
||||
) -> Result<Vec<tunnels::OpenTunnel>, RpcError> {
|
||||
if let Some(owner) = ctx.engine_elsewhere() {
|
||||
return ControlClient::new(owner).reconnectable_tunnels(&req).await.map_err(rpc_error);
|
||||
}
|
||||
blocking(move || ctx.reconnectable_tunnels(&req)).await
|
||||
}
|
||||
|
||||
/// Closes tunnels so their clients reconnect, decrypted as the list now says.
|
||||
async fn reconnect_tunnels(
|
||||
ctx: ProxyCtx,
|
||||
req: ReconnectTunnelsRequest,
|
||||
) -> Result<ReconnectTunnelsResponse, RpcError> {
|
||||
if let Some(owner) = ctx.engine_elsewhere() {
|
||||
return ControlClient::new(owner).reconnect_tunnels(&req).await.map_err(rpc_error);
|
||||
}
|
||||
blocking(move || {
|
||||
let closed = ctx.reconnect_tunnels(req.ids.as_deref(), &req.scope)?;
|
||||
Ok::<_, String>(ReconnectTunnelsResponse { closed })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn system_proxy_status(
|
||||
@@ -2780,8 +3070,9 @@ async fn query_values(ctx: ProxyCtx, req: QueryValuesRequest) -> Result<Vec<Stri
|
||||
}
|
||||
|
||||
fn watch_query(ctx: &ProxyCtx, req: WatchQueryRequest) -> Result<bool, RpcError> {
|
||||
let watched = req.query.filter(|q| !q.trim().is_empty() || req.hide_system);
|
||||
ctx.live_search.watch(watched.map(|q| (req.key, q, req.hide_system)));
|
||||
let hidden = query::Hidden { system: req.hide_system, encrypted: req.hide_encrypted };
|
||||
let watched = req.query.filter(|q| !q.trim().is_empty() || hidden != Default::default());
|
||||
ctx.live_search.watch(watched.map(|q| (req.key, q, hidden)));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -2886,7 +3177,8 @@ async fn count_saved_queries(
|
||||
ctx: ProxyCtx,
|
||||
req: CountSavedQueriesRequest,
|
||||
) -> Result<Vec<saved_queries::SavedQueryCount>, RpcError> {
|
||||
blocking(move || saved_queries::counts(&ctx.db()?, req.hide_system)).await
|
||||
let hidden = query::Hidden { system: req.hide_system, encrypted: req.hide_encrypted };
|
||||
blocking(move || saved_queries::counts(&ctx.db()?, hidden)).await
|
||||
}
|
||||
|
||||
// -- Event loop --
|
||||
@@ -2936,6 +3228,13 @@ const STOPPED_MID_STREAM: &str = "Yaak Proxy stopped before the stream ended";
|
||||
/// Why a tunnel still open when the engine stopped ended.
|
||||
const STOPPED_MID_TUNNEL: &str = "Yaak Proxy stopped before the connection closed";
|
||||
|
||||
/// Why a failed handshake's flow can't be saved as a request.
|
||||
pub(crate) const NOT_A_REQUEST: &str =
|
||||
"Not a request: the client's TLS handshake failed before it sent one";
|
||||
|
||||
/// The error of a flow recording a client's refusal of the proxy's certificate.
|
||||
const REJECTED_CERTIFICATE: &str = "Rejected the proxy's certificate";
|
||||
|
||||
/// A flow between its `RequestStart` and its end, with its bodies being written as they
|
||||
/// stream.
|
||||
struct InFlight {
|
||||
@@ -3043,6 +3342,15 @@ struct FlowSink {
|
||||
finished: std::cell::RefCell<FinishedFlows>,
|
||||
/// What a batch's wait is timed by.
|
||||
clock: BatchClock,
|
||||
/// The open tunnels, kept for this engine's run whether or not flows are recorded.
|
||||
tunnels: Arc<tunnels::OpenTunnels>,
|
||||
tunnel_run: u64,
|
||||
/// The engine, for its pass-through set as it changes. Weak, so the loop never keeps alive
|
||||
/// the engine whose events it waits for.
|
||||
handle: Weak<Mutex<Option<ProxyHandle>>>,
|
||||
/// The flow recorded for each refused handshake, by the engine's id for it, for the tunnels
|
||||
/// passed through after it to name.
|
||||
rejections: std::cell::RefCell<HashMap<u64, String>>,
|
||||
}
|
||||
|
||||
/// What the event loop times a batch's wait by: the time, or in tests a time that stands still,
|
||||
@@ -3168,6 +3476,7 @@ fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, sink: FlowSink) {
|
||||
}
|
||||
}
|
||||
sink.write_finished();
|
||||
sink.tunnels.end_run(sink.tunnel_run);
|
||||
// Its hold on the databases goes before the gate opens again.
|
||||
drop(sink);
|
||||
}
|
||||
@@ -3279,19 +3588,61 @@ impl FlowSink {
|
||||
},
|
||||
);
|
||||
}
|
||||
ProxyEvent::TunnelStart { id, started_at, authority, connection, client_addr } => {
|
||||
if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
ProxyEvent::TunnelStart {
|
||||
id,
|
||||
started_at,
|
||||
authority,
|
||||
host,
|
||||
port,
|
||||
connection,
|
||||
client_addr,
|
||||
reason,
|
||||
} => {
|
||||
attribution.begin(connection, client_addr);
|
||||
let recorded = recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true);
|
||||
let flow_id = recorded.then(Flow::generate_id);
|
||||
let (tunnel_reason, excluded_by, excluded_built_in, rejected_by) = match reason {
|
||||
yaak_proxy::TunnelReason::NotListed => {
|
||||
(TunnelReason::NotListed, None, false, None)
|
||||
}
|
||||
yaak_proxy::TunnelReason::Excluded { entry, built_in } => {
|
||||
(TunnelReason::Excluded, Some(entry), built_in, None)
|
||||
}
|
||||
yaak_proxy::TunnelReason::Rejected { rejected_by } => {
|
||||
let flow = self.rejections.borrow().get(&rejected_by).cloned();
|
||||
(TunnelReason::Rejected, None, false, flow)
|
||||
}
|
||||
};
|
||||
// Kept paused too, so it can be closed for a reconnect.
|
||||
let mut open = tunnels::OpenTunnel {
|
||||
id: String::new(),
|
||||
engine_id: id,
|
||||
host,
|
||||
port,
|
||||
app_name: None,
|
||||
app_bundle_id: None,
|
||||
flow_id: flow_id.clone(),
|
||||
reason: tunnel_reason,
|
||||
};
|
||||
if let Some(app) = attribution.get(connection, Duration::ZERO) {
|
||||
tunnels::set_app(&mut open, app);
|
||||
}
|
||||
self.tunnels.open(self.tunnel_run, open, connection);
|
||||
let Some(flow_id) = flow_id else {
|
||||
return;
|
||||
};
|
||||
let flow = Flow {
|
||||
id: Flow::generate_id(),
|
||||
id: flow_id,
|
||||
started_at: DateTime::<Utc>::from(started_at).naive_utc(),
|
||||
method: "CONNECT".to_string(),
|
||||
url: format!("https://{authority}"),
|
||||
client_http_version: "HTTP/1.1".to_string(),
|
||||
client_addr: Some(client_addr.to_string()),
|
||||
tunnelled: true,
|
||||
tunnel_reason: Some(tunnel_reason),
|
||||
tunnel_excluded_by: excluded_by,
|
||||
tunnel_excluded_built_in: excluded_built_in,
|
||||
tunnel_rejected_by: rejected_by,
|
||||
..Default::default()
|
||||
};
|
||||
let mut f = InFlight {
|
||||
@@ -3310,15 +3661,77 @@ impl FlowSink {
|
||||
self.write_stream_flow(&mut f);
|
||||
in_flight.insert(id, f);
|
||||
}
|
||||
ProxyEvent::TunnelEnd { id, sent, received, elapsed_ms, error } => {
|
||||
ProxyEvent::TunnelEnd {
|
||||
id,
|
||||
sent,
|
||||
received,
|
||||
elapsed_ms,
|
||||
error,
|
||||
reconnect_requested,
|
||||
} => {
|
||||
self.tunnels.close(self.tunnel_run, id);
|
||||
if let Some(mut f) = in_flight.remove(&id) {
|
||||
f.flow.request_size = sent as i64;
|
||||
f.flow.response_size = received as i64;
|
||||
f.flow.elapsed_ms = Some(clamp_ms(elapsed_ms));
|
||||
f.flow.error = error;
|
||||
f.flow.reconnect_requested = reconnect_requested;
|
||||
self.finish_flow(f);
|
||||
}
|
||||
}
|
||||
ProxyEvent::HandshakeFailed {
|
||||
id,
|
||||
started_at,
|
||||
authority,
|
||||
connection,
|
||||
client_addr,
|
||||
rejection,
|
||||
error,
|
||||
elapsed_ms,
|
||||
} => {
|
||||
// Whether or not it is recorded, the pass-through set has changed.
|
||||
let addresses = self
|
||||
.handle
|
||||
.upgrade()
|
||||
.and_then(|handle| {
|
||||
let handle = handle.lock().ok()?;
|
||||
handle.as_ref().map(ProxyHandle::passthrough)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.events.emit("passthrough_changed", &PassthroughChangedPayload { addresses });
|
||||
if !recording.lock().map(|r| r.was_on_at(started_at)).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
attribution.begin(connection, client_addr);
|
||||
let error = match &rejection {
|
||||
Some(_) => REJECTED_CERTIFICATE.to_string(),
|
||||
None => format!("TLS handshake failed: {error}"),
|
||||
};
|
||||
let flow = Flow {
|
||||
id: Flow::generate_id(),
|
||||
started_at: DateTime::<Utc>::from(started_at).naive_utc(),
|
||||
url: format!("https://{authority}"),
|
||||
client_http_version: "HTTP/1.1".to_string(),
|
||||
client_addr: Some(client_addr.to_string()),
|
||||
elapsed_ms: Some(clamp_ms(elapsed_ms)),
|
||||
tls_rejected: rejection,
|
||||
error: Some(error),
|
||||
..Default::default()
|
||||
};
|
||||
self.rejections.borrow_mut().insert(id, flow.id.clone());
|
||||
self.finish_flow(InFlight {
|
||||
flow,
|
||||
connection,
|
||||
request_body: None,
|
||||
response_body: None,
|
||||
persisted: false,
|
||||
cleared: false,
|
||||
next_seq: 0,
|
||||
dirty: false,
|
||||
last_write: None,
|
||||
graphql_scan: None,
|
||||
});
|
||||
}
|
||||
ProxyEvent::StreamStart { id, kind } => {
|
||||
if let Some(f) = in_flight.get_mut(&id) {
|
||||
let kind = StreamKind::from(kind);
|
||||
@@ -3787,7 +4200,11 @@ define_rpc! {
|
||||
add_grpc_schema(AddGrpcSchemaRequest) -> GrpcSchema,
|
||||
app_icon(AppIconRequest) -> Option<String>,
|
||||
ca_status(CaStatusRequest) -> CaStatus,
|
||||
ca_trust(CaTrustRequest) -> CaStatus,
|
||||
ca_trust(CaTrustRequest) -> ca::CaTrustResult,
|
||||
forget_passthrough(ForgetPassthroughRequest) -> ForgetPassthroughResponse,
|
||||
list_passthrough(ListPassthroughRequest) -> PassthroughResponse,
|
||||
list_reconnectable_tunnels(tunnels::TunnelScope) -> Vec<tunnels::OpenTunnel>,
|
||||
reconnect_tunnels(ReconnectTunnelsRequest) -> ReconnectTunnelsResponse,
|
||||
clear_flows(ClearFlowsRequest) -> usize,
|
||||
decode_grpc_message(DecodeGrpcMessageRequest) -> DecodedGrpcMessage,
|
||||
export_har(ExportHarRequest) -> usize,
|
||||
@@ -3796,9 +4213,9 @@ define_rpc! {
|
||||
get_proxy_state(GetProxyStateRequest) -> GetProxyStateResponse,
|
||||
helper_register(HelperRegisterRequest) -> SystemProxyStatus,
|
||||
helper_unregister(HelperUnregisterRequest) -> SystemProxyStatus,
|
||||
edit_decrypt_list(EditDecryptListRequest) -> DecryptList,
|
||||
edit_decrypt_list(EditDecryptListRequest) -> DecryptListSaved,
|
||||
list_seen(ListSeenRequest) -> flows::SeenTargets,
|
||||
set_decrypt_list(SetDecryptListRequest) -> DecryptList,
|
||||
set_decrypt_list(SetDecryptListRequest) -> DecryptListSaved,
|
||||
list_workspaces(ListWorkspacesRequest) -> ListWorkspacesResponse,
|
||||
preview_save(PreviewSaveRequest) -> SavePreview,
|
||||
reflect_grpc_host(ReflectGrpcHostRequest) -> GrpcSchema,
|
||||
@@ -3823,6 +4240,7 @@ define_rpc! {
|
||||
flows_removed(FlowsRemovedPayload),
|
||||
message_write(MessagePayload),
|
||||
model_write(ModelPayload),
|
||||
passthrough_changed(PassthroughChangedPayload),
|
||||
proxy_state_changed(ProxyStatePayload),
|
||||
saved_queries_changed(SavedQueriesChangedPayload),
|
||||
}
|
||||
@@ -3832,6 +4250,151 @@ define_rpc! {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// While set, [`ProxyCtx::reconnect_tunnels`] says when it has looked up what to close and
|
||||
/// waits, up to a time, to be let go, so a test can restart the engine in between.
|
||||
static BETWEEN_LOOKUP_AND_CLOSE: Mutex<
|
||||
Option<(std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>)>,
|
||||
> = Mutex::new(None);
|
||||
|
||||
pub(super) fn pause_between_lookup_and_close() {
|
||||
let pause = BETWEEN_LOOKUP_AND_CLOSE.lock().unwrap().take();
|
||||
if let Some((reached, go)) = pause {
|
||||
let _ = reached.send(());
|
||||
let _ = go.recv_timeout(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// A CONNECT tunnel through the engine on `port` to `target`, its bytes crossing both ways.
|
||||
async fn open_tunnel(port: u16, target: &str) -> tokio::net::TcpStream {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
||||
tcp.write_all(connect.as_bytes()).await.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
head.push(tcp.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(head.starts_with(b"HTTP/1.1 200"), "{}", String::from_utf8_lossy(&head));
|
||||
assert!(still_open(&mut tcp).await, "the tunnel did not relay");
|
||||
tcp
|
||||
}
|
||||
|
||||
/// Whether `tcp` still relays: a ping comes back, rather than the connection ending.
|
||||
async fn still_open(tcp: &mut tokio::net::TcpStream) -> bool {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let mut echoed = [0u8; 4];
|
||||
tcp.write_all(b"ping").await.is_ok() && tcp.read_exact(&mut echoed).await.is_ok()
|
||||
}
|
||||
|
||||
async fn spawn_echo() -> u16 {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut tcp, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut read, mut write) = tcp.split();
|
||||
let _ = tokio::io::copy(&mut read, &mut write).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
async fn wait_for_open_tunnels(ctx: &ProxyCtx, n: usize) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while ctx.tunnels.list().len() != n {
|
||||
assert!(std::time::Instant::now() < deadline, "{:?}", ctx.tunnels.list());
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine restart between finding what to close and closing it can't close a tunnel of
|
||||
/// the new run, though the new run numbers its tunnels from 1 again: Greptile's "Reconnect
|
||||
/// closes the wrong tunnel" on #835.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_restart_between_lookup_and_close_closes_nothing_of_the_new_run() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings =
|
||||
ProxySettings { port: 0, system_proxy_at_launch: false, ..Default::default() };
|
||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||
let (events, _rx) = RpcEventEmitter::new();
|
||||
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||
ctx.start().unwrap();
|
||||
let echo = spawn_echo().await;
|
||||
// The address the echo listens on, not `localhost`, which Windows tries as `::1` first
|
||||
// and takes seconds to give up on.
|
||||
let target = format!("127.0.0.1:{echo}");
|
||||
let host = "host:127.0.0.1".to_string();
|
||||
|
||||
// Run 1: one tunnel, listed as reconnectable once the list names its host.
|
||||
let mut old = open_tunnel(ctx.port().unwrap(), &target).await;
|
||||
ctx.edit_decrypt_list(std::slice::from_ref(&host), &[]).unwrap();
|
||||
wait_for_open_tunnels(&ctx, 1).await;
|
||||
let listed = ctx.reconnectable_tunnels(&tunnels::TunnelScope::default()).unwrap();
|
||||
let ids: Vec<String> = listed.iter().map(|t| t.id.clone()).collect();
|
||||
assert_eq!((ids.len(), listed[0].engine_id), (1, 1));
|
||||
|
||||
// Reconnect pauses once it has looked up what to close.
|
||||
let (reached_tx, reached_rx) = std::sync::mpsc::channel();
|
||||
let (go_tx, go_rx) = std::sync::mpsc::channel();
|
||||
*BETWEEN_LOOKUP_AND_CLOSE.lock().unwrap() = Some((reached_tx, go_rx));
|
||||
let reconnecting = {
|
||||
let ctx = ctx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ctx.reconnect_tunnels(Some(&ids), &tunnels::TunnelScope::default())
|
||||
})
|
||||
};
|
||||
tokio::task::spawn_blocking(move || reached_rx.recv().unwrap()).await.unwrap();
|
||||
|
||||
// Then the engine restarts, and the new run's first tunnel is number 1 too. While the
|
||||
// reconnect holds the engine's lock, a restart would wait for it, so it is let go
|
||||
// first; were the lock not held, the restart and the new tunnel land in between.
|
||||
let holds_engine = ctx.handle.try_lock().is_err();
|
||||
if holds_engine {
|
||||
go_tx.send(()).unwrap();
|
||||
}
|
||||
let restarting = ctx.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
restarting.stop().unwrap();
|
||||
restarting.edit_decrypt_list(&[], std::slice::from_ref(&host)).unwrap();
|
||||
restarting.start().unwrap();
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut new = open_tunnel(ctx.port().unwrap(), &target).await;
|
||||
wait_for_open_tunnels(&ctx, 1).await;
|
||||
assert_eq!(ctx.tunnels.list()[0].engine_id, 1, "the new run's first tunnel");
|
||||
if !holds_engine {
|
||||
go_tx.send(()).unwrap();
|
||||
}
|
||||
|
||||
let closed = reconnecting.await.unwrap().unwrap();
|
||||
assert!(still_open(&mut new).await, "the new run's tunnel was closed");
|
||||
assert!(!still_open(&mut old).await, "the old run's tunnel outlived its engine");
|
||||
assert!(holds_engine, "looked up without the engine's lock held");
|
||||
assert_eq!(closed, 1, "the old run's tunnel, closed in its own run");
|
||||
}
|
||||
|
||||
/// An owner from before the count answers a forwarded save without it.
|
||||
#[test]
|
||||
fn a_saved_list_from_an_older_owner_reads_without_its_count() {
|
||||
let saved: DecryptListSaved = serde_json::from_str(r#"{"entries": []}"#).unwrap();
|
||||
assert_eq!((saved.list.entries.len(), saved.still_encrypted), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_addresses_as_the_engine_keys_them() {
|
||||
assert_eq!(passthrough_address("a.com").as_deref(), Some("a.com:443"));
|
||||
assert_eq!(passthrough_address(" a.com:8443 ").as_deref(), Some("a.com:8443"));
|
||||
assert_eq!(passthrough_address("[::1]").as_deref(), Some("[::1]:443"));
|
||||
assert_eq!(passthrough_address("[::1]:8443").as_deref(), Some("[::1]:8443"));
|
||||
assert_eq!(passthrough_address("::1").as_deref(), Some("[::1]:443"));
|
||||
assert_eq!(passthrough_address(""), None);
|
||||
assert_eq!(passthrough_address("a.com:http"), None);
|
||||
assert_eq!(passthrough_address("[]"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_the_password_prompt_parked_keep_it_over_the_helper() {
|
||||
use yaak_proxy_helper::networksetup::{ProxySetting, ServiceProxy, Snapshot};
|
||||
@@ -3899,6 +4462,10 @@ mod tests {
|
||||
recording: ctx.recording.clone(),
|
||||
finished: Default::default(),
|
||||
clock: BatchClock::Real,
|
||||
tunnels: ctx.tunnels.clone(),
|
||||
tunnel_run: ctx.tunnels.begin_run(None),
|
||||
handle: Arc::downgrade(&ctx.handle),
|
||||
rejections: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,11 +110,58 @@ pub struct Flow {
|
||||
/// client rejected the proxy's certificate: method `CONNECT`, its URL the host, its sizes
|
||||
/// the bytes each way, and no headers or bodies. Open until `elapsedMs` or `error` is set.
|
||||
pub tunnelled: bool,
|
||||
/// Why a tunnelled flow was not decrypted. None for other flows, and for tunnels recorded
|
||||
/// before the reason was.
|
||||
pub tunnel_reason: Option<TunnelReason>,
|
||||
/// For a tunnel the decrypt list excluded, the exclusion that kept it out, as stored:
|
||||
/// `!host:*.apple.com`.
|
||||
pub tunnel_excluded_by: Option<String>,
|
||||
/// That exclusion is one of the built-in ones.
|
||||
pub tunnel_excluded_built_in: bool,
|
||||
/// For a tunnel passed through after a client rejected the proxy's certificate, the id of
|
||||
/// the flow of that refused handshake, when it was recorded.
|
||||
pub tunnel_rejected_by: Option<String>,
|
||||
/// The proxy closed this tunnel on request, so its client would reconnect.
|
||||
pub reconnect_requested: bool,
|
||||
/// A client's refused TLS handshake, recorded as its host and port began to be passed
|
||||
/// through: how it refused, its alert (`unknown_ca`, `bad_certificate`,
|
||||
/// `certificate_unknown`, ...) or `hang_up`. Such a flow has no method, its URL is
|
||||
/// `https://<authority>`, and its error says it rejected the certificate. A handshake that
|
||||
/// failed otherwise is recorded the same way with this None and the failure as its error.
|
||||
pub tls_rejected: Option<String>,
|
||||
/// An agent read the flow through the control API: its detail, a body or its messages.
|
||||
/// Never written by an upsert, which keeps what is stored.
|
||||
pub agent_read: bool,
|
||||
}
|
||||
|
||||
/// Why a tunnel was passed through undecrypted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub enum TunnelReason {
|
||||
/// No entry of the decrypt list named it.
|
||||
NotListed,
|
||||
/// An entry named it, and an exclusion kept it out: `tunnelExcludedBy`.
|
||||
Excluded,
|
||||
/// The list decrypts it, but a client rejected the proxy's certificate for its host and
|
||||
/// port, which are passed through until the proxy restarts or forgets them.
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl TunnelReason {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotListed => "not_listed",
|
||||
Self::Excluded => "excluded",
|
||||
Self::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[Self::NotListed, Self::Excluded, Self::Rejected].into_iter().find(|r| r.as_str() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The streaming protocol a flow carries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -182,6 +229,11 @@ impl Flow {
|
||||
}
|
||||
}
|
||||
|
||||
/// A client's failed TLS handshake, recorded without a request: nothing to save or replay.
|
||||
pub fn is_handshake_only(&self) -> bool {
|
||||
self.method.is_empty()
|
||||
}
|
||||
|
||||
pub fn truncated(&self, body: FlowBody) -> bool {
|
||||
match body {
|
||||
FlowBody::Request => self.request_truncated,
|
||||
@@ -278,6 +330,12 @@ impl UpsertModelInfo for Flow {
|
||||
(GrpcMessage, self.grpc_message.into()),
|
||||
(GraphqlOperation, self.graphql_operation.into()),
|
||||
(Tunnelled, self.tunnelled.into()),
|
||||
(FlowIden::TunnelReason, self.tunnel_reason.map(|r| r.as_str()).into()),
|
||||
(TunnelExcludedBy, self.tunnel_excluded_by.into()),
|
||||
(TunnelExcludedBuiltIn, self.tunnel_excluded_built_in.into()),
|
||||
(TunnelRejectedBy, self.tunnel_rejected_by.into()),
|
||||
(ReconnectRequested, self.reconnect_requested.into()),
|
||||
(TlsRejected, self.tls_rejected.into()),
|
||||
(ElapsedConnectMs, self.elapsed_connect_ms.into()),
|
||||
(ElapsedTlsMs, self.elapsed_tls_ms.into()),
|
||||
(ElapsedRequestMs, self.elapsed_request_ms.into()),
|
||||
@@ -322,6 +380,12 @@ impl UpsertModelInfo for Flow {
|
||||
FlowIden::GrpcMessage,
|
||||
FlowIden::GraphqlOperation,
|
||||
FlowIden::Tunnelled,
|
||||
FlowIden::TunnelReason,
|
||||
FlowIden::TunnelExcludedBy,
|
||||
FlowIden::TunnelExcludedBuiltIn,
|
||||
FlowIden::TunnelRejectedBy,
|
||||
FlowIden::ReconnectRequested,
|
||||
FlowIden::TlsRejected,
|
||||
FlowIden::ElapsedConnectMs,
|
||||
FlowIden::ElapsedTlsMs,
|
||||
FlowIden::ElapsedRequestMs,
|
||||
@@ -381,6 +445,14 @@ impl UpsertModelInfo for Flow {
|
||||
grpc_message: added_column(r, "grpc_message")?,
|
||||
graphql_operation: added_column(r, "graphql_operation")?,
|
||||
tunnelled: added_column(r, "tunnelled")?.unwrap_or_default(),
|
||||
tunnel_reason: added_column::<String>(r, "tunnel_reason")?
|
||||
.and_then(|r| TunnelReason::parse(&r)),
|
||||
tunnel_excluded_by: added_column(r, "tunnel_excluded_by")?,
|
||||
tunnel_excluded_built_in: added_column(r, "tunnel_excluded_built_in")?
|
||||
.unwrap_or_default(),
|
||||
tunnel_rejected_by: added_column(r, "tunnel_rejected_by")?,
|
||||
reconnect_requested: added_column(r, "reconnect_requested")?.unwrap_or_default(),
|
||||
tls_rejected: added_column(r, "tls_rejected")?,
|
||||
agent_read: added_column(r, "agent_read")?.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ pub const FLAGS: &[&str] = &[
|
||||
"error",
|
||||
"pending",
|
||||
"system",
|
||||
"tunnelled",
|
||||
"encrypted",
|
||||
"rejected",
|
||||
"saved",
|
||||
"read",
|
||||
"loop",
|
||||
@@ -40,6 +41,10 @@ pub const FLAGS: &[&str] = &[
|
||||
"graphql",
|
||||
];
|
||||
|
||||
/// Other names `@is:` takes for a flag, kept for queries written with them: `tunnelled` is
|
||||
/// `encrypted`'s name from before.
|
||||
const FLAG_ALIASES: &[(&str, &str)] = &[("tunnelled", "encrypted")];
|
||||
|
||||
/// Flags `@is:` will take once flows have what they need. Valid, matching nothing.
|
||||
const FLAGS_NOT_YET: &[&str] = &["marked"];
|
||||
|
||||
@@ -84,7 +89,12 @@ pub fn schema() -> &'static Schema {
|
||||
field("status", number(), "Response status", "@status:4xx @status:>=400"),
|
||||
field("type", text(), "Content type", "@type:json"),
|
||||
field("app", text(), "App or program", "@app:Slack"),
|
||||
field("is", text(), "error, pending, system, …", "@is:error -@is:system"),
|
||||
field(
|
||||
"is",
|
||||
text(),
|
||||
"error, pending, system, encrypted, rejected, …",
|
||||
"@is:error -@is:system @is:encrypted",
|
||||
),
|
||||
map("header", "A header on either side", "@header.content-type:json"),
|
||||
map("req.header", "A request header", "@req.header.accept:*"),
|
||||
map("res.header", "A response header", "@res.header.x-cache:hit"),
|
||||
@@ -130,30 +140,53 @@ const TIME_UNITS: &[(&str, f64)] = &[
|
||||
|
||||
// -- Preparing --
|
||||
|
||||
/// A query prepared for running: its shorthands written out, System left out when hidden.
|
||||
/// What a query leaves out unless it names it: System's flows, encrypted connections.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Hidden {
|
||||
pub system: bool,
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// System alone hidden, or nothing.
|
||||
impl From<bool> for Hidden {
|
||||
fn from(system: bool) -> Self {
|
||||
Self { system, encrypted: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// A query prepared for running: its shorthands written out, hidden flows left out.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FlowQuery {
|
||||
root: Option<Node>,
|
||||
}
|
||||
|
||||
impl FlowQuery {
|
||||
/// `text` as of now. With `hide_system`, macOS's own daemons are left out, unless the text
|
||||
/// says `@is:system` itself.
|
||||
pub fn new(text: &str, hide_system: bool) -> Self {
|
||||
Self::at(text, hide_system, Utc::now())
|
||||
/// `text` as of now. With `hidden.system`, macOS's own daemons are left out, unless the text
|
||||
/// says `@is:system` itself; with `hidden.encrypted`, connections tunnelled undecrypted,
|
||||
/// unless it says `@is:encrypted` (or `@is:tunnelled`).
|
||||
pub fn new(text: &str, hidden: impl Into<Hidden>) -> Self {
|
||||
Self::at(text, hidden, Utc::now())
|
||||
}
|
||||
|
||||
/// [`Self::new`], with `now` for the times `@time` counts back from.
|
||||
pub fn at(text: &str, hide_system: bool, now: DateTime<Utc>) -> Self {
|
||||
pub fn at(text: &str, hidden: impl Into<Hidden>, now: DateTime<Utc>) -> Self {
|
||||
let hidden = hidden.into();
|
||||
let parsed = yaak_query::parse(text);
|
||||
let mentions_system = parsed.as_ref().is_some_and(mentions_system);
|
||||
let mentions = |flags: &[&str]| parsed.as_ref().is_some_and(|r| mentions_flag(r, flags));
|
||||
let hide: Vec<&str> = [
|
||||
(hidden.system && !mentions(&["system"])).then_some("system"),
|
||||
(hidden.encrypted && !mentions(&["encrypted", "tunnelled"])).then_some("encrypted"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
let mut root = parsed.map(|root| prepare(&root, now, &mut Vec::new()));
|
||||
if hide_system && !mentions_system {
|
||||
for flag in hide {
|
||||
let span = Span::default();
|
||||
let not_system = Node::Not { node: Box::new(is_flag("system", span)), span };
|
||||
let not_flag = Node::Not { node: Box::new(is_flag(flag, span)), span };
|
||||
root = Some(match root {
|
||||
None => not_system,
|
||||
Some(root) => Node::And { children: vec![root, not_system], span },
|
||||
None => not_flag,
|
||||
Some(root) => Node::And { children: vec![root, not_flag], span },
|
||||
});
|
||||
}
|
||||
Self { root }
|
||||
@@ -197,15 +230,15 @@ fn is_flag(flag: &str, span: Span) -> Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// The query names `@is:system`, either way, so hiding System leaves it be.
|
||||
fn mentions_system(root: &Node) -> bool {
|
||||
/// The query names `@is:` one of `flags`, either way, so hiding it leaves it be.
|
||||
fn mentions_flag(root: &Node, flags: &[&str]) -> bool {
|
||||
let mut found = false;
|
||||
root.walk(&mut |node| {
|
||||
if let Node::Field { path, value, .. } = node
|
||||
&& path.len() == 1
|
||||
&& path[0].eq_ignore_ascii_case("is")
|
||||
&& let FieldValue::Text { text, .. } | FieldValue::Wildcard { pattern: text } = value
|
||||
&& text.eq_ignore_ascii_case("system")
|
||||
&& flags.iter().any(|f| text.eq_ignore_ascii_case(f))
|
||||
{
|
||||
found = true;
|
||||
}
|
||||
@@ -295,7 +328,10 @@ fn prepare(node: &Node, now: DateTime<Utc>, issues: &mut Vec<Issue>) -> Node {
|
||||
with(FieldValue::Wildcard { pattern: text.clone() })
|
||||
}
|
||||
("is", FieldValue::Text { text, .. }) if !text.is_empty() => {
|
||||
let flag = text.to_lowercase();
|
||||
let mut flag = text.to_lowercase();
|
||||
if let Some((_, to)) = FLAG_ALIASES.iter().find(|(alias, _)| *alias == flag) {
|
||||
flag = to.to_string();
|
||||
}
|
||||
if FLAGS.contains(&flag.as_str()) {
|
||||
with(FieldValue::Wildcard { pattern: flag })
|
||||
} else if FLAGS_NOT_YET.contains(&flag.as_str()) {
|
||||
@@ -441,22 +477,22 @@ pub struct LiveSearch(RwLock<Option<Watched>>);
|
||||
struct Watched {
|
||||
key: String,
|
||||
text: String,
|
||||
hide_system: bool,
|
||||
hidden: Hidden,
|
||||
}
|
||||
|
||||
impl LiveSearch {
|
||||
/// Matches flows against `text` from now on, their events naming `key`; None stops.
|
||||
pub fn watch(&self, watched: Option<(String, String, bool)>) {
|
||||
let watched = watched.map(|(key, text, hide_system)| Watched { key, text, hide_system });
|
||||
pub fn watch(&self, watched: Option<(String, String, Hidden)>) {
|
||||
let watched = watched.map(|(key, text, hidden)| Watched { key, text, hidden });
|
||||
*self.0.write().unwrap_or_else(PoisonError::into_inner) = watched;
|
||||
}
|
||||
|
||||
/// `flow` matched against the search shown, if any.
|
||||
pub fn check(&self, flow: &Flow) -> Option<SearchMatch> {
|
||||
let watched = self.0.read().unwrap_or_else(PoisonError::into_inner);
|
||||
let Watched { key, text, hide_system } = watched.as_ref()?;
|
||||
let Watched { key, text, hidden } = watched.as_ref()?;
|
||||
// Prepared again for each flow, so `@time:<5m` counts back from now.
|
||||
let matches = FlowQuery::new(text, *hide_system).matches(flow);
|
||||
let matches = FlowQuery::new(text, *hidden).matches(flow);
|
||||
Some(SearchMatch { key: key.clone(), matches })
|
||||
}
|
||||
|
||||
@@ -812,7 +848,8 @@ fn flags(flow: &Flow) -> Vec<&'static str> {
|
||||
&& flow.elapsed_ms.is_none()
|
||||
}
|
||||
"system" => is_system(flow.client_bundle_id.as_deref(), flow.client_path.as_deref()),
|
||||
"tunnelled" => flow.tunnelled,
|
||||
"encrypted" => flow.tunnelled,
|
||||
"rejected" => flow.tls_rejected.is_some(),
|
||||
"saved" => flow.saved_request_id.is_some(),
|
||||
"read" => flow.agent_read,
|
||||
"loop" => flow.status == Some(508),
|
||||
@@ -970,7 +1007,11 @@ impl FlowColumns {
|
||||
return name.to_string();
|
||||
}
|
||||
match name {
|
||||
"cancelled" | "tunnelled" | "agent_read" => "0".into(),
|
||||
"cancelled"
|
||||
| "tunnelled"
|
||||
| "agent_read"
|
||||
| "reconnect_requested"
|
||||
| "tunnel_excluded_built_in" => "0".into(),
|
||||
_ => "NULL".into(),
|
||||
}
|
||||
}
|
||||
@@ -1145,7 +1186,8 @@ impl Sql<'_> {
|
||||
c("cancelled")
|
||||
),
|
||||
"system" => format!("is_system({}, {})", c("client_bundle_id"), c("client_path")),
|
||||
"tunnelled" => c("tunnelled"),
|
||||
"encrypted" => c("tunnelled"),
|
||||
"rejected" => format!("{} IS NOT NULL", c("tls_rejected")),
|
||||
"saved" => format!("{} IS NOT NULL", c("saved_request_id")),
|
||||
"read" => c("agent_read"),
|
||||
"loop" => "status = 508".into(),
|
||||
|
||||
@@ -381,6 +381,53 @@ fn headers_on_either_side_or_one() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `@is:encrypted` finds tunnelled connections, as `@is:tunnelled` did and still does, and
|
||||
/// `@is:rejected` refused handshakes; hiding encrypted connections leaves them out unless the
|
||||
/// query names them.
|
||||
#[test]
|
||||
fn encrypted_and_rejected() {
|
||||
let rejected = Flow {
|
||||
id: "fl_rejected".into(),
|
||||
url: "https://my.1password.com".into(),
|
||||
client_http_version: "HTTP/1.1".into(),
|
||||
tls_rejected: Some("unknown_ca".into()),
|
||||
error: Some("Rejected the proxy's certificate".into()),
|
||||
elapsed_ms: Some(3),
|
||||
started_at: (now() - Duration::minutes(5)).naive_utc(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut flows = fixture();
|
||||
flows.push(rejected);
|
||||
let (_dir, db, flows) = db_of(flows);
|
||||
let find = |query: &str, hidden: Hidden| {
|
||||
let prepared = FlowQuery::at(query, hidden, now());
|
||||
let sql = sql_ids(&db, &prepared);
|
||||
assert_eq!(sql, memory_ids(&flows, &prepared), "the two disagree on {query:?}");
|
||||
sql
|
||||
};
|
||||
let none = Hidden::default();
|
||||
let encrypted = Hidden { system: false, encrypted: true };
|
||||
assert_eq!(find("@is:encrypted", none), ids(&["fl_tunnel"]));
|
||||
assert_eq!(find("@is:tunnelled", none), ids(&["fl_tunnel"]));
|
||||
assert_eq!(find("@is:TUNNELLED", none), ids(&["fl_tunnel"]));
|
||||
assert_eq!(find("@is:rejected", none), ids(&["fl_rejected"]));
|
||||
assert!(find("@is:error", none).contains(&"fl_rejected".to_string()));
|
||||
// Hidden, unless named.
|
||||
let all = find("", none);
|
||||
let shown = find("", encrypted);
|
||||
assert_eq!(shown.len(), all.len() - 1);
|
||||
assert!(!shown.contains(&"fl_tunnel".to_string()));
|
||||
assert_eq!(find("@is:encrypted", encrypted), ids(&["fl_tunnel"]));
|
||||
assert_eq!(find("@is:tunnelled", encrypted), ids(&["fl_tunnel"]));
|
||||
assert_eq!(find("bank", encrypted), Vec::<String>::new());
|
||||
// Both hidden.
|
||||
let both = find("", Hidden { system: true, encrypted: true });
|
||||
assert_eq!(both.len(), all.len() - 2);
|
||||
assert_eq!(diagnostics("@is:encrypted @is:rejected @is:tunnelled"), vec![]);
|
||||
assert!(FLAGS.contains(&"encrypted") && FLAGS.contains(&"rejected"));
|
||||
assert!(!FLAGS.contains(&"tunnelled"), "an alias, not offered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags() {
|
||||
assert_eq!(finds("@is:system"), ids(&["fl_daemon"]));
|
||||
@@ -701,6 +748,10 @@ fn sample(count: usize) -> Vec<Flow> {
|
||||
cancelled: rng.chance(10),
|
||||
saved_request_id: rng.chance(15).then(|| "rq_x".to_string()),
|
||||
tunnelled,
|
||||
tls_rejected: rng
|
||||
.chance(10)
|
||||
.then(|| rng.pick(&["unknown_ca", "hang_up"]).to_string())
|
||||
.filter(|_| !tunnelled),
|
||||
agent_read: rng.chance(20),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -888,6 +939,12 @@ const BATTERY: &[&str] = &[
|
||||
"@nope:x",
|
||||
"-@nope:x",
|
||||
"@status:4xx OR @is:tunnelled",
|
||||
"@is:encrypted",
|
||||
"-@is:encrypted",
|
||||
"@is:rejected",
|
||||
"@is:rejected OR @is:encrypted",
|
||||
"-@is:rejected @is:error",
|
||||
"@is:e*",
|
||||
"(@status:4xx OR @is:error) -@method:post",
|
||||
"-(@status:* OR @is:pending)",
|
||||
"github -@host:github.com",
|
||||
@@ -903,8 +960,9 @@ fn the_database_and_the_evaluator_find_the_same_flows() {
|
||||
let (_dir, db, flows) = db_of(sample(400));
|
||||
let mut checked = 0;
|
||||
for query in BATTERY {
|
||||
for hide_system in [false, true] {
|
||||
let prepared = FlowQuery::at(query, hide_system, now());
|
||||
for (system, encrypted) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let hidden = Hidden { system, encrypted };
|
||||
let prepared = FlowQuery::at(query, hidden, now());
|
||||
let sql = sql_ids(&db, &prepared);
|
||||
let memory = memory_ids(&flows, &prepared);
|
||||
if sql != memory {
|
||||
@@ -915,14 +973,14 @@ fn the_database_and_the_evaluator_find_the_same_flows() {
|
||||
.or(only_memory.first())
|
||||
.map(|id| flows.iter().find(|f| &&f.id == id).cloned());
|
||||
panic!(
|
||||
"{query:?} (hide_system {hide_system}): only in SQL {only_sql:?}, \
|
||||
"{query:?} ({hidden:?}): only in SQL {only_sql:?}, \
|
||||
only in memory {only_memory:?}; e.g. {example:#?}"
|
||||
);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, BATTERY.len() * 2);
|
||||
assert_eq!(checked, BATTERY.len() * 4);
|
||||
// The battery means something only if the sample spreads its answers.
|
||||
let spread = BATTERY
|
||||
.iter()
|
||||
@@ -938,9 +996,9 @@ fn live_search_matches_as_the_list_does() {
|
||||
let live = LiveSearch::default();
|
||||
let flow = fixture().remove(0);
|
||||
assert_eq!(live.check(&flow), None);
|
||||
live.watch(Some(("k1".into(), "@status:2xx @host:*.github.com".into(), false)));
|
||||
live.watch(Some(("k1".into(), "@status:2xx @host:*.github.com".into(), false.into())));
|
||||
assert_eq!(live.check(&flow), Some(SearchMatch { key: "k1".into(), matches: true }));
|
||||
live.watch(Some(("k2".into(), "@status:4xx".into(), false)));
|
||||
live.watch(Some(("k2".into(), "@status:4xx".into(), false.into())));
|
||||
let payload = live.payload(flow, true);
|
||||
assert_eq!(payload.search, Some(SearchMatch { key: "k2".into(), matches: false }));
|
||||
live.watch(None);
|
||||
|
||||
@@ -111,6 +111,9 @@ impl ClientDb {
|
||||
body: &DecodedBody,
|
||||
target: &SaveFlowRequest,
|
||||
) -> Result<SaveFlowResponse, String> {
|
||||
if flow.is_handshake_only() {
|
||||
return Err(crate::NOT_A_REQUEST.to_string());
|
||||
}
|
||||
let db = self.query_manager.connect();
|
||||
db.get_workspace(&target.workspace_id)
|
||||
.map_err(|_| format!("No workspace {} in Yaak", target.workspace_id))?;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::db::ProxyQueryManager;
|
||||
use crate::flows::{self, FlowFilter};
|
||||
use crate::query::Hidden;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use yaak_database::generate_prefixed_id;
|
||||
@@ -115,13 +116,17 @@ pub fn delete(db: &ProxyQueryManager, id: &str) -> Result<bool, String> {
|
||||
.map_err(|e| failed("delete", e))
|
||||
}
|
||||
|
||||
/// Each saved query's count of flows, with System's left out when `hide_system`, as the list
|
||||
/// would show it, read from one snapshot.
|
||||
pub fn counts(db: &ProxyQueryManager, hide_system: bool) -> Result<Vec<SavedQueryCount>, String> {
|
||||
/// Each saved query's count of flows, with what `hidden` says left out, as the list would show
|
||||
/// it, read from one snapshot.
|
||||
pub fn counts(db: &ProxyQueryManager, hidden: Hidden) -> Result<Vec<SavedQueryCount>, String> {
|
||||
let saved = list(db)?;
|
||||
let filters: Vec<FlowFilter> = saved
|
||||
.iter()
|
||||
.map(|s| FlowFilter { hide_system, ..FlowFilter::search(s.query.clone()) })
|
||||
.map(|s| FlowFilter {
|
||||
hide_system: hidden.system,
|
||||
hide_encrypted: hidden.encrypted,
|
||||
..FlowFilter::search(s.query.clone())
|
||||
})
|
||||
.collect();
|
||||
let counts = flows::count_each(db, &filters)?;
|
||||
Ok(saved
|
||||
@@ -165,7 +170,7 @@ mod tests {
|
||||
["Errors", "Fine"]
|
||||
);
|
||||
|
||||
let counts = counts(&db, false).unwrap();
|
||||
let counts = counts(&db, false.into()).unwrap();
|
||||
assert_eq!(
|
||||
counts,
|
||||
vec![
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
use yaak_proxy::{BUILT_IN_EXCLUSIONS, DEFAULT_CAPTURE_BODY_LIMIT, DecryptEntry, DecryptPolicy};
|
||||
use yaak_proxy::{
|
||||
BUILT_IN_EXCLUSIONS, BuiltInKind, DEFAULT_CAPTURE_BODY_LIMIT, DecryptEntry, DecryptPolicy,
|
||||
};
|
||||
|
||||
/// `settings.json` in the proxy data directory. Missing keys take their defaults.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -69,6 +71,30 @@ pub struct DecryptListEntry {
|
||||
pub entry: String,
|
||||
/// Shipped with the app: an exclusion of Apple's services or a pinned app's hosts.
|
||||
pub built_in: bool,
|
||||
/// Why a built-in exclusion ships. None for the user's own entries.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub kind: Option<ExclusionKind>,
|
||||
}
|
||||
|
||||
/// Why a built-in exclusion ships.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub enum ExclusionKind {
|
||||
/// Apple's own services, which macOS daemons reach with pinned certificates.
|
||||
Apple,
|
||||
/// The hosts of an app that pins its certificates.
|
||||
Pinned,
|
||||
}
|
||||
|
||||
impl From<BuiltInKind> for ExclusionKind {
|
||||
fn from(kind: BuiltInKind) -> Self {
|
||||
match kind {
|
||||
BuiltInKind::Apple => Self::Apple,
|
||||
BuiltInKind::Pinned => Self::Pinned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What HTTPS is decrypted: the user's entries in order, then the built-in exclusions not
|
||||
@@ -115,12 +141,19 @@ impl ProxySettings {
|
||||
|
||||
/// The user's entries, then the built-in exclusions they kept.
|
||||
pub fn decrypt_list(&self) -> DecryptList {
|
||||
let user =
|
||||
self.decrypt.iter().map(|e| DecryptListEntry { entry: e.clone(), built_in: false });
|
||||
let user = self.decrypt.iter().map(|e| DecryptListEntry {
|
||||
entry: e.clone(),
|
||||
built_in: false,
|
||||
kind: None,
|
||||
});
|
||||
let built_in = BUILT_IN_EXCLUSIONS
|
||||
.iter()
|
||||
.filter(|e| !self.decrypt_built_ins_removed.iter().any(|r| r == *e))
|
||||
.map(|e| DecryptListEntry { entry: e.to_string(), built_in: true });
|
||||
.filter(|(e, _)| !self.decrypt_built_ins_removed.iter().any(|r| r == e))
|
||||
.map(|(e, kind)| DecryptListEntry {
|
||||
entry: e.to_string(),
|
||||
built_in: true,
|
||||
kind: Some((*kind).into()),
|
||||
});
|
||||
DecryptList { entries: user.chain(built_in).collect() }
|
||||
}
|
||||
|
||||
@@ -142,8 +175,8 @@ impl ProxySettings {
|
||||
self.decrypt = user;
|
||||
self.decrypt_built_ins_removed = BUILT_IN_EXCLUSIONS
|
||||
.iter()
|
||||
.filter(|e| !kept_built_ins.iter().any(|k| k == *e))
|
||||
.map(|e| e.to_string())
|
||||
.filter(|(e, _)| !kept_built_ins.iter().any(|k| k == e))
|
||||
.map(|(e, _)| e.to_string())
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
@@ -346,7 +379,10 @@ mod tests {
|
||||
];
|
||||
// Every built-in exclusion but zoom.us, kept.
|
||||
entries.extend(
|
||||
BUILT_IN_EXCLUSIONS.iter().filter(|e| **e != "!host:*.zoom.us").map(|e| e.to_string()),
|
||||
BUILT_IN_EXCLUSIONS
|
||||
.iter()
|
||||
.filter(|(e, _)| *e != "!host:*.zoom.us")
|
||||
.map(|(e, _)| e.to_string()),
|
||||
);
|
||||
settings.set_decrypt_list(&entries).unwrap();
|
||||
assert_eq!(settings.decrypt, ["host:api.example.com", "*", "app:Google Chrome"]);
|
||||
@@ -354,9 +390,13 @@ mod tests {
|
||||
let list = settings.decrypt_list();
|
||||
assert_eq!(
|
||||
list.entries[0],
|
||||
DecryptListEntry { entry: "host:api.example.com".into(), built_in: false }
|
||||
DecryptListEntry { entry: "host:api.example.com".into(), built_in: false, kind: None }
|
||||
);
|
||||
assert!(list.entries[3].built_in);
|
||||
assert_eq!(list.entries[3].entry, "!host:*.apple.com");
|
||||
assert_eq!(list.entries[3].kind, Some(ExclusionKind::Apple));
|
||||
let onepassword = list.entries.iter().find(|e| e.entry == "!host:*.1password.com");
|
||||
assert_eq!(onepassword.unwrap().kind, Some(ExclusionKind::Pinned));
|
||||
assert!(!list.entries.iter().any(|e| e.entry == "!host:*.zoom.us"));
|
||||
|
||||
// Written again, a built-in exclusion is restored.
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! The HTTPS connections the engine is passing through undecrypted right now, kept whether or
|
||||
//! not flows are recorded, since a tunnel opened while paused has no flow. Each is added as its
|
||||
//! `TunnelStart` is handled and removed at its `TunnelEnd`, never on the decrypted path. From
|
||||
//! them and the decrypt list come the tunnels that would be decrypted if their client
|
||||
//! reconnected, which the app may close so it does.
|
||||
|
||||
use crate::attribution::{Attribution, ClientApp};
|
||||
use crate::models::TunnelReason;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
use ts_rs::TS;
|
||||
use yaak_proxy::{DecryptApp, DecryptEntry, DecryptPolicy, DecryptQuery};
|
||||
|
||||
/// An open tunnel, as the app is told of it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct OpenTunnel {
|
||||
/// Its id for closing it, unique to this run of the engine, so one listed before the
|
||||
/// engine restarted closes nothing after.
|
||||
pub id: String,
|
||||
/// The engine's own number for it, which starts again each run.
|
||||
#[serde(skip)]
|
||||
#[ts(skip)]
|
||||
pub engine_id: u64,
|
||||
/// The host it leads to, without an IPv6 address's brackets.
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// The app that opened it, when known.
|
||||
pub app_name: Option<String>,
|
||||
pub app_bundle_id: Option<String>,
|
||||
/// Its flow, when it was recorded.
|
||||
pub flow_id: Option<String>,
|
||||
pub reason: TunnelReason,
|
||||
}
|
||||
|
||||
impl OpenTunnel {
|
||||
/// `host:port`, as the engine keys its pass-through set, an IPv6 host in brackets.
|
||||
pub fn address(&self) -> String {
|
||||
if self.host.contains(':') {
|
||||
format!("[{}]:{}", self.host, self.port)
|
||||
} else {
|
||||
format!("{}:{}", self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn query(&self) -> DecryptQuery {
|
||||
DecryptQuery {
|
||||
host: self.host.clone(),
|
||||
port: self.port,
|
||||
app: self.app_name.as_ref().map(|name| DecryptApp {
|
||||
name: name.clone(),
|
||||
bundle_id: self.app_bundle_id.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which open tunnels to consider: all of them, those one decrypt-list entry names, or the one
|
||||
/// a flow records.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct TunnelScope {
|
||||
/// An entry as stored or typed, `host:www.google.com`, `app:Dia`, `*`: only tunnels it
|
||||
/// names. An exclusion, `!host:*.chase.com`, names those it would keep out.
|
||||
#[ts(optional)]
|
||||
pub entry: Option<String>,
|
||||
/// Only the tunnel this flow records.
|
||||
#[ts(optional)]
|
||||
pub flow_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The open tunnels among `tunnels` in `scope` that `policy` would decrypt if their clients
|
||||
/// reconnected. Those whose address `passthrough` holds, after a client rejected the proxy's
|
||||
/// certificate, are left out, as their next connection would be passed through again; once the
|
||||
/// address is forgotten, its tunnels count again.
|
||||
pub fn reconnectable(
|
||||
tunnels: &[OpenTunnel],
|
||||
policy: &DecryptPolicy,
|
||||
passthrough: &[String],
|
||||
scope: &TunnelScope,
|
||||
) -> Result<Vec<OpenTunnel>, String> {
|
||||
let entry = scope.entry.as_deref().map(DecryptEntry::parse).transpose()?;
|
||||
Ok(tunnels
|
||||
.iter()
|
||||
.filter(|t| scope.flow_id.as_ref().is_none_or(|f| t.flow_id.as_ref() == Some(f)))
|
||||
.filter(|t| !passthrough.contains(&t.address()))
|
||||
.filter(|t| {
|
||||
let query = t.query();
|
||||
entry.as_ref().is_none_or(|e| e.covers(&query)) && policy.decrypts(&query)
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The registry of open tunnels, shared by the context and the engine's event loop. Each start
|
||||
/// of the engine begins a new run, its ids its own, and an event loop of an earlier run changes
|
||||
/// nothing.
|
||||
#[derive(Default)]
|
||||
pub struct OpenTunnels(Mutex<Run>);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Run {
|
||||
run: u64,
|
||||
/// Random for each run, so ids from another run or process never match this one's.
|
||||
token: String,
|
||||
/// Finds the app behind a tunnel not known when it opened.
|
||||
attribution: Option<Attribution>,
|
||||
tunnels: HashMap<u64, Open>,
|
||||
}
|
||||
|
||||
struct Open {
|
||||
tunnel: OpenTunnel,
|
||||
/// The client connection it came on, for finding its app.
|
||||
connection: u64,
|
||||
}
|
||||
|
||||
impl OpenTunnels {
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Run> {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Starts a run for an engine just started, forgetting the last one's tunnels, which ended
|
||||
/// with it. Returns the run, for the event loop's changes.
|
||||
pub fn begin_run(&self, attribution: Option<Attribution>) -> u64 {
|
||||
let mut run = self.lock();
|
||||
run.run += 1;
|
||||
run.token = yaak_database::generate_prefixed_id("tnl");
|
||||
run.attribution = attribution;
|
||||
run.tunnels.clear();
|
||||
run.run
|
||||
}
|
||||
|
||||
/// The engine stopped, and its tunnels with it. Its event loop's late changes, and ids it
|
||||
/// gave out, apply to nothing from now on.
|
||||
pub fn stop_run(&self) {
|
||||
let mut run = self.lock();
|
||||
run.run += 1;
|
||||
run.token.clear();
|
||||
run.attribution = None;
|
||||
run.tunnels.clear();
|
||||
}
|
||||
|
||||
/// The engine of `run` stopped, and its tunnels with it.
|
||||
pub fn end_run(&self, run: u64) {
|
||||
let mut current = self.lock();
|
||||
if current.run == run {
|
||||
current.tunnels.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A tunnel opened in `run`, on client `connection`, its `id` given here from its
|
||||
/// `engine_id`.
|
||||
pub fn open(&self, run: u64, mut tunnel: OpenTunnel, connection: u64) {
|
||||
let mut current = self.lock();
|
||||
if current.run == run {
|
||||
tunnel.id = format!("{}.{}", current.token, tunnel.engine_id);
|
||||
current.tunnels.insert(tunnel.engine_id, Open { tunnel, connection });
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's numbers for those of `ids` this run gave out. Others are left out.
|
||||
pub fn engine_ids(&self, ids: &[String]) -> Vec<u64> {
|
||||
let current = self.lock();
|
||||
if current.token.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
ids.iter()
|
||||
.filter_map(|id| id.strip_prefix(current.token.as_str())?.strip_prefix('.'))
|
||||
.filter_map(|n| n.parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A tunnel of `run` closed.
|
||||
pub fn close(&self, run: u64, id: u64) {
|
||||
let mut current = self.lock();
|
||||
if current.run == run {
|
||||
current.tunnels.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tunnels open now, oldest first, each with its app when it can be found without
|
||||
/// waiting.
|
||||
pub fn list(&self) -> Vec<OpenTunnel> {
|
||||
let mut current = self.lock();
|
||||
let Run { attribution, tunnels, .. } = &mut *current;
|
||||
for open in tunnels.values_mut() {
|
||||
if open.tunnel.app_name.is_none()
|
||||
&& let Some(app) =
|
||||
attribution.as_ref().and_then(|a| a.get(open.connection, Duration::ZERO))
|
||||
{
|
||||
set_app(&mut open.tunnel, app);
|
||||
}
|
||||
}
|
||||
let mut list: Vec<OpenTunnel> = tunnels.values().map(|o| o.tunnel.clone()).collect();
|
||||
list.sort_by_key(|t| t.engine_id);
|
||||
list
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the app that opened `tunnel`.
|
||||
pub fn set_app(tunnel: &mut OpenTunnel, app: ClientApp) {
|
||||
tunnel.app_name = Some(app.name);
|
||||
tunnel.app_bundle_id = app.bundle_id;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_proxy::BUILT_IN_EXCLUSIONS;
|
||||
|
||||
fn tunnel(id: u64, host: &str, app: Option<&str>, reason: TunnelReason) -> OpenTunnel {
|
||||
OpenTunnel {
|
||||
id: String::new(),
|
||||
engine_id: id,
|
||||
host: host.into(),
|
||||
port: 443,
|
||||
app_name: app.map(String::from),
|
||||
app_bundle_id: app.map(|a| format!("com.example.{}", a.to_lowercase())),
|
||||
flow_id: Some(format!("fl_{id}")),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample() -> Vec<OpenTunnel> {
|
||||
use TunnelReason::*;
|
||||
vec![
|
||||
tunnel(1, "www.google.com", Some("Dia"), NotListed),
|
||||
tunnel(2, "api.example.com", Some("Slack"), NotListed),
|
||||
tunnel(3, "gateway.icloud.com", Some("Dia"), Excluded),
|
||||
tunnel(4, "my.1password.com", Some("1Password"), Rejected),
|
||||
tunnel(5, "www.chase.com", None, Excluded),
|
||||
tunnel(6, "maps.google.com", None, NotListed),
|
||||
]
|
||||
}
|
||||
|
||||
fn policy(entries: &[&str]) -> DecryptPolicy {
|
||||
let mut all = entries.to_vec();
|
||||
all.extend(BUILT_IN_EXCLUSIONS.iter().map(|(e, _)| *e));
|
||||
DecryptPolicy::parse(&all).unwrap()
|
||||
}
|
||||
|
||||
fn ids(
|
||||
policy: &DecryptPolicy,
|
||||
passthrough: &[&str],
|
||||
entry: Option<&str>,
|
||||
flow_id: Option<&str>,
|
||||
) -> Vec<u64> {
|
||||
let passthrough: Vec<String> = passthrough.iter().map(|p| p.to_string()).collect();
|
||||
let scope =
|
||||
TunnelScope { entry: entry.map(String::from), flow_id: flow_id.map(String::from) };
|
||||
reconnectable(&sample(), policy, &passthrough, &scope)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t.engine_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_entries() {
|
||||
let p = policy(&["host:www.google.com"]);
|
||||
assert_eq!(ids(&p, &[], None, None), [1]);
|
||||
let p = policy(&["host:*.google.com"]);
|
||||
assert_eq!(ids(&p, &[], None, None), [1, 6]);
|
||||
// Narrowed to one entry of several.
|
||||
let p = policy(&["host:*.google.com", "host:api.example.com"]);
|
||||
assert_eq!(ids(&p, &[], Some("host:api.example.com"), None), [2]);
|
||||
assert_eq!(ids(&p, &[], Some("api.example.com"), None), [2], "as typed");
|
||||
assert_eq!(ids(&p, &[], Some("host:*.google.com"), None), [1, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_entries() {
|
||||
// Dia's iCloud tunnel too: an app entry outranks the built-in exclusion.
|
||||
let p = policy(&["app:Dia"]);
|
||||
assert_eq!(ids(&p, &[], None, None), [1, 3]);
|
||||
let p = policy(&["app:com.example.slack"]);
|
||||
assert_eq!(ids(&p, &[], Some("app:com.example.slack"), None), [2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_and_exclusions() {
|
||||
// The built-in exclusions keep iCloud and 1Password out.
|
||||
let p = policy(&["*"]);
|
||||
assert_eq!(ids(&p, &[], None, None), [1, 2, 5, 6]);
|
||||
assert_eq!(ids(&p, &[], Some("*"), None), [1, 2, 5, 6]);
|
||||
// The person's own exclusion.
|
||||
let p = policy(&["*", "!host:*.chase.com"]);
|
||||
assert_eq!(ids(&p, &[], None, None), [1, 2, 6]);
|
||||
// A removed exclusion, asked about by name: what it kept out.
|
||||
let p = policy(&["*"]);
|
||||
assert_eq!(ids(&p, &[], Some("!host:*.chase.com"), None), [5]);
|
||||
assert!(reconnectable(&sample(), &p, &[], &TunnelScope::search_entry("host:a b")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_pass_throughs_are_left_out() {
|
||||
let p = policy(&["*", "host:my.1password.com"]);
|
||||
let rejected = "my.1password.com:443";
|
||||
assert!(!ids(&p, &[rejected], None, None).contains(&4), "passed through still");
|
||||
// Forgotten, its next connection would be decrypted.
|
||||
assert!(ids(&p, &[], None, None).contains(&4));
|
||||
// An address passed through since it opened.
|
||||
assert_eq!(ids(&p, &[rejected, "www.google.com:443"], None, None), [2, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_flow() {
|
||||
let p = policy(&["*"]);
|
||||
assert_eq!(ids(&p, &[], None, Some("fl_2")), [2]);
|
||||
assert!(ids(&p, &[], None, Some("fl_3")).is_empty(), "excluded still");
|
||||
assert!(ids(&p, &[], None, Some("fl_nope")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_listed_reconnects_nothing() {
|
||||
assert!(ids(&policy(&[]), &[], None, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registry_keeps_one_run() {
|
||||
let tunnels = OpenTunnels::default();
|
||||
let first = tunnels.begin_run(None);
|
||||
tunnels.open(first, tunnel(1, "a.com", None, TunnelReason::NotListed), 1);
|
||||
tunnels.open(first, tunnel(2, "b.com", None, TunnelReason::NotListed), 2);
|
||||
tunnels.close(first, 1);
|
||||
assert_eq!(tunnels.list().iter().map(|t| t.engine_id).collect::<Vec<_>>(), [2]);
|
||||
let id = tunnels.list()[0].id.clone();
|
||||
assert!(id.starts_with("tnl_") && id.ends_with(".2"), "{id}");
|
||||
assert_eq!(tunnels.engine_ids(&[id.clone(), "tnl_x.2".into(), "2".into()]), [2]);
|
||||
// A new run starts empty, and the old one's late events change nothing.
|
||||
let second = tunnels.begin_run(None);
|
||||
tunnels.open(first, tunnel(3, "c.com", None, TunnelReason::NotListed), 3);
|
||||
tunnels.open(second, tunnel(2, "d.com", None, TunnelReason::NotListed), 4);
|
||||
tunnels.close(first, 2);
|
||||
tunnels.end_run(first);
|
||||
assert_eq!(tunnels.list().iter().map(|t| t.host.as_str()).collect::<Vec<_>>(), ["d.com"]);
|
||||
// An id the first run gave out names nothing now, though its number is reused.
|
||||
assert!(tunnels.engine_ids(&[id]).is_empty());
|
||||
tunnels.end_run(second);
|
||||
assert!(tunnels.list().is_empty());
|
||||
|
||||
// Stopped, a run's late events and ids apply to nothing.
|
||||
let third = tunnels.begin_run(None);
|
||||
tunnels.open(third, tunnel(1, "e.com", None, TunnelReason::NotListed), 5);
|
||||
let id = tunnels.list()[0].id.clone();
|
||||
tunnels.stop_run();
|
||||
assert!(tunnels.list().is_empty());
|
||||
tunnels.open(third, tunnel(2, "f.com", None, TunnelReason::NotListed), 6);
|
||||
assert!(tunnels.list().is_empty());
|
||||
assert!(tunnels.engine_ids(&[id, ".1".into()]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_as_the_engine_keys_them() {
|
||||
assert_eq!(tunnel(1, "a.com", None, TunnelReason::NotListed).address(), "a.com:443");
|
||||
assert_eq!(tunnel(1, "::1", None, TunnelReason::NotListed).address(), "[::1]:443");
|
||||
}
|
||||
|
||||
impl TunnelScope {
|
||||
fn search_entry(entry: &str) -> Self {
|
||||
Self { entry: Some(entry.into()), flow_id: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,6 +494,103 @@ async fn the_app_takes_over_from_the_cli() {
|
||||
assert!(control::live_owner(dir.path()).is_none());
|
||||
}
|
||||
|
||||
/// Tunnels listed while the CLI owned the engine can't be closed once the app has taken over,
|
||||
/// by id or by flow, though the app's engine numbers its tunnels from 1 again: a request closes
|
||||
/// only tunnels of the engine run it names. An entry names what is open now.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn tunnels_listed_before_the_app_took_over_close_nothing_after() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let client_dir = tempfile::tempdir().unwrap();
|
||||
let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli);
|
||||
let mut server = ControlServer::start(cli.clone()).unwrap();
|
||||
let stop_rx = server.take_stop_rx().unwrap();
|
||||
let daemon = std::thread::spawn(move || {
|
||||
stop_rx.recv().unwrap();
|
||||
drop(server);
|
||||
});
|
||||
cli.start().unwrap();
|
||||
let app = ctx(dir.path(), client_dir.path());
|
||||
|
||||
let echo = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let echo_port = echo.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut tcp, _) = echo.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut read, mut write) = tcp.split();
|
||||
let _ = tokio::io::copy(&mut read, &mut write).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
// The address the echo listens on, not `localhost`, which Windows tries as `::1` first and
|
||||
// takes seconds to give up on.
|
||||
let target = format!("127.0.0.1:{echo_port}");
|
||||
let open = |port: u16| {
|
||||
let target = target.clone();
|
||||
async move {
|
||||
let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", port)).await.unwrap();
|
||||
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
||||
tcp.write_all(connect.as_bytes()).await.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
head.push(tcp.read_u8().await.unwrap());
|
||||
}
|
||||
tcp
|
||||
}
|
||||
};
|
||||
let alive = |mut tcp: tokio::net::TcpStream| async move {
|
||||
let mut echoed = [0u8; 4];
|
||||
let ok = tcp.write_all(b"ping").await.is_ok() && tcp.read_exact(&mut echoed).await.is_ok();
|
||||
(ok, tcp)
|
||||
};
|
||||
let listed = |app: &ProxyCtx| {
|
||||
let app = app.clone();
|
||||
async move {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let tunnels = rpc(&app, "list_reconnectable_tunnels", json!({})).await.unwrap();
|
||||
if tunnels.as_array().unwrap().len() == 1 && !tunnels[0]["flowId"].is_null() {
|
||||
return tunnels[0].clone();
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the tunnel never showed: {tunnels}");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The CLI's tunnel, listed through it.
|
||||
let (ok, _old) = alive(open(cli.port().unwrap()).await).await;
|
||||
assert!(ok);
|
||||
rpc(&app, "edit_decrypt_list", json!({"add": ["host:127.0.0.1"]})).await.unwrap();
|
||||
let old = listed(&app).await;
|
||||
|
||||
// The app takes over, and its first tunnel is number 1 again.
|
||||
assert_eq!(rpc(&app, "take_over", json!({})).await.unwrap(), json!(true));
|
||||
daemon.join().unwrap();
|
||||
rpc(&app, "edit_decrypt_list", json!({"remove": ["host:127.0.0.1"]})).await.unwrap();
|
||||
let (ok, new) = alive(open(app.port().unwrap()).await).await;
|
||||
assert!(ok);
|
||||
rpc(&app, "edit_decrypt_list", json!({"add": ["host:127.0.0.1"]})).await.unwrap();
|
||||
let current = listed(&app).await;
|
||||
assert_ne!(current["id"], old["id"]);
|
||||
assert_ne!(current["flowId"], old["flowId"]);
|
||||
|
||||
let by_id = rpc(&app, "reconnect_tunnels", json!({"ids": [old["id"]]})).await.unwrap();
|
||||
assert_eq!(by_id["closed"], 0);
|
||||
let by_flow = rpc(&app, "reconnect_tunnels", json!({"flowId": old["flowId"]})).await.unwrap();
|
||||
assert_eq!(by_flow["closed"], 0);
|
||||
let (ok, mut new) = alive(new).await;
|
||||
assert!(ok, "the app's tunnel was closed by the CLI's ids");
|
||||
|
||||
// An entry is resolved against what is open now.
|
||||
let by_entry = rpc(&app, "reconnect_tunnels", json!({"entry": "host:127.0.0.1"})).await;
|
||||
assert_eq!(by_entry.unwrap()["closed"], 1);
|
||||
let mut rest = Vec::new();
|
||||
let _ = new.read_to_end(&mut rest).await;
|
||||
app.stop().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_save_dialog_reads_workspaces_previews_and_saves() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -809,9 +906,11 @@ async fn the_decrypt_list_is_read_and_replaced_over_the_control_api() {
|
||||
]),
|
||||
"every built-in exclusion left out is removed"
|
||||
);
|
||||
// Saved, it says how many open connections stay encrypted: none, as none are open.
|
||||
assert_eq!(list["stillEncrypted"], 0);
|
||||
let read: Value =
|
||||
http.get(&url).bearer_auth(&info.token).send().await.unwrap().json().await.unwrap();
|
||||
assert_eq!(read, list);
|
||||
assert_eq!(read["entries"], list["entries"]);
|
||||
|
||||
let bad = http
|
||||
.put(&url)
|
||||
@@ -859,6 +958,82 @@ async fn the_decrypt_list_reaches_the_cli_that_owns_the_engine() {
|
||||
}
|
||||
}
|
||||
|
||||
/// While the CLI owns the engine, the app reads its open tunnels and pass-through set and
|
||||
/// reconnects tunnels there, through the control API.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_tunnels_are_reconnected_in_the_cli_that_owns_the_engine() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let client_dir = tempfile::tempdir().unwrap();
|
||||
let cli = ctx(dir.path(), client_dir.path()).with_owner(Owner::Cli);
|
||||
let _server = ControlServer::start(cli.clone()).unwrap();
|
||||
cli.start().unwrap();
|
||||
let app = ctx(dir.path(), client_dir.path());
|
||||
|
||||
// An open tunnel, which the empty list leaves encrypted.
|
||||
let echo = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let echo_port = echo.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut tcp, _) = echo.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut read, mut write) = tcp.split();
|
||||
let _ = tokio::io::copy(&mut read, &mut write).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut tcp = tokio::net::TcpStream::connect(("127.0.0.1", cli.port().unwrap())).await.unwrap();
|
||||
let target = format!("localhost:{echo_port}");
|
||||
tcp.write_all(format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
head.push(tcp.read_u8().await.unwrap());
|
||||
}
|
||||
tcp.write_all(b"ping").await.unwrap();
|
||||
let mut echoed = [0u8; 4];
|
||||
tcp.read_exact(&mut echoed).await.unwrap();
|
||||
|
||||
rpc(&app, "edit_decrypt_list", json!({"add": ["host:localhost"]})).await.unwrap();
|
||||
// Known once the CLI's event loop has seen it open.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let tunnels = loop {
|
||||
let tunnels = rpc(&app, "list_reconnectable_tunnels", json!({})).await.unwrap();
|
||||
if !tunnels.as_array().unwrap().is_empty() {
|
||||
break tunnels;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the tunnel never showed");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
};
|
||||
let saved = rpc(&app, "edit_decrypt_list", json!({"add": ["host:localhost"]})).await.unwrap();
|
||||
assert_eq!(saved["stillEncrypted"], 1, "counted where the engine runs");
|
||||
assert_eq!(tunnels.as_array().unwrap().len(), 1);
|
||||
assert_eq!(tunnels[0]["host"], "localhost");
|
||||
let scope = json!({"entry": "host:localhost"});
|
||||
let closed = rpc(&app, "reconnect_tunnels", scope).await.unwrap();
|
||||
assert_eq!(closed["closed"], 1);
|
||||
let mut rest = Vec::new();
|
||||
tcp.read_to_end(&mut rest).await.unwrap();
|
||||
|
||||
let set = rpc(&app, "list_passthrough", json!({})).await.unwrap();
|
||||
assert_eq!(set["addresses"], json!([]));
|
||||
let forgot = rpc(&app, "forget_passthrough", json!({"address": target})).await.unwrap();
|
||||
assert_eq!(forgot, json!({"forgotten": false, "addresses": []}));
|
||||
|
||||
// The routes themselves.
|
||||
let info = control::read(dir.path()).unwrap();
|
||||
let http = reqwest::Client::builder().no_proxy().build().unwrap();
|
||||
let get =
|
||||
|path: &str| http.get(format!("{}{path}", info.base_url())).bearer_auth(&info.token).send();
|
||||
let none: Value =
|
||||
get("/tunnels/reconnectable?entry=host%3Alocalhost").await.unwrap().json().await.unwrap();
|
||||
assert_eq!(none, json!([]));
|
||||
assert_eq!(get("/tunnels/reconnectable?entry=host%3Aa+b").await.unwrap().status(), 400);
|
||||
let set: Value = get("/passthrough").await.unwrap().json().await.unwrap();
|
||||
assert_eq!(set, json!({"addresses": []}));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_decrypt_list_is_edited_over_the_control_api() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//! HTTPS decryption through the engine: why each connection was tunnelled, a client's refused
|
||||
//! handshake as a flow, the addresses passed through after it, and the open tunnels the decrypt
|
||||
//! list would decrypt if their clients reconnected.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustls::pki_types::ServerName;
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_rustls::TlsConnector;
|
||||
use yaak_proxy_lib::models::{Flow, TunnelReason};
|
||||
use yaak_proxy_lib::settings::ProxySettings;
|
||||
use yaak_proxy_lib::{MasterKeySource, ProxyCtx};
|
||||
use yaak_rpc::{RpcEvent, RpcEventEmitter};
|
||||
|
||||
async fn rpc(ctx: &ProxyCtx, cmd: &str, payload: Value) -> Result<Value, String> {
|
||||
yaak_proxy_lib::build_router().dispatch(cmd, payload, ctx).await.map_err(|e| e.message)
|
||||
}
|
||||
|
||||
/// A started engine with `decrypt` as the list, and the events it sends the app.
|
||||
fn started(decrypt: &[&str]) -> (tempfile::TempDir, ProxyCtx, std::sync::mpsc::Receiver<RpcEvent>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = ProxySettings {
|
||||
port: 0,
|
||||
system_proxy_at_launch: false,
|
||||
decrypt: decrypt.iter().map(|e| e.to_string()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
settings.save(&dir.path().join("settings.json")).unwrap();
|
||||
let (events, event_rx) = RpcEventEmitter::new();
|
||||
let ctx = ProxyCtx::new(dir.path(), events, MasterKeySource::memory()).unwrap();
|
||||
ctx.start().unwrap();
|
||||
(dir, ctx, event_rx)
|
||||
}
|
||||
|
||||
/// A TCP server that echoes what it reads, then closes once the client does.
|
||||
async fn spawn_echo() -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut tcp, _) = listener.accept().await.unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut read, mut write) = tcp.split();
|
||||
let _ = tokio::io::copy(&mut read, &mut write).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// Opens a CONNECT tunnel to `target` through the engine of `ctx`.
|
||||
async fn connect_through(ctx: &ProxyCtx, target: &str) -> TcpStream {
|
||||
let mut tcp = TcpStream::connect(("127.0.0.1", ctx.port().unwrap())).await.unwrap();
|
||||
let connect = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n");
|
||||
tcp.write_all(connect.as_bytes()).await.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
head.push(tcp.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(head.starts_with(b"HTTP/1.1 200"), "{}", String::from_utf8_lossy(&head));
|
||||
tcp
|
||||
}
|
||||
|
||||
/// A tunnel whose bytes have crossed both ways, so the engine has judged it and is relaying.
|
||||
async fn open_tunnel(ctx: &ProxyCtx, target: &str) -> TcpStream {
|
||||
let mut tcp = connect_through(ctx, target).await;
|
||||
tcp.write_all(b"ping").await.unwrap();
|
||||
let mut echoed = [0u8; 4];
|
||||
tcp.read_exact(&mut echoed).await.unwrap();
|
||||
tcp
|
||||
}
|
||||
|
||||
/// TLS through the engine to `target` from a client that trusts no CA at all, so it refuses the
|
||||
/// engine's certificate with an `unknown_ca` alert.
|
||||
async fn refuse_certificate(ctx: &ProxyCtx, target: &str) {
|
||||
let config =
|
||||
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_root_certificates(RootCertStore::empty())
|
||||
.with_no_client_auth();
|
||||
let tcp = connect_through(ctx, target).await;
|
||||
let name = ServerName::try_from("localhost").unwrap();
|
||||
let tls = TlsConnector::from(Arc::new(config)).connect(name, tcp).await;
|
||||
assert!(tls.is_err(), "trusting nothing, the client refuses the engine's certificate");
|
||||
}
|
||||
|
||||
fn flows(ctx: &ProxyCtx) -> Vec<Flow> {
|
||||
ctx.db().unwrap().with_conn(|db| db.find_all::<Flow>().unwrap())
|
||||
}
|
||||
|
||||
async fn wait_for(ctx: &ProxyCtx, what: &str, check: impl Fn(&Flow) -> bool) -> Flow {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if let Some(flow) = flows(ctx).into_iter().find(|f| check(f)) {
|
||||
return flow;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "no flow {what}");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits until `check` holds of what `list_reconnectable_tunnels` returns for `scope`.
|
||||
async fn wait_for_tunnels(ctx: &ProxyCtx, scope: Value, check: impl Fn(&[Value]) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let tunnels = rpc(ctx, "list_reconnectable_tunnels", scope.clone()).await.unwrap();
|
||||
if check(tunnels.as_array().unwrap()) {
|
||||
return;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "tunnels for {scope} stayed {tunnels}");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconnectable(ctx: &ProxyCtx, scope: Value) -> Vec<Value> {
|
||||
let tunnels = rpc(ctx, "list_reconnectable_tunnels", scope).await.unwrap();
|
||||
tunnels.as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_tunnel_records_why_it_was_not_decrypted() {
|
||||
let (_dir, ctx, _events) = started(&["host:localhost", "!host:localhost"]);
|
||||
let echo = spawn_echo().await;
|
||||
|
||||
// Nothing names 127.0.0.1.
|
||||
let _plain = open_tunnel(&ctx, &format!("127.0.0.1:{}", echo.port())).await;
|
||||
let url = format!("https://127.0.0.1:{}", echo.port());
|
||||
let flow = wait_for(&ctx, "to 127.0.0.1", |f| f.url == url).await;
|
||||
assert_eq!(flow.tunnel_reason, Some(TunnelReason::NotListed));
|
||||
assert_eq!((flow.tunnel_excluded_by, flow.tunnel_rejected_by), (None, None));
|
||||
|
||||
// The person's exclusion beats their entry.
|
||||
let _excluded = open_tunnel(&ctx, &format!("localhost:{}", echo.port())).await;
|
||||
let url = format!("https://localhost:{}", echo.port());
|
||||
let flow = wait_for(&ctx, "to localhost", |f| f.url == url).await;
|
||||
assert_eq!(flow.tunnel_reason, Some(TunnelReason::Excluded));
|
||||
assert_eq!(flow.tunnel_excluded_by.as_deref(), Some("!host:localhost"));
|
||||
assert!(!flow.tunnel_excluded_built_in);
|
||||
|
||||
let encrypted = rpc(&ctx, "list_flows", json!({"query": "@is:encrypted"})).await.unwrap();
|
||||
assert_eq!(encrypted["flows"].as_array().unwrap().len(), 2);
|
||||
let flow = &encrypted["flows"][0];
|
||||
assert_eq!(flow["tunnelReason"], "excluded");
|
||||
assert_eq!(flow["tunnelExcludedBy"], "!host:localhost");
|
||||
}
|
||||
|
||||
/// A client refusing the certificate is one flow per address, however many refuse together,
|
||||
/// and the tunnels passed through after it name it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_refused_handshake_is_one_flow_per_address() {
|
||||
let (_dir, ctx, events) = started(&["*"]);
|
||||
let echo = spawn_echo().await;
|
||||
let target = format!("localhost:{}", echo.port());
|
||||
let url = format!("https://{target}");
|
||||
|
||||
refuse_certificate(&ctx, &target).await;
|
||||
let rejected = wait_for(&ctx, "refused", |f| f.tls_rejected.is_some()).await;
|
||||
assert_eq!(rejected.url, url);
|
||||
assert_eq!(rejected.method, "");
|
||||
assert_eq!(rejected.tls_rejected.as_deref(), Some("unknown_ca"));
|
||||
assert_eq!(rejected.error.as_deref(), Some("Rejected the proxy's certificate"));
|
||||
assert!(!rejected.tunnelled);
|
||||
assert!(rejected.elapsed_ms.is_some(), "ended, not pending");
|
||||
// No request was sent, so there is none to save.
|
||||
let preview = rpc(&ctx, "preview_save", json!({"flowId": rejected.id})).await;
|
||||
assert!(preview.unwrap_err().starts_with("Not a request"));
|
||||
|
||||
// Passed through from now on, naming the refusal.
|
||||
let _tunnel = open_tunnel(&ctx, &target).await;
|
||||
let tunnel = wait_for(&ctx, "passed through", |f| f.tunnelled && f.url == url).await;
|
||||
assert_eq!(tunnel.tunnel_reason, Some(TunnelReason::Rejected));
|
||||
assert_eq!(tunnel.tunnel_rejected_by.as_deref(), Some(rejected.id.as_str()));
|
||||
// It would be passed through again, so it is no tunnel to reconnect.
|
||||
assert!(reconnectable(&ctx, json!({})).await.is_empty());
|
||||
|
||||
// Several refusing at once to another address make one flow too.
|
||||
let other = format!("127.0.0.1:{}", echo.port());
|
||||
let mut tunnels = Vec::new();
|
||||
for _ in 0..6 {
|
||||
tunnels.push(connect_through(&ctx, &other).await);
|
||||
}
|
||||
let config = Arc::new(
|
||||
ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_root_certificates(RootCertStore::empty())
|
||||
.with_no_client_auth(),
|
||||
);
|
||||
let attempts: Vec<_> = tunnels
|
||||
.into_iter()
|
||||
.map(|tcp| {
|
||||
let connector = TlsConnector::from(config.clone());
|
||||
let name = ServerName::try_from("localhost").unwrap();
|
||||
tokio::spawn(async move { connector.connect(name, tcp).await.map(drop) })
|
||||
})
|
||||
.collect();
|
||||
for attempt in attempts {
|
||||
let _ = attempt.await.unwrap();
|
||||
}
|
||||
let other_url = format!("https://{other}");
|
||||
wait_for(&ctx, "refused at once", |f| f.url == other_url && f.tls_rejected.is_some()).await;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let refused: Vec<Flow> = flows(&ctx).into_iter().filter(|f| f.tls_rejected.is_some()).collect();
|
||||
assert_eq!(refused.len(), 2, "one for each address: {refused:#?}");
|
||||
let listed = rpc(&ctx, "list_flows", json!({"query": "@is:rejected"})).await.unwrap();
|
||||
assert_eq!(listed["flows"].as_array().unwrap().len(), 2);
|
||||
|
||||
// The set, read and changed.
|
||||
let set = rpc(&ctx, "list_passthrough", json!({})).await.unwrap();
|
||||
assert_eq!(set["addresses"], json!([other, target]));
|
||||
let pushed: Vec<RpcEvent> =
|
||||
events.try_iter().filter(|e| e.event == "passthrough_changed").collect();
|
||||
assert_eq!(pushed.len(), 2, "one push as each address was added");
|
||||
assert_eq!(pushed[1].payload["addresses"], json!([other, target]));
|
||||
|
||||
// Forgotten, by the flow's authority, the address is intercepted again, and a refusal is
|
||||
// another flow.
|
||||
let forgot = rpc(&ctx, "forget_passthrough", json!({"address": target})).await.unwrap();
|
||||
assert_eq!(forgot, json!({"forgotten": true, "addresses": [other]}));
|
||||
let pushed: Vec<RpcEvent> =
|
||||
events.try_iter().filter(|e| e.event == "passthrough_changed").collect();
|
||||
assert_eq!(pushed.len(), 1);
|
||||
assert_eq!(pushed[0].payload["addresses"], json!([other]));
|
||||
// Its tunnel still open would now be decrypted if it reconnected.
|
||||
let open = reconnectable(&ctx, json!({"flowId": tunnel.id})).await;
|
||||
assert_eq!(open.len(), 1, "forgotten, its tunnel counts again");
|
||||
let again = rpc(&ctx, "forget_passthrough", json!({"address": target})).await.unwrap();
|
||||
assert_eq!(again["forgotten"], false);
|
||||
assert!(rpc(&ctx, "forget_passthrough", json!({"address": ""})).await.is_err());
|
||||
refuse_certificate(&ctx, &target).await;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while flows(&ctx).iter().filter(|f| f.url == url && f.tls_rejected.is_some()).count() < 2 {
|
||||
assert!(Instant::now() < deadline, "no second refusal");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open tunnels are kept whether or not flows are recorded; saving the list closes none of
|
||||
/// them but counts those it would decrypt, and reconnecting closes them by flow, by entry or
|
||||
/// all at once.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn open_tunnels_are_reconnected_only_when_asked() {
|
||||
let (_dir, ctx, _events) = started(&[]);
|
||||
let echo = spawn_echo().await;
|
||||
let local = format!("localhost:{}", echo.port());
|
||||
let loopback = format!("127.0.0.1:{}", echo.port());
|
||||
|
||||
let mut a = open_tunnel(&ctx, &local).await;
|
||||
let a_flow = wait_for(&ctx, "a", |f| f.url == format!("https://{local}")).await;
|
||||
let mut b = open_tunnel(&ctx, &loopback).await;
|
||||
let b_flow = wait_for(&ctx, "b", |f| f.url == format!("https://{loopback}")).await;
|
||||
// Paused, a tunnel has no flow, and is kept all the same.
|
||||
rpc(&ctx, "set_recording", json!({"enabled": false})).await.unwrap();
|
||||
let mut c = open_tunnel(&ctx, &local).await;
|
||||
|
||||
// Nothing is listed, so nothing would be decrypted.
|
||||
assert!(reconnectable(&ctx, json!({})).await.is_empty());
|
||||
let saved = rpc(&ctx, "edit_decrypt_list", json!({"add": ["host:localhost"]})).await.unwrap();
|
||||
assert!(saved["stillEncrypted"].as_u64().unwrap() <= 2);
|
||||
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 2).await;
|
||||
let listed = reconnectable(&ctx, json!({})).await;
|
||||
assert_eq!(listed[0]["flowId"], json!(a_flow.id));
|
||||
assert_eq!(listed[0]["host"], "localhost");
|
||||
assert_eq!(listed[0]["port"], echo.port());
|
||||
assert_eq!(listed[0]["reason"], "not_listed");
|
||||
assert_eq!(listed[1]["flowId"], Value::Null, "opened while paused");
|
||||
|
||||
// Saving counts what stays encrypted, and closes nothing.
|
||||
let saved = rpc(&ctx, "edit_decrypt_list", json!({"add": ["127.0.0.1"]})).await.unwrap();
|
||||
assert_eq!(saved["stillEncrypted"], 3);
|
||||
for tcp in [&mut a, &mut b, &mut c] {
|
||||
tcp.write_all(b"ping").await.unwrap();
|
||||
let mut echoed = [0u8; 4];
|
||||
tcp.read_exact(&mut echoed).await.unwrap();
|
||||
}
|
||||
|
||||
// Narrowed to an entry, or to a flow.
|
||||
let entry = reconnectable(&ctx, json!({"entry": "host:127.0.0.1"})).await;
|
||||
assert_eq!(entry.len(), 1);
|
||||
assert_eq!(entry[0]["flowId"], json!(b_flow.id));
|
||||
assert_eq!(reconnectable(&ctx, json!({"flowId": a_flow.id})).await.len(), 1);
|
||||
assert!(rpc(&ctx, "list_reconnectable_tunnels", json!({"entry": "host:a b"})).await.is_err());
|
||||
|
||||
// By flow.
|
||||
let closed = rpc(&ctx, "reconnect_tunnels", json!({"flowId": a_flow.id})).await.unwrap();
|
||||
assert_eq!(closed, json!({"closed": 1}));
|
||||
let mut rest = Vec::new();
|
||||
a.read_to_end(&mut rest).await.unwrap();
|
||||
let ended = wait_for(&ctx, "a ended", |f| f.id == a_flow.id && f.elapsed_ms.is_some()).await;
|
||||
assert!(ended.reconnect_requested);
|
||||
assert_eq!(ended.error, None);
|
||||
|
||||
// By entry.
|
||||
let closed = rpc(&ctx, "reconnect_tunnels", json!({"entry": "127.0.0.1"})).await.unwrap();
|
||||
assert_eq!(closed["closed"], 1);
|
||||
b.read_to_end(&mut rest).await.unwrap();
|
||||
let ended = wait_for(&ctx, "b ended", |f| f.id == b_flow.id && f.elapsed_ms.is_some()).await;
|
||||
assert!(ended.reconnect_requested);
|
||||
|
||||
// All, and by id what is closed already.
|
||||
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 1).await;
|
||||
let id = reconnectable(&ctx, json!({})).await[0]["id"].clone();
|
||||
let closed = rpc(&ctx, "reconnect_tunnels", json!({})).await.unwrap();
|
||||
assert_eq!(closed["closed"], 1);
|
||||
c.read_to_end(&mut rest).await.unwrap();
|
||||
wait_for_tunnels(&ctx, json!({}), |t| t.is_empty()).await;
|
||||
let closed = rpc(&ctx, "reconnect_tunnels", json!({"ids": [id]})).await.unwrap();
|
||||
assert_eq!(closed["closed"], 0);
|
||||
|
||||
// An id listed before the engine restarted closes nothing after, though the new run
|
||||
// numbers its tunnels from 1 again. Each is opened while its host is unlisted.
|
||||
let unlisted = || rpc(&ctx, "edit_decrypt_list", json!({"remove": ["host:127.0.0.1"]}));
|
||||
let listed = || rpc(&ctx, "edit_decrypt_list", json!({"add": ["host:127.0.0.1"]}));
|
||||
unlisted().await.unwrap();
|
||||
let mut d = open_tunnel(&ctx, &loopback).await;
|
||||
listed().await.unwrap();
|
||||
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 1).await;
|
||||
let old = reconnectable(&ctx, json!({})).await[0]["id"].clone();
|
||||
ctx.stop().unwrap();
|
||||
// Stopped, nothing is open, at once, whatever its event loop has still to read.
|
||||
assert!(reconnectable(&ctx, json!({})).await.is_empty());
|
||||
assert_eq!(listed().await.unwrap()["stillEncrypted"], 0);
|
||||
assert_eq!(rpc(&ctx, "reconnect_tunnels", json!({"ids": [old]})).await.unwrap()["closed"], 0);
|
||||
assert_eq!(rpc(&ctx, "list_passthrough", json!({})).await.unwrap()["addresses"], json!([]));
|
||||
let forgot = rpc(&ctx, "forget_passthrough", json!({"address": "localhost"})).await.unwrap();
|
||||
assert_eq!(forgot, json!({"forgotten": false, "addresses": []}));
|
||||
d.read_to_end(&mut rest).await.unwrap();
|
||||
ctx.start().unwrap();
|
||||
unlisted().await.unwrap();
|
||||
let mut e = open_tunnel(&ctx, &loopback).await;
|
||||
listed().await.unwrap();
|
||||
wait_for_tunnels(&ctx, json!({}), |t| t.len() == 1).await;
|
||||
assert_ne!(reconnectable(&ctx, json!({})).await[0]["id"], old);
|
||||
let closed = rpc(&ctx, "reconnect_tunnels", json!({"ids": [old]})).await.unwrap();
|
||||
assert_eq!(closed["closed"], 0);
|
||||
e.write_all(b"ping").await.unwrap();
|
||||
let mut echoed = [0u8; 4];
|
||||
e.read_exact(&mut echoed).await.unwrap();
|
||||
}
|
||||
|
||||
/// Dropping the last context stops its engine, which its event loop does not keep alive.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn dropping_the_context_stops_the_engine() {
|
||||
let (_dir, ctx, _events) = started(&[]);
|
||||
let port = ctx.port().unwrap();
|
||||
drop(ctx);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
|
||||
assert!(Instant::now() < deadline, "the engine still listens on {port}");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// A tunnel that ends on its own is no longer open, and is not marked for a reconnect.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_closed_tunnel_leaves_the_registry() {
|
||||
let (_dir, ctx, _events) = started(&["*"]);
|
||||
let echo = spawn_echo().await;
|
||||
// Kept out by the built-in exclusions' rival, an exclusion of the person's.
|
||||
rpc(&ctx, "edit_decrypt_list", json!({"add": ["!host:localhost"]})).await.unwrap();
|
||||
let tcp = open_tunnel(&ctx, &format!("localhost:{}", echo.port())).await;
|
||||
let url = format!("https://localhost:{}", echo.port());
|
||||
wait_for(&ctx, "open", |f| f.url == url).await;
|
||||
let saved = rpc(&ctx, "edit_decrypt_list", json!({"remove": ["!host:localhost"]})).await;
|
||||
assert_eq!(saved.unwrap()["stillEncrypted"], 1);
|
||||
drop(tcp);
|
||||
let ended = wait_for(&ctx, "ended", |f| f.url == url && f.elapsed_ms.is_some()).await;
|
||||
assert!(!ended.reconnect_requested);
|
||||
wait_for_tunnels(&ctx, json!({}), |t| t.is_empty()).await;
|
||||
}
|
||||
@@ -530,16 +530,17 @@ async fn the_decrypt_list_rpcs_save_and_apply() {
|
||||
json!([
|
||||
{"entry": "host:api.example.com", "builtIn": false},
|
||||
{"entry": "app:Google Chrome", "builtIn": false},
|
||||
{"entry": "!host:*.apple.com", "builtIn": true},
|
||||
{"entry": "!host:*.apple.com", "builtIn": true, "kind": "apple"},
|
||||
])
|
||||
);
|
||||
assert_eq!(list["stillEncrypted"], 0);
|
||||
let saved = ProxySettings::load(&dir.path().join("settings.json")).unwrap();
|
||||
assert_eq!(saved.decrypt, ["host:api.example.com", "app:Google Chrome"]);
|
||||
assert!(saved.decrypt_built_ins_removed.contains(&"!host:*.icloud.com".to_string()));
|
||||
|
||||
let invalid = rpc(&ctx, "set_decrypt_list", json!({"entries": ["host:a b"]})).await;
|
||||
assert!(invalid.unwrap_err().contains("Invalid host"));
|
||||
assert_eq!(rpc(&ctx, "get_decrypt_list", json!({})).await.unwrap(), list);
|
||||
assert_eq!(rpc(&ctx, "get_decrypt_list", json!({})).await.unwrap()["entries"], list["entries"]);
|
||||
|
||||
// Applied to the running engine's next connection: decrypting everything intercepts a
|
||||
// CONNECT, which a client that does not trust the CA then rejects.
|
||||
@@ -629,7 +630,7 @@ async fn decrypt_list_edits_apply_to_the_latest_list_and_are_announced() {
|
||||
let changes: Vec<_> =
|
||||
event_rx.try_iter().filter(|e| e.event == "decrypt_list_changed").collect();
|
||||
assert_eq!(changes.len(), 3);
|
||||
assert_eq!(changes[2].payload["list"], list);
|
||||
assert_eq!(changes[2].payload["list"]["entries"], list["entries"]);
|
||||
}
|
||||
|
||||
/// Capture throughput through the engine, end to end: 3,000 requests, 16 at a time over
|
||||
|
||||
@@ -99,5 +99,6 @@ yaak-system-appearance = { workspace = true }
|
||||
yaak-sync = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
yaak-tls = { workspace = true }
|
||||
yaak-updater = { workspace = true }
|
||||
yaak-window = { workspace = true }
|
||||
yaak-ws = { workspace = true }
|
||||
-20
@@ -6,26 +6,6 @@ export type PluginUpdateInfo = { name: string; currentVersion: string; latestVer
|
||||
|
||||
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
|
||||
|
||||
export type UpdateInfo = {
|
||||
replyEventId: string;
|
||||
version: string;
|
||||
downloaded: boolean;
|
||||
/**
|
||||
* How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||
* itself and the user is told how to update instead.
|
||||
*/
|
||||
install: UpdateInstall;
|
||||
};
|
||||
|
||||
/**
|
||||
* How an update can be applied to this install.
|
||||
*/
|
||||
export type UpdateInstall = "integrated" | "flatpak" | "manual";
|
||||
|
||||
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
|
||||
|
||||
export type UpdateResponseAction = "install" | "skip";
|
||||
|
||||
export type YaakNotification = {
|
||||
timestamp: string;
|
||||
timeout: number | null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// ts-rs owns bindings/index.ts and rewrites it on export. What remains here
|
||||
// after the RPC schema moved to @yaakapp-internal/rpc-schema is the
|
||||
// desktop-only surface: updater and notification types.
|
||||
// desktop-only surface: plugin update and notification types. The app updater's
|
||||
// types are @yaakapp-internal/tauri-updater's, shared with Yaak Proxy.
|
||||
export * from "./bindings/index";
|
||||
@@ -52,6 +52,7 @@ use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_updater::restart;
|
||||
|
||||
mod encoding;
|
||||
mod error;
|
||||
@@ -67,7 +68,6 @@ mod notifications;
|
||||
mod plugin_events;
|
||||
mod plugins_ext;
|
||||
mod render;
|
||||
mod restart;
|
||||
mod rpc_ext;
|
||||
mod sync_ext;
|
||||
mod updates;
|
||||
@@ -1333,6 +1333,8 @@ pub fn run() {
|
||||
// Add updater
|
||||
let yaak_updater = YaakUpdater::new();
|
||||
app.manage(Mutex::new(yaak_updater));
|
||||
// The one update this process runs at a time, whichever window or trigger asks
|
||||
app.manage(yaak_updater::UpdateGate::default());
|
||||
|
||||
// Add notifier
|
||||
let yaak_notifier = YaakNotifier::new();
|
||||
|
||||
@@ -1,68 +1,24 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use crate::restart;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
|
||||
use log::info;
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use tokio::task::block_in_place;
|
||||
use tokio::time::sleep;
|
||||
use ts_rs::TS;
|
||||
use yaak_models::util::generate_id;
|
||||
|
||||
use url::Url;
|
||||
use yaak_api::get_system_proxy_url;
|
||||
use yaak_updater::{UpdateGate, UpdateInstall};
|
||||
pub use yaak_updater::{UpdateMode, UpdateTrigger};
|
||||
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::is_dev;
|
||||
|
||||
const MAX_UPDATE_CHECK_HOURS_STABLE: u64 = 12;
|
||||
const MAX_UPDATE_CHECK_HOURS_BETA: u64 = 3;
|
||||
const MAX_UPDATE_CHECK_HOURS_ALPHA: u64 = 1;
|
||||
|
||||
// Create updater struct
|
||||
pub struct YaakUpdater {
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
|
||||
pub enum UpdateMode {
|
||||
Stable,
|
||||
Beta,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
impl Display for UpdateMode {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
UpdateMode::Stable => "stable",
|
||||
UpdateMode::Beta => "beta",
|
||||
UpdateMode::Alpha => "alpha",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateMode {
|
||||
pub fn new(mode: &str) -> UpdateMode {
|
||||
match mode {
|
||||
"beta" => UpdateMode::Beta,
|
||||
"alpha" => UpdateMode::Alpha,
|
||||
_ => UpdateMode::Stable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum UpdateTrigger {
|
||||
Background,
|
||||
User,
|
||||
}
|
||||
|
||||
impl YaakUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self { last_check: None }
|
||||
@@ -75,6 +31,20 @@ impl YaakUpdater {
|
||||
auto_download: bool,
|
||||
update_trigger: UpdateTrigger,
|
||||
) -> Result<bool> {
|
||||
// One check and one update at a time, whichever window or trigger asks. The user who
|
||||
// asks while one is in hand sees where it stands, so there's no "no update" to show.
|
||||
let token = match window.state::<UpdateGate>().begin_check() {
|
||||
Ok(token) => token,
|
||||
Err(busy) => {
|
||||
info!("Not checking for updates: the update is {:?}", busy.state);
|
||||
if update_trigger == UpdateTrigger::User {
|
||||
busy.show(window);
|
||||
return Ok(true);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let settings = window.db().get_settings();
|
||||
let update_key = format!("{:x}", md5::compute(settings.id));
|
||||
self.last_check = Some(Instant::now());
|
||||
@@ -105,13 +75,7 @@ impl YaakUpdater {
|
||||
})
|
||||
.header("X-Update-Mode", mode.to_string())?
|
||||
.header("X-Update-Key", update_key)?
|
||||
.header(
|
||||
"X-Update-Trigger",
|
||||
match update_trigger {
|
||||
UpdateTrigger::Background => "background",
|
||||
UpdateTrigger::User => "user",
|
||||
},
|
||||
)?
|
||||
.header("X-Update-Trigger", update_trigger.header_value())?
|
||||
.header("X-Install-Mode", detect_install_mode().unwrap_or("unknown"))?
|
||||
.build()?
|
||||
.check()
|
||||
@@ -120,57 +84,8 @@ impl YaakUpdater {
|
||||
let result = match update_check_result? {
|
||||
None => false,
|
||||
Some(update) => {
|
||||
let w = window.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Only hand the artifact to the updater plugin when this install can
|
||||
// apply it itself; otherwise tell the user how to update instead
|
||||
let install = update_install_method(&update);
|
||||
if install != UpdateInstall::Integrated {
|
||||
info!(
|
||||
"{} available, but this install updates via {install:?}",
|
||||
update.version
|
||||
);
|
||||
notify_external_update(&w, &update, install);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force native updater if specified (useful if a release broke the UI)
|
||||
let native_install_mode =
|
||||
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
|
||||
== Some("native");
|
||||
if native_install_mode {
|
||||
start_native_update(&w, &update).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a background update, try downloading it first
|
||||
if update_trigger == UpdateTrigger::Background && auto_download {
|
||||
info!("Downloading update {} in background", update.version);
|
||||
if let Err(e) = download_update_idempotent(&w, &update).await {
|
||||
error!("Failed to download {}: {}", update.version, e);
|
||||
}
|
||||
}
|
||||
|
||||
match start_integrated_update(&w, &update).await {
|
||||
Ok(UpdateResponseAction::Skip) => {
|
||||
info!("Confirmed {}: skipped", update.version);
|
||||
}
|
||||
Ok(UpdateResponseAction::Install) => {
|
||||
info!("Confirmed {}: install", update.version);
|
||||
if let Err(e) = install_update_maybe_download(&w, &update).await {
|
||||
error!("Failed to install: {e}");
|
||||
return;
|
||||
};
|
||||
|
||||
info!("Installed {}", update.version);
|
||||
finish_integrated_update(&w, &update).await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to notify frontend, falling back: {e}",);
|
||||
start_native_update(&w, &update).await;
|
||||
}
|
||||
};
|
||||
});
|
||||
let install = update_install_method(&update);
|
||||
token.offer(window, update, update_trigger, auto_download, install);
|
||||
true
|
||||
}
|
||||
};
|
||||
@@ -183,14 +98,8 @@ impl YaakUpdater {
|
||||
auto_download: bool,
|
||||
mode: UpdateMode,
|
||||
) -> Result<bool> {
|
||||
let update_period_seconds = match mode {
|
||||
UpdateMode::Stable => MAX_UPDATE_CHECK_HOURS_STABLE,
|
||||
UpdateMode::Beta => MAX_UPDATE_CHECK_HOURS_BETA,
|
||||
UpdateMode::Alpha => MAX_UPDATE_CHECK_HOURS_ALPHA,
|
||||
} * (60 * 60);
|
||||
|
||||
if let Some(i) = self.last_check
|
||||
&& i.elapsed().as_secs() < update_period_seconds
|
||||
&& i.elapsed() < mode.check_interval()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -204,208 +113,6 @@ impl YaakUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Default, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
struct UpdateInfo {
|
||||
reply_event_id: String,
|
||||
version: String,
|
||||
downloaded: bool,
|
||||
/// How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||
/// itself and the user is told how to update instead.
|
||||
install: UpdateInstall,
|
||||
}
|
||||
|
||||
/// How an update can be applied to this install.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateInstall {
|
||||
/// The app downloads and installs it itself
|
||||
#[default]
|
||||
Integrated,
|
||||
/// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark)
|
||||
Flatpak,
|
||||
/// Nothing can install it in-app (distro package, Nix, unknown); download by hand
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateResponse {
|
||||
Ack,
|
||||
Action { action: UpdateResponseAction },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
enum UpdateResponseAction {
|
||||
Install,
|
||||
Skip,
|
||||
}
|
||||
|
||||
async fn finish_integrated_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||
if let Err(e) = window.emit_to(window.label(), "update_installed", update.version.to_string()) {
|
||||
warn!("Failed to notify frontend of update install: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_integrated_update<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<UpdateResponseAction> {
|
||||
let download_path = ensure_download_dir(window)?.join(download_file_name(update));
|
||||
debug!("Download path: {}", download_path.display());
|
||||
let downloaded = download_path.exists();
|
||||
let ack_wait = Duration::from_secs(3);
|
||||
let reply_id = generate_id();
|
||||
|
||||
// 1) Start listening BEFORE emitting to avoid missing a fast reply
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<UpdateResponse>();
|
||||
let w_for_listener = window.clone();
|
||||
|
||||
let event_id = w_for_listener.listen(reply_id.clone(), move |ev| {
|
||||
match serde_json::from_str::<UpdateResponse>(ev.payload()) {
|
||||
Ok(UpdateResponse::Ack) => {
|
||||
let _ = tx.send(UpdateResponse::Ack);
|
||||
}
|
||||
Ok(UpdateResponse::Action { action }) => {
|
||||
let _ = tx.send(UpdateResponse::Action { action });
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse update reply from frontend: {e:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure we always unlisten
|
||||
struct Unlisten<'a, R: Runtime> {
|
||||
win: &'a WebviewWindow<R>,
|
||||
id: tauri::EventId,
|
||||
}
|
||||
impl<'a, R: Runtime> Drop for Unlisten<'a, R> {
|
||||
fn drop(&mut self) {
|
||||
self.win.unlisten(self.id);
|
||||
}
|
||||
}
|
||||
let _guard = Unlisten { win: window, id: event_id };
|
||||
|
||||
// 2) Emit the event now that listener is in place
|
||||
let info = UpdateInfo {
|
||||
version: update.version.to_string(),
|
||||
downloaded,
|
||||
install: UpdateInstall::Integrated,
|
||||
reply_event_id: reply_id,
|
||||
};
|
||||
window
|
||||
.emit_to(window.label(), "update_available", &info)
|
||||
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
|
||||
|
||||
// 3) Two-stage timeout: first wait for ack, then wait for final action
|
||||
// --- Phase 1: wait for ACK with timeout ---
|
||||
let ack_timer = sleep(ack_wait);
|
||||
tokio::pin!(ack_timer);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = rx.recv() => match msg {
|
||||
Some(UpdateResponse::Ack) => break, // proceed to Phase 2
|
||||
Some(UpdateResponse::Action{action}) => return Ok(action), // user was fast
|
||||
None => return Err(GenericError("frontend channel closed before ack".into())),
|
||||
},
|
||||
_ = &mut ack_timer => {
|
||||
return Err(GenericError("timed out waiting for frontend ack".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 2: wait forever for final action ---
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(UpdateResponse::Action { action }) => return Ok(action),
|
||||
Some(UpdateResponse::Ack) => { /* ignore extra acks */ }
|
||||
None => return Err(GenericError("frontend channel closed before action".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the frontend about an update this install can't apply itself, so the user can be
|
||||
/// told how to get it. Unlike the integrated flow, there is nothing to reply to.
|
||||
fn notify_external_update<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
install: UpdateInstall,
|
||||
) {
|
||||
let info = UpdateInfo {
|
||||
version: update.version.to_string(),
|
||||
downloaded: false,
|
||||
install,
|
||||
reply_event_id: generate_id(),
|
||||
};
|
||||
if let Err(e) = window.emit_to(window.label(), "update_available", &info) {
|
||||
warn!("Failed to emit update_available: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
|
||||
// If the frontend doesn't respond, fallback to native dialogs
|
||||
let confirmed = window
|
||||
.dialog()
|
||||
.message(format!(
|
||||
"{} is available. Would you like to download and install it now?",
|
||||
update.version
|
||||
))
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("Download".to_string(), "Later".to_string()))
|
||||
.title("Update Available")
|
||||
.blocking_show();
|
||||
if !confirmed {
|
||||
return;
|
||||
}
|
||||
|
||||
match update.download_and_install(|_, _| {}, || {}).await {
|
||||
Ok(()) => {
|
||||
if window
|
||||
.dialog()
|
||||
.message("Would you like to restart the app?")
|
||||
.title("Update Installed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||
"Restart".to_string(),
|
||||
"Later".to_string(),
|
||||
))
|
||||
.blocking_show()
|
||||
{
|
||||
restart::request_restart(window.app_handle());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
window.dialog().message(format!("The update failed to install: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_update_idempotent<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<PathBuf> {
|
||||
let dl_path = ensure_download_dir(window)?.join(download_file_name(update));
|
||||
|
||||
if dl_path.exists() {
|
||||
info!("{} already downloaded to {}", update.version, dl_path.display());
|
||||
return Ok(dl_path);
|
||||
}
|
||||
|
||||
info!("{} downloading: {}", update.version, dl_path.display());
|
||||
let dl_bytes = update.download(|_, _| {}, || {}).await?;
|
||||
std::fs::write(&dl_path, dl_bytes)
|
||||
.map_err(|e| GenericError(format!("Failed to write update: {e}")))?;
|
||||
|
||||
info!("{} downloaded", update.version);
|
||||
|
||||
Ok(dl_path)
|
||||
}
|
||||
|
||||
/// Detect the installer type so the update server can serve the correct artifact.
|
||||
fn detect_install_mode() -> Option<&'static str> {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -551,44 +258,3 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_update_maybe_download<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
update: &Update,
|
||||
) -> Result<()> {
|
||||
let dl_path = download_update_idempotent(window, update).await?;
|
||||
let update_bytes = std::fs::read(&dl_path)?;
|
||||
update.install(update_bytes.as_slice())?;
|
||||
delete_download_dir(window);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
Ok(window.path().app_cache_dir()?.join("updates"))
|
||||
}
|
||||
|
||||
pub fn ensure_download_dir<R: Runtime>(window: &WebviewWindow<R>) -> Result<PathBuf> {
|
||||
let base_dir = download_dir(window)?;
|
||||
std::fs::create_dir_all(&base_dir)?;
|
||||
Ok(base_dir)
|
||||
}
|
||||
|
||||
pub fn download_file_name(update: &Update) -> String {
|
||||
let sig_digest = md5::compute(&update.signature);
|
||||
format!("yaak-{}-{:x}", update.version, sig_digest)
|
||||
}
|
||||
|
||||
pub fn delete_download_dir<R: Runtime>(window: &WebviewWindow<R>) {
|
||||
let base_dir = match download_dir(window) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
warn!("Failed to locate update downloads dir: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match std::fs::remove_dir_all(&base_dir) {
|
||||
Ok(()) => info!("Removed update downloads dir {}", base_dir.display()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => warn!("Failed to remove update downloads dir {}: {}", base_dir.display(), e),
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ tauri-build = { version = "2.6.1", features = [] }
|
||||
[features]
|
||||
default = ["wry"]
|
||||
wry = ["tauri/wry", "tauri/x11", "tauri/dbus", "tauri-plugin-dialog/gtk3"]
|
||||
# Checks for and installs updates, as release builds do (`tauri.release.conf.json`).
|
||||
updater = ["dep:tauri-plugin-updater"]
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
@@ -23,7 +25,15 @@ tauri = { workspace = true, features = ["devtools"] }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-updater = { version = "2.10.1", optional = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
yaak-mac-window = { workspace = true }
|
||||
yaak-proxy-lib = { workspace = true }
|
||||
yaak-rpc = { workspace = true }
|
||||
yaak-updater = { workspace = true }
|
||||
yaak-window = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# A mock app for the update checks' tests
|
||||
tauri = { workspace = true, features = ["test"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
@@ -6,6 +6,7 @@ use yaak_proxy_lib::{MasterKeySource, ProxyCtx};
|
||||
use yaak_rpc::{RpcEventEmitter, RpcRouter};
|
||||
use yaak_window::window::CreateWindowConfig;
|
||||
|
||||
mod updates;
|
||||
mod window_menu;
|
||||
|
||||
fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) {
|
||||
@@ -47,6 +48,14 @@ fn handle_menu_events<R: Runtime>(win: &WebviewWindow<R>) {
|
||||
error!("Failed to emit the HAR export: {e}");
|
||||
}
|
||||
}
|
||||
// To the window it was chosen in alone, which checks and shows what it finds
|
||||
window_menu::CHECK_FOR_UPDATES_ID => {
|
||||
if let Err(e) =
|
||||
webview_window.emit_to(w.label(), window_menu::CHECK_FOR_UPDATES_EVENT, ())
|
||||
{
|
||||
error!("Failed to emit {}: {e}", window_menu::CHECK_FOR_UPDATES_EVENT);
|
||||
}
|
||||
}
|
||||
window_menu::GO_BACK_ID | window_menu::GO_FORWARD_ID => {
|
||||
let event = if event_id == window_menu::GO_BACK_ID {
|
||||
window_menu::GO_BACK_EVENT
|
||||
@@ -90,8 +99,11 @@ fn bundled_protoc<R: Runtime>(app: &tauri::AppHandle<R>) -> Option<yaak_proxy_li
|
||||
|
||||
/// The main window's label.
|
||||
const MAIN_WINDOW_LABEL: &str = "main_0";
|
||||
/// The Settings window's label, by which its page renders the settings alone.
|
||||
const SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||
/// The name the Settings window is opened under, and the key its size and position are kept by.
|
||||
const SETTINGS_WINDOW_NAME: &str = "settings";
|
||||
/// The Settings window's label, by which its page renders the settings alone: its name with the
|
||||
/// prefix the shared code gives each child window, as Yaak's Settings window has.
|
||||
const SETTINGS_WINDOW_LABEL: &str = "other__settings";
|
||||
/// The event that moves an open Settings window to another tab.
|
||||
const SETTINGS_TAB_EVENT: &str = "settings_tab";
|
||||
/// The Settings window's size, as Yaak's.
|
||||
@@ -105,8 +117,10 @@ fn settings_tab_script(tab: &str) -> String {
|
||||
format!("window.__YAAK_SETTINGS_TAB__ = {tab};")
|
||||
}
|
||||
|
||||
/// Opens Settings in its own window on `tab`, or, when it is open, brings it forward and moves
|
||||
/// it to `tab` if one is given.
|
||||
/// Opens Settings on `tab` in a child of the main window, or, when it is open, brings it forward
|
||||
/// and moves it to `tab` if one is given. As Yaak's, the child sits over the middle of the main
|
||||
/// window the first time and where it was left after; it stays in front, taking focus back when
|
||||
/// the main window is clicked, and closes with it.
|
||||
#[tauri::command]
|
||||
async fn cmd_open_settings(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -124,53 +138,25 @@ async fn cmd_open_settings(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Over the middle of the main window the first time, as Yaak's; after that, where it was left
|
||||
let position = app_handle
|
||||
let main = app_handle
|
||||
.get_webview_window(MAIN_WINDOW_LABEL)
|
||||
.and_then(|main| centered_over(&main, SETTINGS_WINDOW_SIZE).ok());
|
||||
let config = CreateWindowConfig {
|
||||
url: "/",
|
||||
label: SETTINGS_WINDOW_LABEL,
|
||||
title: "Yaak Proxy Settings",
|
||||
inner_size: Some(SETTINGS_WINDOW_SIZE),
|
||||
position,
|
||||
initialization_script: tab.as_deref().map(settings_tab_script),
|
||||
hidden: true,
|
||||
hide_titlebar: true,
|
||||
..Default::default()
|
||||
};
|
||||
let win = yaak_window::window::create_window(&app_handle, config).map_err(|e| e.to_string())?;
|
||||
.ok_or("The main window is not open to open Settings over")?;
|
||||
let win = yaak_window::window::create_child_window(
|
||||
&main,
|
||||
"/",
|
||||
SETTINGS_WINDOW_NAME,
|
||||
"Yaak Proxy Settings",
|
||||
SETTINGS_WINDOW_SIZE,
|
||||
tab.as_deref().map(settings_tab_script),
|
||||
false,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height) = SETTINGS_WINDOW_MIN_SIZE;
|
||||
win.set_min_size(Some(tauri::LogicalSize::new(width, height))).map_err(|e| e.to_string())?;
|
||||
handle_menu_events(&win);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where a window of `size` sits centred over `parent`, in logical pixels.
|
||||
fn centered_over<R: Runtime>(
|
||||
parent: &WebviewWindow<R>,
|
||||
(width, height): (f64, f64),
|
||||
) -> tauri::Result<(f64, f64)> {
|
||||
let scale = parent.scale_factor()?;
|
||||
let pos = parent.inner_position()?.to_logical::<f64>(scale);
|
||||
let size = parent.inner_size()?.to_logical::<f64>(scale);
|
||||
Ok((pos.x + (size.width - width) / 2.0, pos.y + (size.height - height) / 2.0))
|
||||
}
|
||||
|
||||
/// Closing the main window closes Settings with it, which would otherwise keep the app open
|
||||
/// with nothing to set.
|
||||
fn close_settings_with<R: Runtime>(main: &WebviewWindow<R>) {
|
||||
let app_handle = main.app_handle().clone();
|
||||
main.on_window_event(move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event
|
||||
&& let Some(settings) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL)
|
||||
&& let Err(e) = settings.close()
|
||||
{
|
||||
warn!("Failed to close the settings window: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn rpc(
|
||||
router: State<'_, RpcRouter<ProxyCtx>>,
|
||||
@@ -188,11 +174,22 @@ fn set_go_menu(app: tauri::AppHandle, back: bool, forward: bool) {
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(yaak_mac_window::init())
|
||||
.plugin(yaak_mac_window::init());
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::default().build());
|
||||
}
|
||||
|
||||
builder
|
||||
.manage(tokio::sync::Mutex::new(updates::ProxyUpdater::default()))
|
||||
// The one update this process runs at a time, whichever window or trigger asks
|
||||
.manage(yaak_updater::UpdateGate::default())
|
||||
.setup(|app| {
|
||||
let data_dir = app.path().app_data_dir().expect("no app data dir");
|
||||
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
|
||||
@@ -240,7 +237,14 @@ pub fn run() {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![rpc, set_go_menu, cmd_open_settings])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
rpc,
|
||||
set_go_menu,
|
||||
cmd_open_settings,
|
||||
updates::cmd_updater_enabled,
|
||||
updates::cmd_check_for_updates,
|
||||
updates::cmd_restart
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building yaak proxy tauri application")
|
||||
.run(|app_handle, event| {
|
||||
@@ -249,6 +253,8 @@ pub fn run() {
|
||||
let ctx = app_handle.state::<ProxyCtx>();
|
||||
ctx.shutdown();
|
||||
ctx.stop_control_api();
|
||||
// Relaunches after an update, once the system proxy is put back
|
||||
yaak_updater::restart::relaunch_if_requested();
|
||||
return;
|
||||
}
|
||||
if let RunEvent::Ready = event {
|
||||
@@ -262,10 +268,7 @@ pub fn run() {
|
||||
..Default::default()
|
||||
};
|
||||
match yaak_window::window::create_window(app_handle, config) {
|
||||
Ok(win) => {
|
||||
setup_window_menu(&win);
|
||||
close_settings_with(&win);
|
||||
}
|
||||
Ok(win) => setup_window_menu(&win),
|
||||
Err(e) => error!("Failed to create proxy window: {e:?}"),
|
||||
}
|
||||
}
|
||||
@@ -333,6 +336,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The page knows the Settings window by the label the shared code gives a child window
|
||||
/// opened under its name, or it would render the whole app in it.
|
||||
#[test]
|
||||
fn the_page_knows_the_settings_window_by_its_label() {
|
||||
assert_eq!(SETTINGS_WINDOW_LABEL, format!("other__{SETTINGS_WINDOW_NAME}"));
|
||||
let windows = include_str!("../../../apps/yaak-proxy/lib/windows.ts");
|
||||
assert!(
|
||||
windows.contains(&format!("SETTINGS_WINDOW_LABEL = \"{SETTINGS_WINDOW_LABEL}\"")),
|
||||
"the page's Settings window label is not {SETTINGS_WINDOW_LABEL}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The tab reaches the new window's page as a string literal, whatever it holds.
|
||||
#[test]
|
||||
fn the_settings_tab_script_quotes_the_tab() {
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
//! Yaak Proxy's update check. The prompt, download, install and relaunch are Yaak's, shared
|
||||
//! through `yaak-updater`; what differs is where the proxy asks and what it accepts.
|
||||
//!
|
||||
//! The channel and update behavior are the shared settings tabs' values, which the window
|
||||
//! keeps, so the window passes them with each check.
|
||||
//!
|
||||
//! A build without the `updater` feature, as development builds are, keeps the commands, which
|
||||
//! say there is no updater, but uses none of the checking itself.
|
||||
#![cfg_attr(not(feature = "updater"), allow(dead_code, unused_imports))]
|
||||
|
||||
use std::hash::BuildHasher;
|
||||
use std::time::Instant;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_updater::{UpdateGate, UpdateInstall, UpdateMode, UpdateTrigger};
|
||||
|
||||
/// Where Yaak Proxy asks for updates. The `/proxy/` segment tells the update server which app
|
||||
/// is asking; Yaak asks at `/check/...` and its answers are unaffected. A server that doesn't
|
||||
/// know the proxy yet answers 404, which is a failed check here, never Yaak's update.
|
||||
pub const UPDATE_ENDPOINT: &str =
|
||||
"https://update.yaak.app/proxy/check/{{target}}/{{arch}}/{{current_version}}";
|
||||
|
||||
/// How every Yaak Proxy updater artifact's file name starts, as the release workflow uploads
|
||||
/// it. An update whose file is named otherwise, such as Yaak's own `Yaak_aarch64.app.tar.gz`,
|
||||
/// is refused: it is signed by the same key, so the signature alone would let it replace the
|
||||
/// proxy with Yaak.
|
||||
pub const ARTIFACT_PREFIX: &str = "Yaak.Proxy_";
|
||||
|
||||
/// The file in the app's data directory holding the random key the update server buckets
|
||||
/// gradual rollouts by, so a Mac keeps its place in a rollout.
|
||||
const UPDATE_KEY_FILE: &str = "update-key";
|
||||
|
||||
/// When the last check ran, so background checks keep to their channel's interval.
|
||||
#[derive(Default)]
|
||||
pub struct ProxyUpdater {
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Whether this build has the updater, so the window shows its settings and menu item.
|
||||
#[tauri::command]
|
||||
pub fn cmd_updater_enabled() -> bool {
|
||||
cfg!(feature = "updater")
|
||||
}
|
||||
|
||||
/// Asks the update server for a newer Yaak Proxy on `channel` and, when there is one, offers
|
||||
/// it in this window. True when an update was found. A background check returns false at once
|
||||
/// when the channel's interval since the last check hasn't passed.
|
||||
#[tauri::command]
|
||||
pub async fn cmd_check_for_updates<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
updater: State<'_, Mutex<ProxyUpdater>>,
|
||||
channel: String,
|
||||
background: bool,
|
||||
auto_download: bool,
|
||||
) -> Result<bool, String> {
|
||||
let mode = UpdateMode::new(&channel);
|
||||
let trigger = if background { UpdateTrigger::Background } else { UpdateTrigger::User };
|
||||
let mut updater = updater.lock().await;
|
||||
if trigger == UpdateTrigger::Background
|
||||
&& let Some(last) = updater.last_check
|
||||
&& last.elapsed() < mode.check_interval()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
updater.last_check = Some(Instant::now());
|
||||
drop(updater);
|
||||
check(&window, mode, trigger, auto_download).await
|
||||
}
|
||||
|
||||
/// Relaunches the app after an update installed, through LaunchServices on macOS.
|
||||
#[tauri::command]
|
||||
pub fn cmd_restart<R: Runtime>(app_handle: tauri::AppHandle<R>) {
|
||||
yaak_updater::restart::request_restart(&app_handle);
|
||||
}
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
async fn check<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
mode: UpdateMode,
|
||||
trigger: UpdateTrigger,
|
||||
auto_download: bool,
|
||||
) -> Result<bool, String> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
// One check and one update at a time, whichever window or trigger asks
|
||||
let token = match window.state::<UpdateGate>().begin_check() {
|
||||
Ok(token) => token,
|
||||
Err(busy) => {
|
||||
info!("Not checking for updates: the update is {:?}", busy.state);
|
||||
if trigger == UpdateTrigger::User {
|
||||
busy.show(window);
|
||||
return Ok(true);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
info!("Checking for updates mode={mode} trigger={}", trigger.header_value());
|
||||
let endpoint = tauri::Url::parse(&endpoint()).map_err(|e| e.to_string())?;
|
||||
let update = window
|
||||
.updater_builder()
|
||||
.endpoints(vec![endpoint])
|
||||
.and_then(|b| b.header("X-Update-Mode", mode.to_string()))
|
||||
.and_then(|b| b.header("X-Update-Key", update_key(window)))
|
||||
.and_then(|b| b.header("X-Update-Trigger", trigger.header_value()))
|
||||
.and_then(|b| b.header("X-Install-Mode", "unknown"))
|
||||
.and_then(|b| b.build())
|
||||
.map_err(|e| e.to_string())?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(update) = update else {
|
||||
return Ok(false);
|
||||
};
|
||||
let file = artifact_file_name(update.download_url.path());
|
||||
if !is_proxy_artifact(file) {
|
||||
warn!(
|
||||
"Refused update {} at {}: not a Yaak Proxy artifact",
|
||||
update.version, update.download_url
|
||||
);
|
||||
return Err(format!("The update server offered {file}, which is not a Yaak Proxy update"));
|
||||
}
|
||||
|
||||
token.offer(window, update, trigger, auto_download, UpdateInstall::Integrated);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "updater"))]
|
||||
async fn check<R: Runtime>(
|
||||
_window: &WebviewWindow<R>,
|
||||
_mode: UpdateMode,
|
||||
_trigger: UpdateTrigger,
|
||||
_auto_download: bool,
|
||||
) -> Result<bool, String> {
|
||||
Err("This build of Yaak Proxy has no updater".to_string())
|
||||
}
|
||||
|
||||
/// Where checks go: [`UPDATE_ENDPOINT`], or, in a debug build only, `YAAK_PROXY_UPDATE_ENDPOINT`
|
||||
/// when it is set, to try the flow against a local server.
|
||||
#[cfg(feature = "updater")]
|
||||
fn endpoint() -> String {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(url) = std::env::var("YAAK_PROXY_UPDATE_ENDPOINT")
|
||||
&& !url.is_empty()
|
||||
{
|
||||
warn!("Checking for updates at {url}, from YAAK_PROXY_UPDATE_ENDPOINT");
|
||||
return url;
|
||||
}
|
||||
UPDATE_ENDPOINT.to_string()
|
||||
}
|
||||
|
||||
/// The last segment of a download URL's path.
|
||||
fn artifact_file_name(path: &str) -> &str {
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Whether a file is one of the proxy's updater artifacts, by the name the release gives it.
|
||||
fn is_proxy_artifact(file: &str) -> bool {
|
||||
file.starts_with(ARTIFACT_PREFIX)
|
||||
}
|
||||
|
||||
/// This Mac's update key, made once and kept in the data directory. Without one, the server
|
||||
/// buckets by address, which is still deterministic, so a failure here is only logged.
|
||||
fn update_key<R: Runtime>(window: &WebviewWindow<R>) -> String {
|
||||
// Tests leave the data directory alone; a mock app's would be Application Support itself
|
||||
if cfg!(test) {
|
||||
return "test".to_string();
|
||||
}
|
||||
let Ok(dir) = window.path().app_data_dir() else {
|
||||
return String::new();
|
||||
};
|
||||
let path = dir.join(UPDATE_KEY_FILE);
|
||||
if let Ok(key) = std::fs::read_to_string(&path)
|
||||
&& !key.trim().is_empty()
|
||||
{
|
||||
return key.trim().to_string();
|
||||
}
|
||||
// Random per process: each `RandomState` is seeded afresh
|
||||
let random = || std::collections::hash_map::RandomState::new().hash_one(std::process::id());
|
||||
let key = format!("{:016x}{:016x}", random(), random());
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
warn!("Failed to create the data directory for the update key: {e}");
|
||||
}
|
||||
if let Err(e) = std::fs::write(&path, &key) {
|
||||
warn!("Failed to save the update key: {e}");
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The endpoint is the update server's, with the proxy's own path, so Yaak's is untouched.
|
||||
#[test]
|
||||
fn the_endpoint_is_the_proxys_own() {
|
||||
let url = tauri::Url::parse(UPDATE_ENDPOINT).unwrap();
|
||||
assert_eq!(url.scheme(), "https");
|
||||
assert_eq!(url.host_str(), Some("update.yaak.app"));
|
||||
assert!(url.path().starts_with("/proxy/check/"), "{}", url.path());
|
||||
}
|
||||
|
||||
/// Only the proxy's artifacts install, never Yaak's, which the same key signs.
|
||||
#[test]
|
||||
fn only_proxy_artifacts_are_accepted() {
|
||||
let base = "/mountain-loop/yaak/releases/download/v2026.11.0/";
|
||||
for (file, ok) in [
|
||||
("Yaak.Proxy_aarch64.app.tar.gz", true),
|
||||
("Yaak.Proxy_x64.app.tar.gz", true),
|
||||
("Yaak_aarch64.app.tar.gz", false),
|
||||
("Yaak_x64.app.tar.gz", false),
|
||||
("Yaak.app.tar.gz", false),
|
||||
] {
|
||||
let name = artifact_file_name(&format!("{base}{file}")).to_string();
|
||||
assert_eq!(name, file);
|
||||
assert_eq!(is_proxy_artifact(&name), ok, "{file}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The release config carries the updater's key but not its endpoint, which is
|
||||
/// [`UPDATE_ENDPOINT`] alone, and builds the updater artifacts; it never turns on a license.
|
||||
#[test]
|
||||
fn the_release_config_has_the_updater_and_no_license() {
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.release.conf.json")).unwrap();
|
||||
let features: Vec<&str> = config["build"]["features"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|f| f.as_str())
|
||||
.collect();
|
||||
assert!(features.contains(&"updater"), "{features:?}");
|
||||
assert!(!features.contains(&"license"), "{features:?}");
|
||||
assert!(config["plugins"]["updater"]["pubkey"].as_str().is_some_and(|k| !k.is_empty()));
|
||||
assert!(config["plugins"]["updater"].get("endpoints").is_none());
|
||||
assert_eq!(config["bundle"]["createUpdaterArtifacts"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
/// The proxy verifies updates with the key Yaak's release signs them with.
|
||||
#[test]
|
||||
fn the_updater_key_is_yaaks() {
|
||||
let proxy: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.release.conf.json")).unwrap();
|
||||
let client: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../yaak-app-client/tauri.release.conf.json"))
|
||||
.unwrap();
|
||||
assert_eq!(proxy["plugins"]["updater"]["pubkey"], client["plugins"]["updater"]["pubkey"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every trigger makes (launch, focus, the menu, Settings), raced against a local
|
||||
/// update server through a mock app with the real updater plugin.
|
||||
#[cfg(all(test, feature = "updater"))]
|
||||
mod race_tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tauri::test::{MockRuntime, mock_builder, mock_context, noop_assets};
|
||||
use tauri::{Emitter, Listener, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
/// A local update server that always offers 99.0.0 as a proxy artifact. Returns its port.
|
||||
fn serve_update() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
let mut request = [0u8; 4096];
|
||||
let _ = stream.read(&mut request);
|
||||
if String::from_utf8_lossy(&request).contains(".app.tar.gz") {
|
||||
// A slow download, to check while it runs. Not a real archive, so the
|
||||
// install then fails its signature check and the run ends.
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
let body = "not an archive";
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let version = served_version().clone();
|
||||
let body = format!(
|
||||
r#"{{"version":"{version}","url":"http://127.0.0.1:{port}/Yaak.Proxy_aarch64.app.tar.gz","signature":"unused"}}"#
|
||||
);
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
/// Where a test app keeps its downloads, removed after each test.
|
||||
const TEST_IDENTIFIER: &str = "app.yaak.proxy.updates-test";
|
||||
|
||||
/// The version the test server offers, 99.0.0 unless a test publishes a newer one.
|
||||
fn served_version() -> std::sync::MutexGuard<'static, String> {
|
||||
static VERSION: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
|
||||
let mut version = VERSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if version.is_empty() {
|
||||
*version = "99.0.0".to_string();
|
||||
}
|
||||
version
|
||||
}
|
||||
|
||||
/// One test at a time, since they share the endpoint variable, the served version and the
|
||||
/// downloads directory.
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let guard = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*served_version() = "99.0.0".to_string();
|
||||
guard
|
||||
}
|
||||
|
||||
fn mock_app(port: u16) -> tauri::App<MockRuntime> {
|
||||
// SAFETY: only this module's tests read or write it, one at a time
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"YAAK_PROXY_UPDATE_ENDPOINT",
|
||||
format!(
|
||||
"http://127.0.0.1:{port}/proxy/check/{{{{target}}}}/{{{{arch}}}}/{{{{current_version}}}}"
|
||||
),
|
||||
);
|
||||
}
|
||||
let mut context = mock_context(noop_assets());
|
||||
context.config_mut().identifier = TEST_IDENTIFIER.to_string();
|
||||
context.config_mut().plugins.0.insert(
|
||||
"updater".to_string(),
|
||||
serde_json::json!({ "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVGRkFGMjQxRUNEOTQ3MzAKUldRd1I5bnNRZkw2NzRtMnRlWTN3R24xYUR3aGRsUjJzWGwvdHdEcGljb3ZJMUNlMjFsaHlqVU4K" }),
|
||||
);
|
||||
mock_builder()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(Mutex::new(ProxyUpdater::default()))
|
||||
.manage(UpdateGate::default())
|
||||
.build(context)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Removes the test app's downloads once the test ends, passing or not.
|
||||
struct CleanUp(tauri::AppHandle<MockRuntime>);
|
||||
|
||||
impl Drop for CleanUp {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(dir) = self.0.path().app_cache_dir()
|
||||
&& dir.ends_with(TEST_IDENTIFIER)
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window(app: &tauri::App<MockRuntime>, label: &str) -> WebviewWindow<MockRuntime> {
|
||||
WebviewWindowBuilder::new(app, label, WebviewUrl::default()).build().unwrap()
|
||||
}
|
||||
|
||||
/// Something the app showed a window about its update.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Shown {
|
||||
window: String,
|
||||
event: &'static str,
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Records what the app shows each of `windows` about its update.
|
||||
fn record(windows: &[&WebviewWindow<MockRuntime>]) -> mpsc::Receiver<Shown> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
for w in windows {
|
||||
for event in [
|
||||
yaak_updater::UPDATE_AVAILABLE_EVENT,
|
||||
yaak_updater::UPDATE_STATE_EVENT,
|
||||
yaak_updater::UPDATE_INSTALLED_EVENT,
|
||||
] {
|
||||
let tx = tx.clone();
|
||||
let window = w.label().to_string();
|
||||
w.listen(event, move |e| {
|
||||
let payload = serde_json::from_str(e.payload()).unwrap();
|
||||
let _ = tx.send(Shown { window: window.clone(), event, payload });
|
||||
});
|
||||
}
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// Everything shown until nothing more comes for a moment, well inside the 3 seconds a
|
||||
/// prompt waits for its window to answer.
|
||||
fn drain(rx: &mpsc::Receiver<Shown>) -> Vec<Shown> {
|
||||
let mut all = Vec::new();
|
||||
while let Ok(shown) = rx.recv_timeout(Duration::from_millis(700)) {
|
||||
all.push(shown);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
fn prompts(shown: &[Shown]) -> Vec<&Shown> {
|
||||
shown.iter().filter(|s| s.event == yaak_updater::UPDATE_AVAILABLE_EVENT).collect()
|
||||
}
|
||||
|
||||
fn reply_id(prompt: &Shown) -> String {
|
||||
prompt.payload["replyEventId"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Answers a prompt as its window's buttons do.
|
||||
fn answer(app: &tauri::App<MockRuntime>, reply: &str, response: serde_json::Value) {
|
||||
app.emit(reply, response).unwrap();
|
||||
}
|
||||
|
||||
fn ack(app: &tauri::App<MockRuntime>, reply: &str) {
|
||||
answer(app, reply, serde_json::json!({ "type": "ack" }));
|
||||
}
|
||||
|
||||
fn user_check(window: &WebviewWindow<MockRuntime>) -> Result<bool, String> {
|
||||
tauri::async_runtime::block_on(check(window, UpdateMode::Beta, UpdateTrigger::User, false))
|
||||
}
|
||||
|
||||
fn background_check(window: &WebviewWindow<MockRuntime>) -> Result<bool, String> {
|
||||
tauri::async_runtime::block_on(check(
|
||||
window,
|
||||
UpdateMode::Beta,
|
||||
UpdateTrigger::Background,
|
||||
false,
|
||||
))
|
||||
}
|
||||
|
||||
/// Waits for the gate to come back to idle, as a run ends.
|
||||
fn wait_for_idle(app: &tauri::App<MockRuntime>) {
|
||||
let gate = app.state::<UpdateGate>();
|
||||
for _ in 0..100 {
|
||||
if gate.state() == yaak_updater::UpdateState::Idle {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!("the update run never ended: {:?}", gate.state());
|
||||
}
|
||||
|
||||
/// The main window and Settings each check, as their Check for Updates… and Settings button
|
||||
/// do, at the same moment: only one prompt, and so one download and install, may start. The
|
||||
/// other window is told a check is under way.
|
||||
#[test]
|
||||
fn overlapping_checks_from_two_windows_start_one_update() {
|
||||
let _serial = serial();
|
||||
let app = mock_app(serve_update());
|
||||
let _clean = CleanUp(app.handle().clone());
|
||||
let main = window(&app, "main_0");
|
||||
let settings = window(&app, "settings");
|
||||
let rx = record(&[&main, &settings]);
|
||||
|
||||
let state = app.state::<Mutex<ProxyUpdater>>();
|
||||
let (a, b) = tauri::async_runtime::block_on(async {
|
||||
tokio::join!(
|
||||
cmd_check_for_updates(main.clone(), state.clone(), "beta".into(), false, false),
|
||||
cmd_check_for_updates(settings.clone(), state.clone(), "beta".into(), false, false),
|
||||
)
|
||||
});
|
||||
let shown = drain(&rx);
|
||||
// Settle every prompt, so no task is left waiting on a window
|
||||
for prompt in prompts(&shown) {
|
||||
answer(
|
||||
&app,
|
||||
&reply_id(prompt),
|
||||
serde_json::json!({ "type": "action", "action": "skip" }),
|
||||
);
|
||||
}
|
||||
assert_eq!(a, Ok(true));
|
||||
assert_eq!(b, Ok(true));
|
||||
let started = prompts(&shown);
|
||||
assert_eq!(started.len(), 1, "two checks started {} prompts: {shown:?}", started.len());
|
||||
let told: Vec<_> =
|
||||
shown.iter().filter(|s| s.event == yaak_updater::UPDATE_STATE_EVENT).collect();
|
||||
assert_eq!(told.len(), 1, "{shown:?}");
|
||||
assert_eq!(told[0].payload, serde_json::json!({ "state": "checking" }));
|
||||
assert_ne!(told[0].window, started[0].window, "the window that lost the race is told");
|
||||
wait_for_idle(&app);
|
||||
}
|
||||
|
||||
/// A check from another window, or a later background check, while a prompt waits on the
|
||||
/// user shows that same prompt, answered by the same run, rather than starting another.
|
||||
#[test]
|
||||
fn a_check_while_a_prompt_waits_shows_the_same_prompt() {
|
||||
let _serial = serial();
|
||||
let app = mock_app(serve_update());
|
||||
let _clean = CleanUp(app.handle().clone());
|
||||
let main = window(&app, "main_0");
|
||||
let settings = window(&app, "settings");
|
||||
let rx = record(&[&main, &settings]);
|
||||
|
||||
assert_eq!(user_check(&main), Ok(true));
|
||||
let first = drain(&rx);
|
||||
assert_eq!(prompts(&first).len(), 1, "{first:?}");
|
||||
let reply = reply_id(prompts(&first)[0]);
|
||||
ack(&app, &reply);
|
||||
|
||||
// The Settings button, then a background check as the main window comes forward
|
||||
assert_eq!(user_check(&settings), Ok(true));
|
||||
assert_eq!(background_check(&main), Ok(true));
|
||||
let again = drain(&rx);
|
||||
let shown_again = prompts(&again);
|
||||
assert_eq!(shown_again.len(), 2, "{again:?}");
|
||||
assert_eq!(shown_again[0].window, "settings");
|
||||
assert_eq!(shown_again[1].window, "main_0");
|
||||
for prompt in &shown_again {
|
||||
assert_eq!(reply_id(prompt), reply, "the same run answers it");
|
||||
}
|
||||
|
||||
answer(&app, &reply, serde_json::json!({ "type": "action", "action": "skip" }));
|
||||
wait_for_idle(&app);
|
||||
}
|
||||
|
||||
/// A newer version found from Settings, while the main window's prompt for the older one
|
||||
/// waits, replaces that prompt in both windows, so neither keeps an Install button whose
|
||||
/// run is gone.
|
||||
#[test]
|
||||
fn a_newer_version_replaces_the_prompt_in_every_window() {
|
||||
let _serial = serial();
|
||||
let app = mock_app(serve_update());
|
||||
let _clean = CleanUp(app.handle().clone());
|
||||
let main = window(&app, "main_0");
|
||||
let settings = window(&app, "settings");
|
||||
let rx = record(&[&main, &settings]);
|
||||
|
||||
assert_eq!(user_check(&main), Ok(true));
|
||||
let first = drain(&rx);
|
||||
let old_reply = reply_id(prompts(&first)[0]);
|
||||
ack(&app, &old_reply);
|
||||
|
||||
*served_version() = "99.1.0".to_string();
|
||||
assert_eq!(user_check(&settings), Ok(true));
|
||||
let replaced = drain(&rx);
|
||||
let new_prompts = prompts(&replaced);
|
||||
assert_eq!(new_prompts.len(), 2, "{replaced:?}");
|
||||
let mut windows: Vec<_> = new_prompts.iter().map(|p| p.window.as_str()).collect();
|
||||
windows.sort();
|
||||
assert_eq!(windows, vec!["main_0", "settings"]);
|
||||
let new_reply = reply_id(new_prompts[0]);
|
||||
for prompt in &new_prompts {
|
||||
assert_eq!(prompt.payload["version"], "99.1.0");
|
||||
assert_eq!(reply_id(prompt), new_reply, "one run answers both");
|
||||
}
|
||||
assert_ne!(new_reply, old_reply);
|
||||
|
||||
ack(&app, &new_reply);
|
||||
answer(&app, &new_reply, serde_json::json!({ "type": "action", "action": "skip" }));
|
||||
wait_for_idle(&app);
|
||||
}
|
||||
|
||||
/// While the update downloads (and so installs), no check starts another: the user who asks
|
||||
/// is told where it stands, and a background check shows nothing.
|
||||
#[test]
|
||||
fn checks_while_downloading_start_nothing() {
|
||||
let _serial = serial();
|
||||
let app = mock_app(serve_update());
|
||||
let _clean = CleanUp(app.handle().clone());
|
||||
let main = window(&app, "main_0");
|
||||
let settings = window(&app, "settings");
|
||||
let rx = record(&[&main, &settings]);
|
||||
|
||||
assert_eq!(user_check(&main), Ok(true));
|
||||
let first = drain(&rx);
|
||||
let reply = reply_id(prompts(&first)[0]);
|
||||
ack(&app, &reply);
|
||||
answer(&app, &reply, serde_json::json!({ "type": "action", "action": "install" }));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert_eq!(
|
||||
app.state::<UpdateGate>().state(),
|
||||
yaak_updater::UpdateState::Downloading { version: "99.0.0".into() }
|
||||
);
|
||||
|
||||
assert_eq!(user_check(&settings), Ok(true));
|
||||
assert_eq!(background_check(&main), Ok(false));
|
||||
let during = drain(&rx);
|
||||
assert!(prompts(&during).is_empty(), "no new prompt: {during:?}");
|
||||
let told: Vec<_> =
|
||||
during.iter().filter(|s| s.event == yaak_updater::UPDATE_STATE_EVENT).collect();
|
||||
assert_eq!(told.len(), 1, "only the user who asked is told: {during:?}");
|
||||
assert_eq!(told[0].window, "settings");
|
||||
assert_eq!(
|
||||
told[0].payload,
|
||||
serde_json::json!({ "state": "downloading", "version": "99.0.0" })
|
||||
);
|
||||
|
||||
// The download isn't a signed archive, so the install fails and the run ends
|
||||
wait_for_idle(&app);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ pub const EXPORT_HAR_ID: &str = "file.export_har";
|
||||
/// The event the window hears File's Export All Flows as HAR by.
|
||||
pub const EXPORT_HAR_EVENT: &str = "menu_export_har";
|
||||
|
||||
/// The app menu's Check for Updates…, in a build with the updater.
|
||||
pub const CHECK_FOR_UPDATES_ID: &str = "app.check_for_updates";
|
||||
|
||||
/// The event the window hears Check for Updates… by, and checks on.
|
||||
pub const CHECK_FOR_UPDATES_EVENT: &str = "menu_check_for_updates";
|
||||
|
||||
/// Go's Back and Forward, which the window carries out along its own places, and enables while
|
||||
/// there is somewhere to go.
|
||||
pub const GO_BACK_ID: &str = "go.back";
|
||||
@@ -98,6 +104,9 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(app_handle, None, Some(about_metadata))?,
|
||||
#[cfg(feature = "updater")]
|
||||
&MenuItemBuilder::with_id(CHECK_FOR_UPDATES_ID, "Check for Updates…")
|
||||
.build(app_handle)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
&PredefinedMenuItem::services(app_handle, None)?,
|
||||
&PredefinedMenuItem::separator(app_handle)?,
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"build": {
|
||||
"features": []
|
||||
"features": ["updater"]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEVGRkFGMjQxRUNEOTQ3MzAKUldRd1I5bnNRZkw2NzRtMnRlWTN3R24xYUR3aGRsUjJzWGwvdHdEcGljb3ZJMUNlMjFsaHlqVU4K"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"publisher": "Yaak",
|
||||
"copyright": "Yaak",
|
||||
"homepage": "https://yaak.app",
|
||||
"category": "DeveloperTool",
|
||||
"createUpdaterArtifacts": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "yaak-updater"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
# The update prompt, download and install that Yaak and Yaak Proxy share, and the macOS-safe
|
||||
# restart that follows an install. Each app checks for updates its own way and hands what it
|
||||
# finds to this crate.
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
nanoid = "0.5.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "time", "macros"] }
|
||||
ts-rs = { workspace = true }
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type UpdateInfo = {
|
||||
replyEventId: string;
|
||||
version: string;
|
||||
downloaded: boolean;
|
||||
/**
|
||||
* How this update gets applied. Anything but `Integrated` means the app can't do it
|
||||
* itself and the user is told how to update instead.
|
||||
*/
|
||||
install: UpdateInstall;
|
||||
};
|
||||
|
||||
/**
|
||||
* How an update can be applied to this install.
|
||||
*/
|
||||
export type UpdateInstall = "integrated" | "flatpak" | "manual";
|
||||
|
||||
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
|
||||
|
||||
export type UpdateResponseAction = "install" | "skip";
|
||||
|
||||
/**
|
||||
* Where this process's update stands.
|
||||
*/
|
||||
export type UpdateState =
|
||||
| { state: "idle" }
|
||||
| { state: "checking" }
|
||||
| { state: "offered"; version: string }
|
||||
| { state: "downloading"; version: string }
|
||||
| { state: "installing"; version: string }
|
||||
| { state: "installed"; version: string };
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user