mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Yaak Proxy: fix review findings in the decryption UI
Count Reconnect's connections within its own scope, refresh the pass-through set from Try Again's answer, report a failed trust, keep the pill's switch where the app can't trust the certificate, and keep a failed tunnel's error across Type and Size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0425069f4
commit
f5ff9cb63e
8 files changed
+91
-12
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Flow, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
@@ -59,7 +60,13 @@ export function EncryptedCard({ flow }: { flow: Flow }) {
|
||||
const showToast = useShowToast();
|
||||
const fail = (err: Error) =>
|
||||
showToast({ id: "encrypted-card", color: "danger", message: err.message });
|
||||
const forget = useRpcMutation("forget_passthrough", { onError: fail });
|
||||
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);
|
||||
|
||||
@@ -1044,6 +1044,16 @@ function encryptedCell({ column, flow, span, selected }: CellProps): ReactElemen
|
||||
</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")}>
|
||||
|
||||
@@ -13,7 +13,7 @@ import classNames from "classnames";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDecrypt, useTrustCertificate } from "../hooks/useDecrypt";
|
||||
import { canTrustHere, useDecrypt, useTrustCertificate } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
@@ -51,7 +51,12 @@ export function ProxyStatus() {
|
||||
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
||||
const decryptList = useDecryptList();
|
||||
// Something is listed to decrypt but the certificate isn't trusted, so it can't be.
|
||||
const broken = caStatus.data != null && !caStatus.data.trusted && decryptsAnything(decryptList);
|
||||
// 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;
|
||||
|
||||
@@ -324,6 +324,16 @@ async function answer(cmd: string, payload: unknown): Promise<unknown> {
|
||||
}
|
||||
case "reconnect_tunnels":
|
||||
return { closed: 1 };
|
||||
case "list_reconnectable_tunnels":
|
||||
return Array.from({ length: Number(params.get("still") ?? 3) }, (_, i) => ({
|
||||
id: `harness_${i}`,
|
||||
host: "www.google.com",
|
||||
port: 443,
|
||||
appName: "Dia",
|
||||
appBundleId: null,
|
||||
flowId: null,
|
||||
reason: "not_listed",
|
||||
}));
|
||||
case "system_proxy_status":
|
||||
return {
|
||||
enabled: !params.has("firstrun"),
|
||||
|
||||
@@ -3,7 +3,13 @@ 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 } from "../lib/decryptChoice";
|
||||
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";
|
||||
@@ -30,10 +36,18 @@ export function useTrusting(): boolean {
|
||||
/** 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}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -110,7 +124,10 @@ export function useDecrypt() {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const toast = choiceToast(choice, result.stillEncrypted);
|
||||
// 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: (
|
||||
@@ -156,6 +173,19 @@ export function useDecrypt() {
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { applyChoice, type ChoiceDeps, choiceToast } from "./decryptChoice";
|
||||
import { applyChoice, type ChoiceDeps, choiceToast, reconnectEntries } from "./decryptChoice";
|
||||
|
||||
function deps(over: Partial<ChoiceDeps> = {}) {
|
||||
const calls: string[] = [];
|
||||
@@ -117,3 +117,11 @@ describe("the toast after a choice", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,17 @@ export async function applyChoice(deps: ChoiceDeps, edit: ListEdit): Promise<Cho
|
||||
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;
|
||||
@@ -67,10 +78,7 @@ export function choiceToast(choice: DecryptChoice, stillEncrypted: number): Choi
|
||||
if (stillEncrypted <= 0)
|
||||
return { message: choice.message, sub: choice.sub ?? null, reconnect: [] };
|
||||
const n = stillEncrypted.toLocaleString();
|
||||
const reconnect = [
|
||||
...choice.edit.add.filter((e) => !e.trim().startsWith("!")),
|
||||
...choice.edit.remove.filter((e) => e.trim().startsWith("!")),
|
||||
];
|
||||
const reconnect = reconnectEntries(choice.edit);
|
||||
return {
|
||||
message: choice.message,
|
||||
sub: `${n} open ${stillEncrypted === 1 ? "connection stays" : "connections stay"} encrypted`,
|
||||
|
||||
@@ -340,7 +340,8 @@ export function buildSidebarTree(
|
||||
parent.item.count++;
|
||||
addPlace(app, place.appRows, flow.tunnelled);
|
||||
}
|
||||
if (encrypted > 0 && encrypted < flows.length) {
|
||||
// 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;
|
||||
|
||||
Reference in new issue
Block a user