mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-18 00:47:19 +02:00
refactor: update logic and locks around vpn extensions
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -15,16 +15,21 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { ConsistencyResult, DetectedVpnExtension } from "@/types";
|
||||
import type {
|
||||
ConsistencyResult,
|
||||
DetectedVpnExtension,
|
||||
ExtensionScanState,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface GateFindings {
|
||||
/// Extensions that can reroute traffic. A warning: the user may proceed.
|
||||
/// Extensions that could reroute traffic. A warning: the user may proceed.
|
||||
vpnExtensions: DetectedVpnExtension[];
|
||||
scanState: string;
|
||||
scanState: ExtensionScanState;
|
||||
/// A measured exit/fingerprint mismatch. A block: the browser has not started.
|
||||
fingerprint: ConsistencyResult | null;
|
||||
/// A confirmed proxy-permission extension makes any exit measurement suspect.
|
||||
/// An extension holds the proxy permission, so the exit measurement may not
|
||||
/// describe the route the browser takes. A caveat on the block, not a waiver.
|
||||
measurementUnreliable: boolean;
|
||||
/// The exit has not been measured yet; the launch itself will still check.
|
||||
probePending: boolean;
|
||||
@@ -41,6 +46,9 @@ interface PreLaunchGateDialogProps {
|
||||
isOpen: boolean;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
/// Identifies this specific request, so state resets even when one gate
|
||||
/// replaces another without the dialog ever closing.
|
||||
requestId: number;
|
||||
findings: GateFindings | null;
|
||||
/// How many further profiles are queued behind this one; >0 offers to apply
|
||||
/// the same decision to all of them.
|
||||
@@ -50,51 +58,155 @@ interface PreLaunchGateDialogProps {
|
||||
onResult: (decision: GateDecision) => void;
|
||||
}
|
||||
|
||||
/// Everything the user can change while one gate is on screen, stamped with
|
||||
/// the gate it belongs to.
|
||||
interface GateAnswerState {
|
||||
requestId: number;
|
||||
ackFingerprint: boolean;
|
||||
ackExtensions: boolean;
|
||||
applyToRemaining: boolean;
|
||||
isMatching: boolean;
|
||||
decided: boolean;
|
||||
}
|
||||
|
||||
/// How long after a decision the footer stops accepting another one. Long
|
||||
/// enough that a double-click cannot answer the gate promoted by its first
|
||||
/// half, short enough that nobody deliberately answering two queued gates in a
|
||||
/// row notices it.
|
||||
const DECISION_COOLDOWN_MS = 500;
|
||||
|
||||
function answersFor(requestId: number): GateAnswerState {
|
||||
return {
|
||||
requestId,
|
||||
ackFingerprint: false,
|
||||
ackExtensions: false,
|
||||
applyToRemaining: false,
|
||||
isMatching: false,
|
||||
decided: false,
|
||||
};
|
||||
}
|
||||
|
||||
function ExtensionEntry({ extension }: { extension: DetectedVpnExtension }) {
|
||||
const { t } = useTranslation();
|
||||
const capability = t(
|
||||
extension.confidence === "confirmed"
|
||||
? "prelaunchGate.vpnExtensionConfirmed"
|
||||
: extension.confidence === "likely"
|
||||
? "prelaunchGate.vpnExtensionLikely"
|
||||
: "prelaunchGate.vpnExtensionCapability",
|
||||
);
|
||||
const source = t(
|
||||
extension.source === "donut"
|
||||
? "prelaunchGate.sourceDonut"
|
||||
: "prelaunchGate.sourceBrowser",
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="text-xs">
|
||||
<span className="font-medium">{extension.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{/* A version-less manifest is legal, and interpolating an empty string
|
||||
into the one template left a doubled space before the dash. */}
|
||||
{extension.version
|
||||
? t("prelaunchGate.vpnExtensionEntry", {
|
||||
version: extension.version,
|
||||
capability,
|
||||
source,
|
||||
})
|
||||
: t("prelaunchGate.vpnExtensionEntryNoVersion", {
|
||||
capability,
|
||||
source,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreLaunchGateDialog({
|
||||
isOpen,
|
||||
profileName,
|
||||
profileId,
|
||||
requestId,
|
||||
findings,
|
||||
remainingCount,
|
||||
onResult,
|
||||
}: PreLaunchGateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [ackFingerprint, setAckFingerprint] = useState(false);
|
||||
const [ackExtensions, setAckExtensions] = useState(false);
|
||||
const [applyToRemaining, setApplyToRemaining] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
// The dialog node is reused as the queue advances, so without this a double
|
||||
// click would decide for the next profile too.
|
||||
const [decided, setDecided] = useState(false);
|
||||
// All mutable state is stamped with the request it belongs to, and anything
|
||||
// stamped with an older request is ignored rather than reset. The dialog
|
||||
// never unmounts and a queued gate promotes the next profile without ever
|
||||
// closing it, so state carried across that boundary would tick a checkbox
|
||||
// for a profile the user never saw — and `decided` carried across it left
|
||||
// every button disabled on a dialog that also refused Escape, which is the
|
||||
// freeze this shape exists to make unrepresentable.
|
||||
//
|
||||
// Deliberately not an effect keyed on `requestId`: a reset effect whose body
|
||||
// reads none of its dependencies is exactly what a lint autofix reduces to
|
||||
// `[]`, and that is how the freeze shipped.
|
||||
const [state, setState] = useState<GateAnswerState>(() => answersFor(0));
|
||||
const answers = state.requestId === requestId ? state : answersFor(requestId);
|
||||
|
||||
// Keyed on profileId, not just isOpen: a queued gate promotes the next
|
||||
// profile without ever closing the dialog, so an isOpen-only reset would
|
||||
// carry the previous profile's ticked boxes — and persist an acknowledgement
|
||||
// against a profile the user never saw.
|
||||
// The gate on screen right now, readable from an async callback whose
|
||||
// closure was captured while an earlier gate was showing.
|
||||
const liveRequestRef = useRef(requestId);
|
||||
useEffect(() => {
|
||||
setAckFingerprint(false);
|
||||
setAckExtensions(false);
|
||||
setIsMatching(false);
|
||||
setDecided(false);
|
||||
}, []);
|
||||
liveRequestRef.current = requestId;
|
||||
}, [requestId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setApplyToRemaining(false);
|
||||
const patch = (next: Partial<GateAnswerState>) => {
|
||||
// A callback that resumes after its gate was answered must not write into
|
||||
// the slot the next gate is now using — that would silently untick boxes
|
||||
// the user has since ticked on a different profile.
|
||||
if (liveRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
}, [isOpen]);
|
||||
setState((prev) => ({
|
||||
...(prev.requestId === requestId ? prev : answersFor(requestId)),
|
||||
...next,
|
||||
requestId,
|
||||
}));
|
||||
};
|
||||
const {
|
||||
ackFingerprint,
|
||||
ackExtensions,
|
||||
applyToRemaining,
|
||||
isMatching,
|
||||
decided,
|
||||
} = answers;
|
||||
|
||||
const fingerprint = findings?.fingerprint ?? null;
|
||||
const extensions = findings?.vpnExtensions ?? [];
|
||||
// Two different claims, kept visually apart. The first names extensions as
|
||||
// VPN/proxy tools; the second says only that an extension holds Chromium's
|
||||
// proxy permission, which a download manager needs to route its own
|
||||
// transfers and which says nothing about what the extension is.
|
||||
const vpnExtensions = extensions.filter((e) => e.confidence !== "capability");
|
||||
const proxyCapableExtensions = extensions.filter(
|
||||
(e) => e.confidence === "capability",
|
||||
);
|
||||
const mismatches = fingerprint?.mismatches ?? [];
|
||||
const exitIp = fingerprint?.exit_ip ?? null;
|
||||
const isBlocked = fingerprint !== null;
|
||||
|
||||
// Two guards, because answering a gate promotes the next one into the same
|
||||
// DOM node rather than closing the dialog. The ref settles one gate exactly
|
||||
// once even if two clicks land in the same React batch; the cooldown stops
|
||||
// the second half of a double-click from answering a dialog that appeared
|
||||
// between the two clicks and that nobody has read.
|
||||
const decidedRef = useRef<number | null>(null);
|
||||
const lastDecisionAtRef = useRef(Number.NEGATIVE_INFINITY);
|
||||
|
||||
const decide = (proceed: boolean) => {
|
||||
if (decided) {
|
||||
if (decided || decidedRef.current === requestId) {
|
||||
return;
|
||||
}
|
||||
setDecided(true);
|
||||
const now = performance.now();
|
||||
if (now - lastDecisionAtRef.current < DECISION_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
decidedRef.current = requestId;
|
||||
lastDecisionAtRef.current = now;
|
||||
patch({ decided: true });
|
||||
onResult({
|
||||
proceed,
|
||||
ackFingerprint: ackFingerprint && isBlocked,
|
||||
@@ -107,21 +219,29 @@ export function PreLaunchGateDialog({
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
setIsMatching(true);
|
||||
const request = requestId;
|
||||
patch({ isMatching: true });
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
patch({ isMatching: false });
|
||||
// Rewriting the fingerprint takes long enough for the user to dismiss
|
||||
// this gate meanwhile. The profile change still stands, but the launch
|
||||
// it belonged to is already settled, and deciding now would answer
|
||||
// whichever gate took its place.
|
||||
if (liveRequestRef.current !== request) {
|
||||
return;
|
||||
}
|
||||
// The fingerprint the block was measured against no longer exists, so
|
||||
// this launch is abandoned rather than forced through with a stale
|
||||
// consent token; the user relaunches against the corrected profile.
|
||||
decide(false);
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsMatching(false);
|
||||
patch({ isMatching: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,8 +261,19 @@ export function PreLaunchGateDialog({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="sm:max-w-md" dismissible={false}>
|
||||
// Dismissible on purpose: cancelling is the safe outcome, so every way out
|
||||
// of this dialog — Escape, the close X, a click outside — resolves the
|
||||
// waiting launch as "don't start". A gate that can only be answered by two
|
||||
// buttons is one disabled button away from trapping the whole app.
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
decide(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning-text" />
|
||||
@@ -184,7 +315,7 @@ export function PreLaunchGateDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extensions.length > 0 && (
|
||||
{vpnExtensions.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-warning/50 bg-warning/10 p-3">
|
||||
<p className="font-medium">
|
||||
{t("prelaunchGate.vpnExtensionHeading")}
|
||||
@@ -193,23 +324,8 @@ export function PreLaunchGateDialog({
|
||||
{t("prelaunchGate.vpnExtensionIntro")}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{extensions.map((ext) => (
|
||||
<li key={ext.key} className="text-xs">
|
||||
<span className="font-medium">{ext.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("prelaunchGate.vpnExtensionEntry", {
|
||||
version: ext.version ?? "",
|
||||
capability:
|
||||
ext.confidence === "confirmed"
|
||||
? t("prelaunchGate.vpnExtensionConfirmed")
|
||||
: t("prelaunchGate.vpnExtensionLikely"),
|
||||
source:
|
||||
ext.source === "donut"
|
||||
? t("prelaunchGate.sourceDonut")
|
||||
: t("prelaunchGate.sourceBrowser"),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
{vpnExtensions.map((ext) => (
|
||||
<ExtensionEntry key={ext.key} extension={ext} />
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -218,6 +334,22 @@ export function PreLaunchGateDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyCapableExtensions.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/40 p-3">
|
||||
<p className="font-medium">
|
||||
{t("prelaunchGate.proxyCapableHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.proxyCapableIntro")}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{proxyCapableExtensions.map((ext) => (
|
||||
<ExtensionEntry key={ext.key} extension={ext} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{findings?.measurementUnreliable && isBlocked && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.measurementUnreliable")}
|
||||
@@ -240,7 +372,7 @@ export function PreLaunchGateDialog({
|
||||
<Checkbox
|
||||
id="gate-ack-fingerprint"
|
||||
checked={ackFingerprint}
|
||||
onCheckedChange={(v) => setAckFingerprint(v === true)}
|
||||
onCheckedChange={(v) => patch({ ackFingerprint: v === true })}
|
||||
/>
|
||||
<Label htmlFor="gate-ack-fingerprint" className="text-xs">
|
||||
{t("prelaunchGate.dontBlockAgain")}
|
||||
@@ -252,7 +384,7 @@ export function PreLaunchGateDialog({
|
||||
<Checkbox
|
||||
id="gate-ack-extensions"
|
||||
checked={ackExtensions}
|
||||
onCheckedChange={(v) => setAckExtensions(v === true)}
|
||||
onCheckedChange={(v) => patch({ ackExtensions: v === true })}
|
||||
/>
|
||||
<Label htmlFor="gate-ack-extensions" className="text-xs">
|
||||
{t("prelaunchGate.dontWarnExtensions")}
|
||||
@@ -264,7 +396,9 @@ export function PreLaunchGateDialog({
|
||||
<Checkbox
|
||||
id="gate-apply-remaining"
|
||||
checked={applyToRemaining}
|
||||
onCheckedChange={(v) => setApplyToRemaining(v === true)}
|
||||
onCheckedChange={(v) =>
|
||||
patch({ applyToRemaining: v === true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="gate-apply-remaining" className="text-xs">
|
||||
{t("prelaunchGate.applyToRemaining")}
|
||||
@@ -276,11 +410,14 @@ export function PreLaunchGateDialog({
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
{/* Cancel is the default action: the browser has not started, and
|
||||
not starting it is the safe outcome. */}
|
||||
not starting it is the safe outcome. Never disabled by `decided`
|
||||
— `decide` is already idempotent, and the one control that ends
|
||||
the dialog safely must not be something a stale flag can switch
|
||||
off. */}
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => decide(false)}
|
||||
disabled={isMatching || decided}
|
||||
disabled={isMatching}
|
||||
autoFocus
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
|
||||
Reference in New Issue
Block a user