feat: prevent launch with inconsistent geodata

This commit is contained in:
zhom
2026-08-05 21:39:10 -07:00
parent 29cb83d063
commit 39bbdcb547
41 changed files with 4010 additions and 644 deletions
@@ -1,176 +0,0 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
export interface ConsistencyResult {
consistent: boolean;
checked: boolean;
exit_ip: string | null;
exit_country_code: string | null;
exit_timezone: string | null;
fingerprint_timezone: string | null;
fingerprint_language: string | null;
mismatches: string[];
}
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
export function isConsistencyWarningSuppressed(profileId: string): boolean {
try {
return (
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
localStorage.getItem(perProfileKey(profileId)) === "1"
);
} catch {
return false;
}
}
interface ConsistencyWarningDialogProps {
isOpen: boolean;
onClose: () => void;
profileName: string;
profileId: string;
result: ConsistencyResult | null;
}
export function ConsistencyWarningDialog({
isOpen,
onClose,
profileName,
profileId,
result,
}: ConsistencyWarningDialogProps) {
const { t } = useTranslation();
const [dontWarnAgain, setDontWarnAgain] = useState(false);
const [isMatching, setIsMatching] = useState(false);
const handleClose = () => {
if (dontWarnAgain) {
try {
localStorage.setItem(perProfileKey(profileId), "1");
} catch {
// localStorage unavailable — nothing to persist
}
}
setDontWarnAgain(false);
onClose();
};
const mismatches = result?.mismatches ?? [];
const exitIp = result?.exit_ip ?? null;
const handleMatch = async () => {
if (!exitIp) {
return;
}
setIsMatching(true);
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
handleClose();
} catch (e) {
showErrorToast(translateBackendError(t, e));
} finally {
setIsMatching(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning-text" />
{t("consistencyWarning.title")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t("consistencyWarning.intro", { name: profileName })}
</p>
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
{mismatches.includes("timezone") && (
<div>
<p className="font-medium">
{t("consistencyWarning.timezoneTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.timezoneDetail", {
exit: result?.exit_timezone ?? "?",
fingerprint: result?.fingerprint_timezone ?? "?",
})}
</p>
</div>
)}
{mismatches.includes("language") && (
<div>
<p className="font-medium">
{t("consistencyWarning.languageTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.languageDetail", {
country: result?.exit_country_code ?? "?",
fingerprint: result?.fingerprint_language ?? "?",
})}
</p>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.explainer")}
</p>
<label
htmlFor="consistency-dont-warn"
className="flex cursor-pointer items-center gap-2 text-xs"
>
<Checkbox
id="consistency-dont-warn"
checked={dontWarnAgain}
onCheckedChange={(v) => setDontWarnAgain(v === true)}
/>
{t("consistencyWarning.dontWarnAgain")}
</label>
</div>
<div className="flex justify-end gap-2">
<RippleButton
variant="outline"
onClick={handleClose}
disabled={isMatching}
>
{t("common.buttons.close")}
</RippleButton>
{exitIp && (
<RippleButton onClick={handleMatch} disabled={isMatching}>
{isMatching
? t("consistencyWarning.matching")
: t("consistencyWarning.matchToProxy")}
</RippleButton>
)}
</div>
</DialogContent>
</Dialog>
);
}
+312
View File
@@ -0,0 +1,312 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuTriangleAlert } from "react-icons/lu";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 { RippleButton } from "./ui/ripple";
export interface GateFindings {
/// Extensions that can reroute traffic. A warning: the user may proceed.
vpnExtensions: DetectedVpnExtension[];
scanState: string;
/// 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.
measurementUnreliable: boolean;
/// The exit has not been measured yet; the launch itself will still check.
probePending: boolean;
}
export interface GateDecision {
proceed: boolean;
ackFingerprint: boolean;
ackExtensionKeys: string[];
applyToRemaining: boolean;
}
interface PreLaunchGateDialogProps {
isOpen: boolean;
profileName: string;
profileId: string;
findings: GateFindings | null;
/// How many further profiles are queued behind this one; >0 offers to apply
/// the same decision to all of them.
remainingCount: number;
/// The single exit route. Every path out of this dialog calls it exactly
/// once, so a caller awaiting a decision can never be left hanging.
onResult: (decision: GateDecision) => void;
}
export function PreLaunchGateDialog({
isOpen,
profileName,
profileId,
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);
// 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.
useEffect(() => {
setAckFingerprint(false);
setAckExtensions(false);
setIsMatching(false);
setDecided(false);
}, []);
useEffect(() => {
if (isOpen) {
setApplyToRemaining(false);
}
}, [isOpen]);
const fingerprint = findings?.fingerprint ?? null;
const extensions = findings?.vpnExtensions ?? [];
const mismatches = fingerprint?.mismatches ?? [];
const exitIp = fingerprint?.exit_ip ?? null;
const isBlocked = fingerprint !== null;
const decide = (proceed: boolean) => {
if (decided) {
return;
}
setDecided(true);
onResult({
proceed,
ackFingerprint: ackFingerprint && isBlocked,
ackExtensionKeys: ackExtensions ? extensions.map((e) => e.key) : [],
applyToRemaining,
});
};
const handleMatchFingerprint = async () => {
if (!exitIp) {
return;
}
setIsMatching(true);
try {
await invoke("match_profile_fingerprint_to_exit", {
profileId,
exitIp,
});
showSuccessToast(t("consistencyWarning.matchSuccess"));
// 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);
}
};
const scanNotice = (() => {
switch (findings?.scanState) {
case "encrypted":
return t("prelaunchGate.scanIncompleteEncrypted");
case "ephemeral":
return t("prelaunchGate.scanIncompleteEphemeral");
case "partial":
return t("prelaunchGate.scanIncompletePartial");
case "missing":
return t("prelaunchGate.scanIncompleteMissing");
default:
return null;
}
})();
return (
<Dialog open={isOpen}>
<DialogContent className="sm:max-w-md" dismissible={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning-text" />
{isBlocked
? t("prelaunchGate.titleBlocked")
: t("prelaunchGate.titleWarning")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p className="text-muted-foreground">
{t("prelaunchGate.intro", { name: profileName })}
</p>
{isBlocked && (
<div className="space-y-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="font-medium">
{t("prelaunchGate.fingerprintHeading")}
</p>
{mismatches.includes("timezone") && (
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.timezoneDetail", {
exit: fingerprint?.exit_timezone ?? "?",
fingerprint: fingerprint?.fingerprint_timezone ?? "?",
})}
</p>
)}
{mismatches.includes("language") && (
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.languageDetail", {
country: fingerprint?.exit_country_code ?? "?",
fingerprint: fingerprint?.fingerprint_language ?? "?",
})}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("consistencyWarning.explainer")}
</p>
</div>
)}
{extensions.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")}
</p>
<p className="text-xs text-muted-foreground">
{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>
))}
</ul>
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.vpnExtensionExplainer")}
</p>
</div>
)}
{findings?.measurementUnreliable && isBlocked && (
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.measurementUnreliable")}
</p>
)}
{findings?.probePending && !isBlocked && (
<p className="text-xs text-muted-foreground">
{t("prelaunchGate.probePending")}
</p>
)}
{scanNotice && (
<p className="text-xs text-muted-foreground">{scanNotice}</p>
)}
<div className="space-y-2">
{isBlocked && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-ack-fingerprint"
checked={ackFingerprint}
onCheckedChange={(v) => setAckFingerprint(v === true)}
/>
<Label htmlFor="gate-ack-fingerprint" className="text-xs">
{t("prelaunchGate.dontBlockAgain")}
</Label>
</div>
)}
{extensions.length > 0 && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-ack-extensions"
checked={ackExtensions}
onCheckedChange={(v) => setAckExtensions(v === true)}
/>
<Label htmlFor="gate-ack-extensions" className="text-xs">
{t("prelaunchGate.dontWarnExtensions")}
</Label>
</div>
)}
{remainingCount > 0 && (
<div className="flex items-center gap-x-2">
<Checkbox
id="gate-apply-remaining"
checked={applyToRemaining}
onCheckedChange={(v) => setApplyToRemaining(v === true)}
/>
<Label htmlFor="gate-apply-remaining" className="text-xs">
{t("prelaunchGate.applyToRemaining")}
</Label>
</div>
)}
</div>
</div>
<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. */}
<RippleButton
variant="outline"
onClick={() => decide(false)}
disabled={isMatching || decided}
autoFocus
>
{t("common.buttons.cancel")}
</RippleButton>
<div className="flex gap-2">
{isBlocked && exitIp && (
<RippleButton
variant="outline"
onClick={() => void handleMatchFingerprint()}
disabled={isMatching || decided}
>
{isMatching
? t("consistencyWarning.matching")
: t("consistencyWarning.matchToProxy")}
</RippleButton>
)}
<RippleButton
variant={isBlocked ? "destructive" : "default"}
onClick={() => decide(true)}
disabled={isMatching || decided}
>
{t("prelaunchGate.launchAnyway")}
</RippleButton>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -242,7 +242,7 @@ interface TableMeta {
setLaunchingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
setStoppingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
// Overflow actions
onAssignProfilesToGroup?: (profileIds: string[]) => void;
@@ -1394,7 +1394,7 @@ BotCell.displayName = "BotCell";
interface ProfilesDataTableProps {
profiles: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
onCloneProfile: (profile: BrowserProfile) => void | Promise<void>;
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
+8 -1
View File
@@ -28,7 +28,9 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import { showErrorToast } from "@/lib/toast-utils";
import type { BrowserProfile } from "@/types";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
@@ -108,10 +110,15 @@ export function ProfileSelectorDialog({
await invoke("open_url_with_profile", {
profileId: selected.id,
url,
consentToken: null,
});
onClose();
} catch (error) {
console.error("Failed to open URL with profile:", error);
// This path reaches the browser without going through page.tsx's gate,
// so a launch the gate blocks surfaces here. Without a toast the deep
// link would simply appear to do nothing.
showErrorToast(translateBackendError(t, error));
} finally {
setIsLaunching(false);
if (selected) {
@@ -122,7 +129,7 @@ export function ProfileSelectorDialog({
});
}
}
}, [selectedProfile, url, onClose, profiles]);
}, [selectedProfile, url, onClose, profiles, t]);
const handleCancel = useCallback(() => {
setSelectedProfile(null);
+54 -27
View File
@@ -73,6 +73,8 @@ interface AppSettings {
api_token?: string;
disable_auto_updates?: boolean;
keep_decrypted_profiles_in_ram?: boolean;
fingerprint_gate_disabled?: boolean;
vpn_extension_warning_disabled?: boolean;
}
interface CustomThemeState {
@@ -127,15 +129,6 @@ export function SettingsDialog({
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
const [consistencyWarningEnabled, setConsistencyWarningEnabled] = useState(
() => {
try {
return localStorage.getItem("consistency-warn-disabled") !== "1";
} catch {
return true;
}
},
);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
const [requestingPermission, setRequestingPermission] =
@@ -267,9 +260,28 @@ export function SettingsDialog({
? normalizeThemeColors(appSettings.custom_theme)
: tokyoNightTheme.colors,
};
setSettings(merged);
setOriginalSettings(merged);
originalSettingsRef.current = merged;
// One-shot migration off the old localStorage flag. Without it, a user
// who explicitly turned the warning off would start getting hard blocks
// after updating — the single most likely support complaint here.
let migrated = merged;
try {
if (
localStorage.getItem("consistency-warn-disabled") === "1" &&
!merged.fingerprint_gate_disabled
) {
migrated = { ...merged, fingerprint_gate_disabled: true };
await invoke<AppSettings>("save_app_settings", {
settings: migrated,
});
}
localStorage.removeItem("consistency-warn-disabled");
} catch (err) {
console.warn("Failed to migrate consistency warning preference:", err);
}
setSettings(migrated);
setOriginalSettings(migrated);
originalSettingsRef.current = migrated;
hasLoadedSettingsRef.current = true;
setHasLoadedSettings(true);
@@ -687,7 +699,11 @@ export function SettingsDialog({
(settings.theme !== "custom" &&
JSON.stringify(settings.custom_theme ?? {}) !==
JSON.stringify(originalSettings.custom_theme ?? {})) ||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
settings.disable_auto_updates !== originalSettings.disable_auto_updates ||
settings.fingerprint_gate_disabled !==
originalSettings.fingerprint_gate_disabled ||
settings.vpn_extension_warning_disabled !==
originalSettings.vpn_extension_warning_disabled;
return (
<>
@@ -1392,21 +1408,32 @@ export function SettingsDialog({
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.consistencyWarning")}
checked={consistencyWarningEnabled}
checked={!(settings.fingerprint_gate_disabled ?? false)}
onCheckedChange={(v) => {
setConsistencyWarningEnabled(v === true);
try {
if (v === true) {
localStorage.removeItem("consistency-warn-disabled");
} else {
localStorage.setItem(
"consistency-warn-disabled",
"1",
);
}
} catch {
// localStorage unavailable
}
updateSetting("fingerprint_gate_disabled", v !== true);
}}
/>
</div>
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.vpnExtensionWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.vpnExtensionWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.vpnExtensionWarning")}
checked={
!(settings.vpn_extension_warning_disabled ?? false)
}
onCheckedChange={(v) => {
updateSetting(
"vpn_extension_warning_disabled",
v !== true,
);
}}
/>
</div>