mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-18 00:47:19 +02:00
feat: prevent launch with inconsistent geodata
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user