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
+321 -43
View File
@@ -13,11 +13,6 @@ import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
import { CommandPalette } from "@/components/command-palette";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
import {
type ConsistencyResult,
ConsistencyWarningDialog,
isConsistencyWarningSuppressed,
} from "@/components/consistency-warning-dialog";
import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
@@ -33,6 +28,11 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PermissionDialog } from "@/components/permission-dialog";
import {
type GateDecision,
type GateFindings,
PreLaunchGateDialog,
} from "@/components/pre-launch-gate-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import {
type PasswordDialogMode,
@@ -67,7 +67,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
import { translateBackendError } from "@/lib/backend-errors";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
@@ -88,7 +88,42 @@ import {
showSyncProgressToast,
showToast,
} from "@/lib/toast-utils";
import type { BrowserProfile, SyncSettings, WayfernConfig } from "@/types";
import type {
BrowserProfile,
ConsistencyResult,
PreLaunchChecks,
SyncSettings,
WayfernConfig,
} from "@/types";
type GateRequest = {
profile: BrowserProfile;
findings: GateFindings;
};
type LaunchResult = {
status: "launched" | "cancelled" | "blocked";
};
/**
* Rebuild the mismatch detail the gate dialog renders from a
* FINGERPRINT_EXIT_MISMATCH error's params. Every param is a string, because
* backend error params always are.
*/
function consistencyFromErrorParams(
params?: Record<string, string>,
): ConsistencyResult {
return {
consistent: false,
checked: true,
exit_ip: params?.exitIp || null,
exit_country_code: params?.exitCountry || null,
exit_timezone: params?.exitTimezone || null,
fingerprint_timezone: params?.fingerprintTimezone || null,
fingerprint_language: params?.fingerprintLanguage || null,
mismatches: (params?.mismatches ?? "").split(",").filter(Boolean),
};
}
type BrowserTypeString = "wayfern";
@@ -252,7 +287,7 @@ export default function Home() {
const { user: cloudUser } = useCloudAuth();
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
// Bulk run/stop is a paid (browser automation) feature, matching the
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
// actions disabled with a Pro badge.
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
// The rail needs to show a live run from every page, so the shell subscribes
@@ -365,10 +400,30 @@ export default function Home() {
useState<BrowserProfile | null>(null);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
const [consistencyWarning, setConsistencyWarning] = useState<{
profile: BrowserProfile;
result: ConsistencyResult;
// Pre-launch gate. Requests queue instead of overwriting a single resolver:
// a bulk run enqueues one per profile, and every waiter must settle or the
// Promise.allSettled below it never resolves and the bulk spinner sticks.
const gateQueueRef = useRef<
Array<{ req: GateRequest; resolve: (decision: GateDecision) => void }>
>([]);
const [gateState, setGateState] = useState<{
req: GateRequest;
remaining: number;
} | null>(null);
// Set when the user ticks "apply to the remaining profiles" during a bulk
// run, so the rest are answered without prompting again. Scoped to one bulk
// run and to the severity it was given for.
const blanketGateDecisionRef = useRef<{
decision: GateDecision;
/// Only auto-answers gates no more severe than the one the user saw. A
/// choice made on an extension warning must never silently bypass a hard
/// block on a later profile.
coversBlocking: boolean;
/// Identifies the bulk run, so a single launch started while a bulk run is
/// in flight still gets its own dialog.
runId: number;
} | null>(null);
const bulkRunIdRef = useRef(0);
// Owned by page.tsx so the command palette can request opening the profile
// info dialog. ProfilesDataTable consumes it through controlled props.
const [profileInfoDialog, setProfileInfoDialog] =
@@ -933,8 +988,137 @@ export default function Home() {
[selectedGroupId, t],
);
// Show the queue's head, and how many are waiting behind it.
// The backend gate downgrades to advisory rather than blocking when it
// cannot trust its own measurement (a confirmed VPN extension can reroute
// traffic away from the proxy it just probed), and for unattended launches.
// Without a listener that finding was emitted into the void.
useEffect(() => {
const unlisten = listen<ConsistencyResult>(
"fingerprint-consistency-warning",
(event) => {
const { exit_timezone, fingerprint_timezone } = event.payload;
showErrorToast(t("backendErrors.fingerprintExitMismatch"), {
// The cause differs by path (an unverifiable measurement vs an
// unattended launch), so state the measurement rather than guess.
description:
exit_timezone && fingerprint_timezone
? t("consistencyWarning.timezoneDetail", {
exit: exit_timezone,
fingerprint: fingerprint_timezone,
})
: undefined,
id: `fingerprint-mismatch-${exit_timezone ?? "unknown"}`,
});
},
);
return () => {
void unlisten.then((fn) => {
fn();
});
};
}, [t]);
const syncGateUi = useCallback(() => {
const queue = gateQueueRef.current;
setGateState(
queue.length > 0
? { req: queue[0].req, remaining: queue.length - 1 }
: null,
);
}, []);
const requestGateDecision = useCallback(
(req: GateRequest, runId?: number): Promise<GateDecision> => {
const blanket = blanketGateDecisionRef.current;
const isBlocking = req.findings.fingerprint !== null;
if (
blanket &&
blanket.runId === runId &&
(blanket.coversBlocking || !isBlocking)
) {
// A blanket answer covers only whether to launch. The acknowledgements
// it carried were about the first profile's specific mismatch and
// extensions, and must not be persisted against profiles the user
// never saw.
return Promise.resolve({
...blanket.decision,
ackFingerprint: false,
ackExtensionKeys: [],
});
}
return new Promise<GateDecision>((resolve) => {
gateQueueRef.current.push({ req, resolve });
syncGateUi();
});
},
[syncGateUi],
);
const settleGate = useCallback(
(decision: GateDecision) => {
const entry = gateQueueRef.current.shift();
entry?.resolve(decision);
if (decision.applyToRemaining) {
const coversBlocking = entry?.req.findings.fingerprint !== null;
blanketGateDecisionRef.current = {
decision,
coversBlocking,
runId: bulkRunIdRef.current,
};
// Drain the queue rather than leaving promises pending forever — but
// only those the blanket actually covers. A hard block still deserves
// its own dialog even after the user blanket-approved a warning.
const remaining = gateQueueRef.current.splice(0);
const kept = remaining.filter(
(queued) =>
!coversBlocking && queued.req.findings.fingerprint !== null,
);
for (const queued of remaining) {
if (kept.includes(queued)) {
continue;
}
queued.resolve({
...decision,
ackFingerprint: false,
ackExtensionKeys: [],
});
}
gateQueueRef.current = kept;
}
syncGateUi();
},
[syncGateUi],
);
const persistGateAcks = useCallback(
async (profileId: string, decision: GateDecision) => {
// Only on proceed. Cancel is the autofocused default action, so a stray
// Enter would otherwise permanently disarm the gate for this profile.
if (!decision.proceed) {
return;
}
if (!decision.ackFingerprint && decision.ackExtensionKeys.length === 0) {
return;
}
try {
await invoke("ack_launch_gate", {
profileId,
ackFingerprint: decision.ackFingerprint,
ackExtensionKeys: decision.ackExtensionKeys,
});
} catch (err) {
console.warn("Failed to persist launch gate acknowledgement:", err);
}
},
[],
);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
async (
profile: BrowserProfile,
opts?: { bulkRunId?: number },
): Promise<LaunchResult> => {
console.log("Starting launch for profile:", profile.name);
// Password-protected: must be unlocked before launch
@@ -947,7 +1131,7 @@ export default function Home() {
pendingLaunchAfterUnlockRef.current = profile;
setPasswordDialogMode("unlock");
setPasswordDialogProfile(profile);
return;
return { status: "cancelled" };
}
} catch (err) {
console.error("Failed to check profile lock state:", err);
@@ -966,7 +1150,7 @@ export default function Home() {
setWindowResizeWarningOpen(true);
});
if (!proceed) {
return;
return { status: "cancelled" };
}
}
} catch (error) {
@@ -974,30 +1158,106 @@ export default function Home() {
}
}
// Tier 1: purely local checks — an extension scan and a cached exit
// verdict. No network, no worker started, so a profile whose exit is
// already known blocks before the launch touches anything.
let consentToken: string | null = null;
try {
// One-shot migration of the old per-profile "don't warn again" flag,
// so a user who already dismissed this profile isn't hard-blocked by
// the new gate. Granted against the profile's current exit, which is
// the mismatch they were looking at when they dismissed it.
const legacySkipKey = `consistency-warn-skip-${profile.id}`;
if (localStorage.getItem(legacySkipKey) === "1") {
await invoke("ack_launch_gate", {
profileId: profile.id,
ackFingerprint: true,
ackExtensionKeys: [],
}).catch((err: unknown) => {
console.warn("Failed to migrate consistency skip flag:", err);
});
localStorage.removeItem(legacySkipKey);
}
const checks = await invoke<PreLaunchChecks>(
"get_profile_pre_launch_checks",
{ profileId: profile.id },
);
const blocked =
checks.consistency.checked && !checks.consistency.consistent;
if (blocked || checks.vpn_extensions.length > 0) {
const decision = await requestGateDecision(
{
profile,
findings: {
vpnExtensions: checks.vpn_extensions,
scanState: checks.scan_state,
fingerprint: blocked ? checks.consistency : null,
measurementUnreliable: checks.exit_measurement_unreliable,
probePending: checks.exit_probe_pending,
},
},
opts?.bulkRunId,
);
await persistGateAcks(profile.id, decision);
if (!decision.proceed) {
return { status: "cancelled" };
}
consentToken = checks.consent_token;
}
} catch (err) {
// Same posture as the password and window-resize gates: a check that
// cannot run must not make profiles unlaunchable.
console.warn("Pre-launch checks failed, launching anyway:", err);
}
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken,
});
console.log("Successfully launched profile:", result.name);
// Non-blocking: after a successful launch, check that the proxy exit
// node's timezone/country agrees with the fingerprint. A mismatch is a
// strong anti-bot tell even though the real device never leaks.
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
void invoke<ConsistencyResult>(
"check_profile_fingerprint_consistency",
{ profileId: profile.id },
)
.then((res) => {
if (res.checked && !res.consistent) {
setConsistencyWarning({ profile, result: res });
}
})
.catch((e) => {
console.warn("Consistency check failed:", e);
});
}
return { status: "launched" };
} catch (err: unknown) {
// Tier 2: the enforcing gate measured the exit mid-launch and stopped
// before spawning the browser. Offer the same decision, then retry
// exactly once with the token it minted — bounded, so a gate loop is
// structurally impossible.
const parsed = parseBackendError(err);
if (parsed?.code === "FINGERPRINT_EXIT_MISMATCH") {
const decision = await requestGateDecision(
{
profile,
findings: {
vpnExtensions: [],
scanState: "scanned",
fingerprint: consistencyFromErrorParams(parsed.params),
measurementUnreliable: false,
probePending: false,
},
},
opts?.bulkRunId,
);
await persistGateAcks(profile.id, decision);
if (!decision.proceed) {
return { status: "cancelled" };
}
try {
await invoke<BrowserProfile>("launch_browser_profile", {
profile,
consentToken: parsed.params?.token ?? null,
});
return { status: "launched" };
} catch (retryErr: unknown) {
showErrorToast(
t("errors.launchBrowserFailed", {
error: translateBackendError(t, retryErr),
}),
);
return { status: "blocked" };
}
}
console.error("Failed to launch browser:", err);
const errorMessage = translateBackendError(t, err);
showErrorToast(
@@ -1006,7 +1266,7 @@ export default function Home() {
throw err;
}
},
[t],
[persistGateAcks, requestGateDecision, t],
);
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
@@ -1203,15 +1463,34 @@ export default function Home() {
const executeBulkRun = useCallback(
async (targets: BrowserProfile[]) => {
setIsBulkActing(true);
blanketGateDecisionRef.current = null;
bulkRunIdRef.current += 1;
const runId = bulkRunIdRef.current;
try {
await Promise.allSettled(targets.map((p) => launchProfile(p)));
const results = await Promise.allSettled(
targets.map((p) => launchProfile(p, { bulkRunId: runId })),
);
const stopped = results.filter(
(r) => r.status === "fulfilled" && r.value.status !== "launched",
).length;
if (stopped > 0) {
// Previously a declined launch resolved to undefined, so allSettled
// reported success and the user was told nothing.
showErrorToast(
t("prelaunchGate.cancelledSummary", {
cancelled: stopped,
total: targets.length,
}),
);
}
setSelectedProfiles([]);
} finally {
blanketGateDecisionRef.current = null;
setIsBulkActing(false);
setPendingBulkAction(null);
}
},
[launchProfile],
[launchProfile, t],
);
const executeBulkStop = useCallback(
@@ -1898,14 +2177,13 @@ export default function Home() {
}}
/>
<ConsistencyWarningDialog
isOpen={consistencyWarning !== null}
onClose={() => {
setConsistencyWarning(null);
}}
profileName={consistencyWarning?.profile.name ?? ""}
profileId={consistencyWarning?.profile.id ?? ""}
result={consistencyWarning?.result ?? null}
<PreLaunchGateDialog
isOpen={gateState !== null}
profileName={gateState?.req.profile.name ?? ""}
profileId={gateState?.req.profile.id ?? ""}
findings={gateState?.req.findings ?? null}
remainingCount={gateState?.remaining ?? 0}
onResult={settleGate}
/>
{pendingUrls.map((pendingUrl) => (
@@ -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>
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless.",
"privacy": {
"consistencyWarning": "Fingerprint consistency warning",
"consistencyWarningDescription": "Warn on launch when a profile's timezone or language doesn't match its proxy exit node.",
"consistencyWarning": "Block on fingerprint mismatch",
"consistencyWarningDescription": "Stop the browser from starting when a profile's timezone or language doesn't match its proxy exit node. You can still choose to launch.",
"clearTraffic": "Clear all traffic history",
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
"clearTrafficSuccess": "Traffic history cleared"
"clearTrafficSuccess": "Traffic history cleared",
"vpnExtensionWarning": "VPN extension warning",
"vpnExtensionWarningDescription": "Warn before launching when a profile contains an extension that can reroute the browser's traffic."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "Too many requests. Wait a moment and try again.",
"remoteNoCapacity": "No remote host is free right now. Try again in a few minutes.",
"remoteNotEntitled": "Your plan does not include remote execution.",
"remoteInteractiveNotEntitled": "Your plan includes remote hours for the Cookie Bot only, not for hands-on remote sessions.",
"remoteSessionRefused": "The remote host refused this session.",
"remoteSessionNotFound": "That remote session no longer exists.",
"remoteSessionConflict": "This profile is already open somewhere else.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "A remote session just finished. Waiting for its changes to download before this profile can open here.",
"profileLockedByMember": "This profile is in use by {{email}}.",
"profileLockedElsewhere": "This profile is in use on another device.",
"profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again."
"profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again.",
"fingerprintExitMismatch": "The proxy exit node doesn't match this profile's fingerprint.",
"launchConsentExpired": "That confirmation is no longer valid. Try launching again.",
"vpnWorkerStartFailed": "Couldn't start the VPN connection: {{detail}}",
"exitProbeFailed": "Couldn't reach the proxy exit node to check its location."
},
"rail": {
"profiles": "Profiles",
@@ -2135,14 +2142,9 @@
"description": "Wipe cookies, history and cache when the browser closes. Extensions and bookmarks are kept."
},
"consistencyWarning": {
"title": "Fingerprint mismatch",
"intro": "Your proxy exit for \"{{name}}\" doesn't match this profile's fingerprint:",
"timezoneTitle": "Timezone mismatch",
"timezoneDetail": "Exit node is in {{exit}} but the fingerprint reports {{fingerprint}}.",
"languageTitle": "Language mismatch",
"languageDetail": "Exit country is {{country}} but the fingerprint language is {{fingerprint}}.",
"explainer": "A timezone or language that disagrees with your exit IP is a strong anti-bot signal, even though your real device never leaks. Align the fingerprint with the proxy location to reduce hostile treatment.",
"dontWarnAgain": "Don't warn again for this profile",
"matchToProxy": "Match fingerprint to proxy",
"matching": "Matching…",
"matchSuccess": "Fingerprint updated to match the proxy. Relaunch the profile to apply."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "Stopped by hand",
"unknown": "Unknown reason ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Launch blocked",
"titleWarning": "Before you launch",
"intro": "Review these issues with \"{{name}}\" before starting the browser.",
"fingerprintHeading": "Proxy exit doesn't match the fingerprint",
"vpnExtensionHeading": "VPN extension detected",
"vpnExtensionIntro": "Extensions in this profile that can reroute the browser's traffic:",
"vpnExtensionConfirmed": "Can change the proxy",
"vpnExtensionLikely": "May change the proxy",
"vpnExtensionExplainer": "If one of these routes your traffic elsewhere, the browser's real location will no longer match the timezone, language and geolocation this profile was created with, and Donut cannot detect that from the outside.",
"sourceDonut": "Managed by Donut",
"sourceBrowser": "Installed in the profile",
"measurementUnreliable": "Because a VPN extension can override the proxy, the exit check may not describe the route the browser actually takes.",
"scanIncompleteEncrypted": "This profile is encrypted, so only Donut-managed extensions could be checked.",
"scanIncompleteEphemeral": "This profile has no data yet, so only Donut-managed extensions could be checked.",
"scanIncompletePartial": "The extension scan was cut short, so some extensions may not be listed.",
"probePending": "The proxy exit hasn't been measured yet. Donut will check it while starting and stop if it doesn't match.",
"launchAnyway": "Launch anyway",
"dontBlockAgain": "Don't block again for this exact mismatch",
"dontWarnExtensions": "Don't warn again about these extensions",
"applyToRemaining": "Apply this choice to the remaining profiles",
"cancelledSummary": "{{cancelled}} of {{total}} launches cancelled",
"cancelled": "Launch cancelled",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "This profile has not been launched yet, so only Donut-managed extensions could be checked."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso.",
"privacy": {
"consistencyWarning": "Advertencia de consistencia de huella digital",
"consistencyWarningDescription": "Advertir al iniciar cuando la zona horaria o el idioma de un perfil no coincidan con su nodo de salida del proxy.",
"consistencyWarning": "Bloquear si la huella digital no coincide",
"consistencyWarningDescription": "Impide que el navegador se inicie cuando la zona horaria o el idioma de un perfil no coinciden con su nodo de salida del proxy. Aun así podrás iniciarlo.",
"clearTraffic": "Borrar todo el historial de tráfico",
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
"clearTrafficSuccess": "Historial de tráfico borrado"
"clearTrafficSuccess": "Historial de tráfico borrado",
"vpnExtensionWarning": "Aviso de extensión VPN",
"vpnExtensionWarningDescription": "Avisar antes de iniciar cuando un perfil contenga una extensión capaz de redirigir el tráfico del navegador."
}
},
"header": {
@@ -1870,6 +1872,7 @@
"remoteRateLimited": "Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.",
"remoteNoCapacity": "Ahora mismo no hay ninguna máquina remota libre. Inténtalo de nuevo en unos minutos.",
"remoteNotEntitled": "Tu plan no incluye la ejecución remota.",
"remoteInteractiveNotEntitled": "Tu plan incluye horas remotas solo para el Cookie Bot, no para sesiones remotas interactivas.",
"remoteSessionRefused": "La máquina remota rechazó esta sesión.",
"remoteSessionNotFound": "Esa sesión remota ya no existe.",
"remoteSessionConflict": "Este perfil ya está abierto en otro sitio.",
@@ -1896,7 +1899,11 @@
"profileRemoteSyncPending": "Una sesión remota acaba de terminar. Esperando a que se descarguen sus cambios antes de abrir este perfil aquí.",
"profileLockedByMember": "Este perfil está siendo usado por {{email}}.",
"profileLockedElsewhere": "Este perfil está en uso en otro dispositivo.",
"profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo."
"profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo.",
"fingerprintExitMismatch": "El nodo de salida del proxy no coincide con la huella digital de este perfil.",
"launchConsentExpired": "Esa confirmación ya no es válida. Vuelve a iniciar.",
"vpnWorkerStartFailed": "No se pudo iniciar la conexión VPN: {{detail}}",
"exitProbeFailed": "No se pudo contactar con el nodo de salida del proxy para comprobar su ubicación."
},
"rail": {
"profiles": "Perfiles",
@@ -2142,14 +2149,9 @@
"description": "Elimina cookies, historial y caché al cerrar el navegador. Las extensiones y los marcadores se conservan."
},
"consistencyWarning": {
"title": "Discrepancia de huella digital",
"intro": "La salida del proxy de \"{{name}}\" no coincide con la huella digital de este perfil:",
"timezoneTitle": "Discrepancia de zona horaria",
"timezoneDetail": "El nodo de salida está en {{exit}}, pero la huella digital indica {{fingerprint}}.",
"languageTitle": "Discrepancia de idioma",
"languageDetail": "El país de salida es {{country}}, pero el idioma de la huella digital es {{fingerprint}}.",
"explainer": "Una zona horaria o un idioma que no coincide con tu IP de salida es una fuerte señal anti-bot, aunque tu dispositivo real nunca se filtre. Alinea la huella digital con la ubicación del proxy para reducir el trato hostil.",
"dontWarnAgain": "No volver a advertir para este perfil",
"matchToProxy": "Ajustar huella al proxy",
"matching": "Ajustando…",
"matchSuccess": "Huella actualizada para coincidir con el proxy. Reinicia el perfil para aplicar."
@@ -2455,5 +2457,31 @@
"cancelledByUser": "Detenido a mano",
"unknown": "Motivo desconocido ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Inicio bloqueado",
"titleWarning": "Antes de iniciar",
"intro": "Revisa estos problemas de \"{{name}}\" antes de iniciar el navegador.",
"fingerprintHeading": "La salida del proxy no coincide con la huella digital",
"vpnExtensionHeading": "Extensión VPN detectada",
"vpnExtensionIntro": "Extensiones de este perfil que pueden redirigir el tráfico del navegador:",
"vpnExtensionConfirmed": "Puede cambiar el proxy",
"vpnExtensionLikely": "Podría cambiar el proxy",
"vpnExtensionExplainer": "Si alguna de ellas redirige tu tráfico a otro lugar, la ubicación real del navegador dejará de coincidir con la zona horaria, el idioma y la geolocalización con los que se creó este perfil, y Donut no puede detectarlo desde fuera.",
"sourceDonut": "Gestionada por Donut",
"sourceBrowser": "Instalada en el perfil",
"measurementUnreliable": "Como una extensión VPN puede anular el proxy, la comprobación de salida podría no reflejar la ruta que el navegador usa realmente.",
"scanIncompleteEncrypted": "Este perfil está cifrado, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
"scanIncompleteEphemeral": "Este perfil aún no tiene datos, así que solo se pudieron comprobar las extensiones gestionadas por Donut.",
"scanIncompletePartial": "El análisis de extensiones se interrumpió, así que puede que falten algunas.",
"probePending": "Todavía no se ha medido la salida del proxy. Donut la comprobará al iniciar y se detendrá si no coincide.",
"launchAnyway": "Iniciar de todos modos",
"dontBlockAgain": "No bloquear de nuevo por esta discrepancia exacta",
"dontWarnExtensions": "No volver a avisar sobre estas extensiones",
"applyToRemaining": "Aplicar esta decisión a los perfiles restantes",
"cancelledSummary": "{{cancelled}} de {{total}} inicios cancelados",
"cancelled": "Inicio cancelado",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Este perfil aún no se ha iniciado, así que solo se pudieron comprobar las extensiones gestionadas por Donut."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas.",
"privacy": {
"consistencyWarning": "Avertissement de cohérence d'empreinte",
"consistencyWarningDescription": "Avertir au lancement lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie proxy.",
"consistencyWarning": "Bloquer en cas d'empreinte incohérente",
"consistencyWarningDescription": "Empêche le navigateur de démarrer lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie. Vous pourrez tout de même lancer.",
"clearTraffic": "Effacer tout l'historique de trafic",
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
"clearTrafficSuccess": "Historique de trafic effacé"
"clearTrafficSuccess": "Historique de trafic effacé",
"vpnExtensionWarning": "Avertissement d'extension VPN",
"vpnExtensionWarningDescription": "Avertir avant le lancement lorsqu'un profil contient une extension capable de rerouter le trafic du navigateur."
}
},
"header": {
@@ -1870,6 +1872,7 @@
"remoteRateLimited": "Trop de requêtes. Patientez un instant et réessayez.",
"remoteNoCapacity": "Aucune machine distante n'est libre pour le moment. Réessayez dans quelques minutes.",
"remoteNotEntitled": "Votre forfait n'inclut pas l'exécution à distance.",
"remoteInteractiveNotEntitled": "Votre forfait inclut des heures distantes uniquement pour le Cookie Bot, pas pour les sessions distantes interactives.",
"remoteSessionRefused": "La machine distante a refusé cette session.",
"remoteSessionNotFound": "Cette session distante n'existe plus.",
"remoteSessionConflict": "Ce profil est déjà ouvert ailleurs.",
@@ -1896,7 +1899,11 @@
"profileRemoteSyncPending": "Une session distante vient de se terminer. Ses modifications doivent être téléchargées avant d'ouvrir ce profil ici.",
"profileLockedByMember": "Ce profil est utilisé par {{email}}.",
"profileLockedElsewhere": "Ce profil est utilisé sur un autre appareil.",
"profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez."
"profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez.",
"fingerprintExitMismatch": "Le nœud de sortie du proxy ne correspond pas à l'empreinte de ce profil.",
"launchConsentExpired": "Cette confirmation n'est plus valide. Relancez le profil.",
"vpnWorkerStartFailed": "Impossible de démarrer la connexion VPN : {{detail}}",
"exitProbeFailed": "Impossible de joindre le nœud de sortie du proxy pour vérifier sa localisation."
},
"rail": {
"profiles": "Profils",
@@ -2142,14 +2149,9 @@
"description": "Supprime les cookies, l'historique et le cache à la fermeture du navigateur. Les extensions et les favoris sont conservés."
},
"consistencyWarning": {
"title": "Incohérence d'empreinte",
"intro": "La sortie du proxy de « {{name}} » ne correspond pas à l'empreinte de ce profil :",
"timezoneTitle": "Incohérence de fuseau horaire",
"timezoneDetail": "Le nœud de sortie est dans {{exit}}, mais l'empreinte indique {{fingerprint}}.",
"languageTitle": "Incohérence de langue",
"languageDetail": "Le pays de sortie est {{country}}, mais la langue de l'empreinte est {{fingerprint}}.",
"explainer": "Un fuseau horaire ou une langue en désaccord avec votre IP de sortie est un signal anti-bot fort, même si votre appareil réel ne fuite jamais. Alignez l'empreinte sur l'emplacement du proxy pour réduire les traitements hostiles.",
"dontWarnAgain": "Ne plus avertir pour ce profil",
"matchToProxy": "Aligner l'empreinte sur le proxy",
"matching": "Alignement…",
"matchSuccess": "Empreinte mise à jour pour correspondre au proxy. Relancez le profil pour l'appliquer."
@@ -2455,5 +2457,31 @@
"cancelledByUser": "Arrêté à la main",
"unknown": "Raison inconnue ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Lancement bloqué",
"titleWarning": "Avant de lancer",
"intro": "Examinez ces problèmes concernant « {{name}} » avant de démarrer le navigateur.",
"fingerprintHeading": "La sortie du proxy ne correspond pas à l'empreinte",
"vpnExtensionHeading": "Extension VPN détectée",
"vpnExtensionIntro": "Extensions de ce profil pouvant rerouter le trafic du navigateur :",
"vpnExtensionConfirmed": "Peut changer le proxy",
"vpnExtensionLikely": "Pourrait changer le proxy",
"vpnExtensionExplainer": "Si l'une d'elles redirige votre trafic ailleurs, la position réelle du navigateur ne correspondra plus au fuseau horaire, à la langue et à la géolocalisation avec lesquels ce profil a été créé, et Donut ne peut pas le détecter de l'extérieur.",
"sourceDonut": "Gérée par Donut",
"sourceBrowser": "Installée dans le profil",
"measurementUnreliable": "Comme une extension VPN peut remplacer le proxy, la vérification de la sortie peut ne pas refléter la route réellement empruntée par le navigateur.",
"scanIncompleteEncrypted": "Ce profil est chiffré : seules les extensions gérées par Donut ont pu être vérifiées.",
"scanIncompleteEphemeral": "Ce profil n'a pas encore de données : seules les extensions gérées par Donut ont pu être vérifiées.",
"scanIncompletePartial": "L'analyse des extensions a été interrompue, certaines peuvent manquer.",
"probePending": "La sortie du proxy n'a pas encore été mesurée. Donut la vérifiera au démarrage et s'arrêtera si elle ne correspond pas.",
"launchAnyway": "Lancer quand même",
"dontBlockAgain": "Ne plus bloquer pour cette incohérence exacte",
"dontWarnExtensions": "Ne plus m'avertir à propos de ces extensions",
"applyToRemaining": "Appliquer ce choix aux profils restants",
"cancelledSummary": "{{cancelled}} lancements sur {{total}} annulés",
"cancelled": "Lancement annulé",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Ce profil n'a jamais été lancé : seules les extensions gérées par Donut ont pu être vérifiées."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。",
"privacy": {
"consistencyWarning": "フィンガープリント整合性の警告",
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、起動時に警告します。",
"consistencyWarning": "フィンガープリント不一致時に起動をブロック",
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、ブラウザーの起動を停止します。それでも起動を選択できます。",
"clearTraffic": "すべてのトラフィック履歴を消去",
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
"clearTrafficSuccess": "トラフィック履歴を消去しました"
"clearTrafficSuccess": "トラフィック履歴を消去しました",
"vpnExtensionWarning": "VPN拡張機能の警告",
"vpnExtensionWarningDescription": "ブラウザーの通信を経路変更できる拡張機能がプロファイルに含まれる場合、起動前に警告します。"
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "リクエストが多すぎます。少し待ってからもう一度お試しください。",
"remoteNoCapacity": "現在空いているリモートマシンがありません。数分後にもう一度お試しください。",
"remoteNotEntitled": "ご利用のプランにはリモート実行が含まれていません。",
"remoteInteractiveNotEntitled": "ご利用のプランのリモート時間は Cookie Bot 専用で、手動のリモートセッションには使えません。",
"remoteSessionRefused": "リモートマシンがこのセッションを拒否しました。",
"remoteSessionNotFound": "そのリモートセッションはすでに存在しません。",
"remoteSessionConflict": "このプロファイルはすでに別の場所で開かれています。",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "リモートセッションが終了しました。この profile をここで開く前に、変更のダウンロードを待っています。",
"profileLockedByMember": "このプロファイルは {{email}} が使用中です。",
"profileLockedElsewhere": "このプロファイルは別のデバイスで使用中です。",
"profileLockUnavailable": "このプロファイルが他で使用中か確認できませんでした。接続を確認して再試行してください。"
"profileLockUnavailable": "このプロファイルが他で使用中か確認できませんでした。接続を確認して再試行してください。",
"fingerprintExitMismatch": "プロキシの出口ノードがこのプロファイルのフィンガープリントと一致しません。",
"launchConsentExpired": "この確認は無効になりました。もう一度起動してください。",
"vpnWorkerStartFailed": "VPN接続を開始できませんでした: {{detail}}",
"exitProbeFailed": "プロキシの出口ノードに接続できず、所在地を確認できませんでした。"
},
"rail": {
"profiles": "プロファイル",
@@ -2135,14 +2142,9 @@
"description": "ブラウザを閉じるときに Cookie、履歴、キャッシュを消去します。拡張機能とブックマークは保持されます。"
},
"consistencyWarning": {
"title": "フィンガープリントの不一致",
"intro": "「{{name}}」のプロキシ出口がこのプロファイルのフィンガープリントと一致していません:",
"timezoneTitle": "タイムゾーンの不一致",
"timezoneDetail": "出口ノードは {{exit}} にありますが、フィンガープリントは {{fingerprint}} を示しています。",
"languageTitle": "言語の不一致",
"languageDetail": "出口の国は {{country}} ですが、フィンガープリントの言語は {{fingerprint}} です。",
"explainer": "出口 IP と食い違うタイムゾーンや言語は、実際のデバイス情報が漏れていなくても強力なアンチボットシグナルになります。フィンガープリントをプロキシの場所に合わせて、警戒される扱いを減らしましょう。",
"dontWarnAgain": "このプロファイルでは今後警告しない",
"matchToProxy": "フィンガープリントをプロキシに合わせる",
"matching": "調整中…",
"matchSuccess": "フィンガープリントをプロキシに合わせて更新しました。反映するにはプロファイルを再起動してください。"
@@ -2428,5 +2430,31 @@
"cancelledByUser": "手動で停止しました",
"unknown": "不明な理由 ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "起動をブロックしました",
"titleWarning": "起動する前に",
"intro": "ブラウザーを起動する前に、「{{name}}」に関する次の問題を確認してください。",
"fingerprintHeading": "プロキシの出口がフィンガープリントと一致しません",
"vpnExtensionHeading": "VPN拡張機能を検出しました",
"vpnExtensionIntro": "このプロファイル内で、ブラウザーの通信を経路変更できる拡張機能:",
"vpnExtensionConfirmed": "プロキシを変更できます",
"vpnExtensionLikely": "プロキシを変更する可能性があります",
"vpnExtensionExplainer": "いずれかが通信を別の経路に変えると、ブラウザーの実際の所在地は、このプロファイルの作成時に設定されたタイムゾーン・言語・位置情報と一致しなくなります。Donutは外部からそれを検出できません。",
"sourceDonut": "Donutが管理",
"sourceBrowser": "プロファイルにインストール済み",
"measurementUnreliable": "VPN拡張機能はプロキシを上書きできるため、出口の確認結果がブラウザーの実際の経路を表していない可能性があります。",
"scanIncompleteEncrypted": "このプロファイルは暗号化されているため、Donutが管理する拡張機能のみ確認できました。",
"scanIncompleteEphemeral": "このプロファイルにはまだデータがないため、Donutが管理する拡張機能のみ確認できました。",
"scanIncompletePartial": "拡張機能のスキャンが途中で終了したため、一部が表示されていない可能性があります。",
"probePending": "プロキシの出口はまだ測定されていません。Donutは起動中に確認し、一致しない場合は停止します。",
"launchAnyway": "このまま起動",
"dontBlockAgain": "この不一致では今後ブロックしない",
"dontWarnExtensions": "これらの拡張機能について今後警告しない",
"applyToRemaining": "この選択を残りのプロファイルにも適用",
"cancelledSummary": "{{total}}件中{{cancelled}}件の起動をキャンセルしました",
"cancelled": "起動をキャンセルしました",
"vpnExtensionEntry": " {{version}}{{capability}}、{{source}}",
"scanIncompleteMissing": "このプロファイルはまだ起動されていないため、Donutが管理する拡張機能のみ確認できました。"
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다.",
"privacy": {
"consistencyWarning": "핑거프린트 일관성 경고",
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 실행 시 경고합니다.",
"consistencyWarning": "핑거프린트 불일치 시 차단",
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 브라우저 시작을 중단합니다. 그래도 실행을 선택할 수 있습니다.",
"clearTraffic": "모든 트래픽 기록 지우기",
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다"
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다",
"vpnExtensionWarning": "VPN 확장 프로그램 경고",
"vpnExtensionWarningDescription": "브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램이 프로필에 있으면 실행 전에 경고합니다."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "요청이 너무 많습니다. 잠시 기다렸다가 다시 시도하세요.",
"remoteNoCapacity": "지금은 사용 가능한 원격 머신이 없습니다. 몇 분 후에 다시 시도하세요.",
"remoteNotEntitled": "현재 요금제에는 원격 실행이 포함되어 있지 않습니다.",
"remoteInteractiveNotEntitled": "현재 플랜의 원격 시간은 Cookie Bot 전용이며, 직접 조작하는 원격 세션에는 사용할 수 없습니다.",
"remoteSessionRefused": "원격 머신이 이 세션을 거부했습니다.",
"remoteSessionNotFound": "해당 원격 세션은 더 이상 존재하지 않습니다.",
"remoteSessionConflict": "이 프로필은 이미 다른 곳에서 열려 있습니다.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "원격 세션이 방금 끝났습니다. 이 프로필을 여기서 열기 전에 변경 사항을 내려받는 중입니다.",
"profileLockedByMember": "이 프로필은 {{email}} 님이 사용 중입니다.",
"profileLockedElsewhere": "이 프로필은 다른 기기에서 사용 중입니다.",
"profileLockUnavailable": "이 프로필이 다른 곳에서 사용 중인지 확인할 수 없습니다. 연결을 확인한 뒤 다시 시도하세요."
"profileLockUnavailable": "이 프로필이 다른 곳에서 사용 중인지 확인할 수 없습니다. 연결을 확인한 뒤 다시 시도하세요.",
"fingerprintExitMismatch": "프록시 출구 노드가 이 프로필의 핑거프린트와 일치하지 않습니다.",
"launchConsentExpired": "해당 확인이 더 이상 유효하지 않습니다. 다시 실행해 보세요.",
"vpnWorkerStartFailed": "VPN 연결을 시작하지 못했습니다: {{detail}}",
"exitProbeFailed": "프록시 출구 노드에 연결할 수 없어 위치를 확인하지 못했습니다."
},
"rail": {
"profiles": "프로필",
@@ -2135,14 +2142,9 @@
"description": "브라우저를 닫을 때 쿠키, 방문 기록, 캐시를 지웁니다. 확장 프로그램과 북마크는 유지됩니다."
},
"consistencyWarning": {
"title": "핑거프린트 불일치",
"intro": "\"{{name}}\"의 프록시 출구가 이 프로필의 핑거프린트와 일치하지 않습니다:",
"timezoneTitle": "시간대 불일치",
"timezoneDetail": "출구 노드는 {{exit}}에 있지만 핑거프린트는 {{fingerprint}}로 보고합니다.",
"languageTitle": "언어 불일치",
"languageDetail": "출구 국가는 {{country}}이지만 핑거프린트 언어는 {{fingerprint}}입니다.",
"explainer": "출구 IP와 어긋나는 시간대나 언어는 실제 기기 정보가 유출되지 않더라도 강력한 안티봇 신호가 됩니다. 핑거프린트를 프록시 위치에 맞춰 의심받는 상황을 줄이세요.",
"dontWarnAgain": "이 프로필에 대해 다시 경고하지 않음",
"matchToProxy": "지문을 프록시에 맞추기",
"matching": "맞추는 중…",
"matchSuccess": "지문이 프록시에 맞게 업데이트되었습니다. 적용하려면 프로필을 다시 실행하세요."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "직접 중지했습니다",
"unknown": "알 수 없는 이유 ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "실행이 차단됨",
"titleWarning": "실행하기 전에",
"intro": "브라우저를 시작하기 전에 \"{{name}}\"의 다음 문제를 확인하세요.",
"fingerprintHeading": "프록시 출구가 핑거프린트와 일치하지 않음",
"vpnExtensionHeading": "VPN 확장 프로그램 감지됨",
"vpnExtensionIntro": "이 프로필에서 브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램:",
"vpnExtensionConfirmed": "프록시를 변경할 수 있음",
"vpnExtensionLikely": "프록시를 변경할 수 있음(추정)",
"vpnExtensionExplainer": "이 중 하나가 트래픽을 다른 곳으로 보내면 브라우저의 실제 위치가 이 프로필을 만들 때 사용한 시간대, 언어, 지리 정보와 더 이상 일치하지 않으며, Donut은 외부에서 이를 감지할 수 없습니다.",
"sourceDonut": "Donut이 관리",
"sourceBrowser": "프로필에 설치됨",
"measurementUnreliable": "VPN 확장 프로그램이 프록시를 덮어쓸 수 있으므로, 출구 확인 결과가 브라우저의 실제 경로와 다를 수 있습니다.",
"scanIncompleteEncrypted": "이 프로필은 암호화되어 있어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
"scanIncompleteEphemeral": "이 프로필에는 아직 데이터가 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다.",
"scanIncompletePartial": "확장 프로그램 검사가 중단되어 일부가 표시되지 않을 수 있습니다.",
"probePending": "프록시 출구를 아직 측정하지 않았습니다. Donut이 시작 중에 확인하고 일치하지 않으면 중단합니다.",
"launchAnyway": "그래도 실행",
"dontBlockAgain": "이 불일치에 대해 다시 차단하지 않기",
"dontWarnExtensions": "이 확장 프로그램에 대해 다시 경고하지 않기",
"applyToRemaining": "이 선택을 나머지 프로필에 적용",
"cancelledSummary": "{{total}}개 중 {{cancelled}}개의 실행이 취소됨",
"cancelled": "실행이 취소됨",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "이 프로필은 아직 실행된 적이 없어 Donut이 관리하는 확장 프로그램만 확인할 수 있었습니다."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso.",
"privacy": {
"consistencyWarning": "Aviso de consistência de impressão digital",
"consistencyWarningDescription": "Avisar ao iniciar quando o fuso horário ou o idioma de um perfil não corresponder ao seu nó de saída do proxy.",
"consistencyWarning": "Bloquear quando a impressão digital divergir",
"consistencyWarningDescription": "Impede que o navegador inicie quando o fuso horário ou o idioma de um perfil não corresponde ao seu nó de saída do proxy. Você ainda pode optar por iniciar.",
"clearTraffic": "Limpar todo o histórico de tráfego",
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
"clearTrafficSuccess": "Histórico de tráfego limpo"
"clearTrafficSuccess": "Histórico de tráfego limpo",
"vpnExtensionWarning": "Aviso de extensão VPN",
"vpnExtensionWarningDescription": "Avisar antes de iniciar quando um perfil contiver uma extensão capaz de redirecionar o tráfego do navegador."
}
},
"header": {
@@ -1870,6 +1872,7 @@
"remoteRateLimited": "Solicitações demais. Aguarde um momento e tente novamente.",
"remoteNoCapacity": "Nenhuma máquina remota está livre agora. Tente novamente em alguns minutos.",
"remoteNotEntitled": "Seu plano não inclui execução remota.",
"remoteInteractiveNotEntitled": "Seu plano inclui horas remotas apenas para o Cookie Bot, não para sessões remotas interativas.",
"remoteSessionRefused": "A máquina remota recusou esta sessão.",
"remoteSessionNotFound": "Essa sessão remota não existe mais.",
"remoteSessionConflict": "Este perfil já está aberto em outro lugar.",
@@ -1896,7 +1899,11 @@
"profileRemoteSyncPending": "Uma sessão remota acabou de terminar. A aguardar a transferência das alterações antes de abrir este perfil aqui.",
"profileLockedByMember": "Este perfil está a ser utilizado por {{email}}.",
"profileLockedElsewhere": "Este perfil está a ser utilizado noutro dispositivo.",
"profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente."
"profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente.",
"fingerprintExitMismatch": "O nó de saída do proxy não corresponde à impressão digital deste perfil.",
"launchConsentExpired": "Essa confirmação não é mais válida. Tente iniciar novamente.",
"vpnWorkerStartFailed": "Não foi possível iniciar a conexão VPN: {{detail}}",
"exitProbeFailed": "Não foi possível alcançar o nó de saída do proxy para verificar sua localização."
},
"rail": {
"profiles": "Perfis",
@@ -2142,14 +2149,9 @@
"description": "Apaga cookies, histórico e cache quando o navegador é fechado. Extensões e favoritos são mantidos."
},
"consistencyWarning": {
"title": "Divergência de impressão digital",
"intro": "A saída do proxy de \"{{name}}\" não corresponde à impressão digital deste perfil:",
"timezoneTitle": "Divergência de fuso horário",
"timezoneDetail": "O nó de saída está em {{exit}}, mas a impressão digital indica {{fingerprint}}.",
"languageTitle": "Divergência de idioma",
"languageDetail": "O país de saída é {{country}}, mas o idioma da impressão digital é {{fingerprint}}.",
"explainer": "Um fuso horário ou idioma que não combina com seu IP de saída é um forte sinal anti-bot, mesmo que seu dispositivo real nunca vaze. Alinhe a impressão digital com a localização do proxy para reduzir tratamentos hostis.",
"dontWarnAgain": "Não avisar novamente para este perfil",
"matchToProxy": "Ajustar impressão ao proxy",
"matching": "Ajustando…",
"matchSuccess": "Impressão digital atualizada para corresponder ao proxy. Reinicie o perfil para aplicar."
@@ -2455,5 +2457,31 @@
"cancelledByUser": "Parado à mão",
"unknown": "Motivo desconhecido ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Inicialização bloqueada",
"titleWarning": "Antes de iniciar",
"intro": "Revise estes problemas de \"{{name}}\" antes de iniciar o navegador.",
"fingerprintHeading": "A saída do proxy não corresponde à impressão digital",
"vpnExtensionHeading": "Extensão VPN detectada",
"vpnExtensionIntro": "Extensões neste perfil que podem redirecionar o tráfego do navegador:",
"vpnExtensionConfirmed": "Pode alterar o proxy",
"vpnExtensionLikely": "Talvez altere o proxy",
"vpnExtensionExplainer": "Se alguma delas redirecionar seu tráfego, a localização real do navegador deixará de corresponder ao fuso horário, ao idioma e à geolocalização com que este perfil foi criado, e o Donut não consegue detectar isso de fora.",
"sourceDonut": "Gerenciada pelo Donut",
"sourceBrowser": "Instalada no perfil",
"measurementUnreliable": "Como uma extensão VPN pode substituir o proxy, a verificação de saída pode não refletir a rota que o navegador realmente usa.",
"scanIncompleteEncrypted": "Este perfil está criptografado, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
"scanIncompleteEphemeral": "Este perfil ainda não tem dados, portanto só foi possível verificar as extensões gerenciadas pelo Donut.",
"scanIncompletePartial": "A verificação de extensões foi interrompida, então algumas podem não estar listadas.",
"probePending": "A saída do proxy ainda não foi medida. O Donut vai verificá-la durante a inicialização e parar se não corresponder.",
"launchAnyway": "Iniciar mesmo assim",
"dontBlockAgain": "Não bloquear novamente para esta divergência exata",
"dontWarnExtensions": "Não avisar novamente sobre estas extensões",
"applyToRemaining": "Aplicar esta escolha aos perfis restantes",
"cancelledSummary": "{{cancelled}} de {{total}} inicializações canceladas",
"cancelled": "Inicialização cancelada",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Este perfil ainda não foi iniciado, portanto só foi possível verificar as extensões gerenciadas pelo Donut."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной.",
"privacy": {
"consistencyWarning": "Предупреждение о согласованности отпечатка",
"consistencyWarningDescription": "Предупреждать при запуске, если часовой пояс или язык профиля не совпадает с выходным узлом прокси.",
"consistencyWarning": "Блокировать при несовпадении отпечатка",
"consistencyWarningDescription": "Не запускать браузер, если часовой пояс или язык профиля не совпадают с выходным узлом прокси. Запустить всё равно можно вручную.",
"clearTraffic": "Очистить всю историю трафика",
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
"clearTrafficSuccess": "История трафика очищена"
"clearTrafficSuccess": "История трафика очищена",
"vpnExtensionWarning": "Предупреждение о VPN-расширении",
"vpnExtensionWarningDescription": "Предупреждать перед запуском, если в профиле есть расширение, способное перенаправить трафик браузера."
}
},
"header": {
@@ -1877,6 +1879,7 @@
"remoteRateLimited": "Слишком много запросов. Подождите немного и попробуйте снова.",
"remoteNoCapacity": "Сейчас нет свободных удалённых машин. Попробуйте через несколько минут.",
"remoteNotEntitled": "Ваш тариф не включает удалённый запуск.",
"remoteInteractiveNotEntitled": "В вашем тарифе удалённые часы доступны только для Cookie Bot, но не для интерактивных удалённых сессий.",
"remoteSessionRefused": "Удалённая машина отклонила эту сессию.",
"remoteSessionNotFound": "Этой удалённой сессии больше не существует.",
"remoteSessionConflict": "Этот профиль уже открыт в другом месте.",
@@ -1903,7 +1906,11 @@
"profileRemoteSyncPending": "Удалённый сеанс только что завершился. Дождитесь загрузки его изменений, прежде чем открывать профиль здесь.",
"profileLockedByMember": "Этот профиль используется пользователем {{email}}.",
"profileLockedElsewhere": "Этот профиль используется на другом устройстве.",
"profileLockUnavailable": "Не удалось проверить, используется ли профиль где-то ещё. Проверьте подключение и попробуйте снова."
"profileLockUnavailable": "Не удалось проверить, используется ли профиль где-то ещё. Проверьте подключение и попробуйте снова.",
"fingerprintExitMismatch": "Выходной узел прокси не совпадает с отпечатком этого профиля.",
"launchConsentExpired": "Это подтверждение больше не действует. Запустите профиль ещё раз.",
"vpnWorkerStartFailed": "Не удалось запустить VPN-подключение: {{detail}}",
"exitProbeFailed": "Не удалось связаться с выходным узлом прокси, чтобы определить его местоположение."
},
"rail": {
"profiles": "Профили",
@@ -2149,14 +2156,9 @@
"description": "Удаляет cookie, историю и кэш при закрытии браузера. Расширения и закладки сохраняются."
},
"consistencyWarning": {
"title": "Несовпадение отпечатка",
"intro": "Выходной узел прокси для «{{name}}» не соответствует отпечатку этого профиля:",
"timezoneTitle": "Несовпадение часового пояса",
"timezoneDetail": "Выходной узел находится в {{exit}}, но отпечаток сообщает {{fingerprint}}.",
"languageTitle": "Несовпадение языка",
"languageDetail": "Страна выхода — {{country}}, но язык отпечатка — {{fingerprint}}.",
"explainer": "Часовой пояс или язык, не совпадающий с выходным IP, — сильный антибот-сигнал, даже если данные вашего реального устройства никогда не утекают. Приведите отпечаток в соответствие с расположением прокси, чтобы снизить враждебное отношение.",
"dontWarnAgain": "Больше не предупреждать для этого профиля",
"matchToProxy": "Подогнать отпечаток под прокси",
"matching": "Подгонка…",
"matchSuccess": "Отпечаток обновлён под прокси. Перезапустите профиль, чтобы применить."
@@ -2482,5 +2484,31 @@
"cancelledByUser": "Остановлено вручную",
"unknown": "Неизвестная причина ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Запуск заблокирован",
"titleWarning": "Перед запуском",
"intro": "Проверьте эти проблемы профиля «{{name}}» перед запуском браузера.",
"fingerprintHeading": "Выходной узел прокси не совпадает с отпечатком",
"vpnExtensionHeading": "Обнаружено VPN-расширение",
"vpnExtensionIntro": "Расширения в этом профиле, способные перенаправить трафик браузера:",
"vpnExtensionConfirmed": "Может изменить прокси",
"vpnExtensionLikely": "Возможно, изменит прокси",
"vpnExtensionExplainer": "Если одно из них направит трафик в другое место, реальное местоположение браузера перестанет совпадать с часовым поясом, языком и геолокацией, с которыми создавался профиль, а Donut не сможет это обнаружить извне.",
"sourceDonut": "Управляется Donut",
"sourceBrowser": "Установлено в профиле",
"measurementUnreliable": "Поскольку VPN-расширение может переопределить прокси, проверка выходного узла может не отражать реальный маршрут браузера.",
"scanIncompleteEncrypted": "Профиль зашифрован, поэтому удалось проверить только расширения, управляемые Donut.",
"scanIncompleteEphemeral": "В профиле ещё нет данных, поэтому удалось проверить только расширения, управляемые Donut.",
"scanIncompletePartial": "Проверка расширений была прервана, поэтому некоторые могут отсутствовать в списке.",
"probePending": "Выходной узел прокси ещё не измерен. Donut проверит его при запуске и остановится, если он не совпадёт.",
"launchAnyway": "Всё равно запустить",
"dontBlockAgain": "Больше не блокировать при этом несовпадении",
"dontWarnExtensions": "Больше не предупреждать об этих расширениях",
"applyToRemaining": "Применить этот выбор к остальным профилям",
"cancelledSummary": "Отменено запусков: {{cancelled}} из {{total}}",
"cancelled": "Запуск отменён",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Профиль ещё ни разу не запускался, поэтому удалось проверить только расширения, управляемые Donut."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Şifresi Çözülmüş Profilleri RAM'de Tut",
"keepDecryptedProfilesInRamDescription": "Daha hızlı başlatma için parola korumalı profillerin şifresi çözülmüş RAM kopyasını başlatmalar arasında koruyun. Diskteki kopya her durumda şifreli kalır.",
"privacy": {
"consistencyWarning": "Parmak izi tutarlılık uyarısı",
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde başlatma sırasında uyar.",
"consistencyWarning": "Parmak izi uyuşmazlığında engelle",
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde tarayıcının başlamasını durdurur. Yine de başlatmayı seçebilirsiniz.",
"clearTraffic": "Tüm trafik geçmişini temizle",
"clearTrafficDescription": "Tüm profillerin kayıtlı trafik istatistiklerini güvenli bir şekilde siler.",
"clearTrafficSuccess": "Trafik geçmişi temizlendi"
"clearTrafficSuccess": "Trafik geçmişi temizlendi",
"vpnExtensionWarning": "VPN uzantısı uyarısı",
"vpnExtensionWarningDescription": "Bir profil, tarayıcı trafiğini yeniden yönlendirebilecek bir uzantı içerdiğinde başlatmadan önce uyarır."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "Çok fazla istek. Biraz bekleyip tekrar deneyin.",
"remoteNoCapacity": "Şu anda boş uzak makine yok. Birkaç dakika sonra tekrar deneyin.",
"remoteNotEntitled": "Planınız uzaktan çalıştırmayı içermiyor.",
"remoteInteractiveNotEntitled": "Planınızdaki uzak saatler yalnızca Cookie Bot için geçerlidir, elle kullanılan uzak oturumlar için değil.",
"remoteSessionRefused": "Uzak makine bu oturumu reddetti.",
"remoteSessionNotFound": "Bu uzak oturum artık mevcut değil.",
"remoteSessionConflict": "Bu profil başka bir yerde zaten açık.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "Uzak oturum az önce bitti. Bu profili burada açmadan önce değişikliklerinin inmesi bekleniyor.",
"profileLockedByMember": "Bu profil {{email}} tarafından kullanılıyor.",
"profileLockedElsewhere": "Bu profil başka bir cihazda kullanılıyor.",
"profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin."
"profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin.",
"fingerprintExitMismatch": "Proxy çıkış düğümü bu profilin parmak iziyle eşleşmiyor.",
"launchConsentExpired": "Bu onay artık geçerli değil. Yeniden başlatmayı deneyin.",
"vpnWorkerStartFailed": "VPN bağlantısı başlatılamadı: {{detail}}",
"exitProbeFailed": "Konumunu denetlemek için proxy çıkış düğümüne ulaşılamadı."
},
"rail": {
"profiles": "Profiller",
@@ -2135,14 +2142,9 @@
"description": "Tarayıcı kapanırken çerezleri, geçmişi ve önbelleği siler. Uzantılar ve yer imleri korunur."
},
"consistencyWarning": {
"title": "Parmak izi uyuşmazlığı",
"intro": "\"{{name}}\" için proxy çıkışı bu profilin parmak iziyle eşleşmiyor:",
"timezoneTitle": "Saat dilimi uyuşmazlığı",
"timezoneDetail": "Çıkış düğümü {{exit}} konumunda, ancak parmak izi {{fingerprint}} bildiriyor.",
"languageTitle": "Dil uyuşmazlığı",
"languageDetail": "Çıkış ülkesi {{country}}, ancak parmak izi dili {{fingerprint}}.",
"explainer": "Çıkış IP'nizle uyuşmayan bir saat dilimi veya dil, gerçek cihazınız hiç sızdırmasa bile güçlü bir anti-bot sinyalidir. Şüpheli muameleyi azaltmak için parmak izini proxy konumuyla hizalayın.",
"dontWarnAgain": "Bu profil için bir daha uyarma",
"matchToProxy": "Parmak izini proxy'ye eşle",
"matching": "Eşleniyor…",
"matchSuccess": "Parmak izi proxy'ye uyacak şekilde güncellendi. Uygulamak için profili yeniden başlatın."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "Elle durduruldu",
"unknown": "Bilinmeyen neden ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Başlatma engellendi",
"titleWarning": "Başlatmadan önce",
"intro": "Tarayıcıyı başlatmadan önce \"{{name}}\" ile ilgili şu sorunları inceleyin.",
"fingerprintHeading": "Proxy çıkışı parmak iziyle eşleşmiyor",
"vpnExtensionHeading": "VPN uzantısı algılandı",
"vpnExtensionIntro": "Bu profildeki, tarayıcı trafiğini yeniden yönlendirebilecek uzantılar:",
"vpnExtensionConfirmed": "Proxy'yi değiştirebilir",
"vpnExtensionLikely": "Proxy'yi değiştirebilir (olası)",
"vpnExtensionExplainer": "Bunlardan biri trafiğinizi başka bir yere yönlendirirse, tarayıcının gerçek konumu artık bu profilin oluşturulduğu saat dilimi, dil ve coğrafi konumla eşleşmez ve Donut bunu dışarıdan algılayamaz.",
"sourceDonut": "Donut tarafından yönetiliyor",
"sourceBrowser": "Profile yüklenmiş",
"measurementUnreliable": "Bir VPN uzantısı proxy'yi geçersiz kılabileceğinden, çıkış kontrolü tarayıcının gerçekte kullandığı rotayı yansıtmayabilir.",
"scanIncompleteEncrypted": "Bu profil şifreli olduğundan yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
"scanIncompleteEphemeral": "Bu profilde henüz veri olmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi.",
"scanIncompletePartial": "Uzantı taraması yarıda kesildi, bu nedenle bazıları listelenmemiş olabilir.",
"probePending": "Proxy çıkışı henüz ölçülmedi. Donut başlatma sırasında kontrol edecek ve eşleşmezse duracak.",
"launchAnyway": "Yine de başlat",
"dontBlockAgain": "Bu tam uyuşmazlık için bir daha engelleme",
"dontWarnExtensions": "Bu uzantılar için bir daha uyarma",
"applyToRemaining": "Bu seçimi kalan profillere uygula",
"cancelledSummary": "{{total}} başlatmadan {{cancelled}} tanesi iptal edildi",
"cancelled": "Başlatma iptal edildi",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Bu profil henüz başlatılmadığından yalnızca Donut tarafından yönetilen uzantılar denetlenebildi."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "Giữ hồ sơ đã giải mã trong RAM",
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa.",
"privacy": {
"consistencyWarning": "Cảnh báo nhất quán vân tay",
"consistencyWarningDescription": "Cảnh báo khi khởi chạy nếu múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát proxy.",
"consistencyWarning": "Chặn khi dấu vân tay không khớp",
"consistencyWarningDescription": "Ngăn trình duyệt khi động khi múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát của proxy. Bạn vẫn có thể chọn khởi chạy.",
"clearTraffic": "Xóa toàn bộ lịch sử lưu lượng",
"clearTrafficDescription": "Xóa an toàn số liệu thống kê lưu lượng đã ghi của mọi hồ sơ.",
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng"
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng",
"vpnExtensionWarning": "Cảnh báo tiện ích VPN",
"vpnExtensionWarningDescription": "Cảnh báo trước khi khởi chạy khi hồ sơ có tiện ích có thể định tuyến lại lưu lượng của trình duyệt."
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "Quá nhiều yêu cầu. Hãy đợi một lát rồi thử lại.",
"remoteNoCapacity": "Hiện không có máy từ xa nào rảnh. Hãy thử lại sau vài phút.",
"remoteNotEntitled": "Gói của bạn không bao gồm chạy từ xa.",
"remoteInteractiveNotEntitled": "Gói của bạn chỉ bao gồm giờ từ xa cho Cookie Bot, không dùng cho phiên từ xa thao tác trực tiếp.",
"remoteSessionRefused": "Máy từ xa đã từ chối phiên này.",
"remoteSessionNotFound": "Phiên từ xa đó không còn tồn tại.",
"remoteSessionConflict": "Hồ sơ này đang được mở ở nơi khác.",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "Một phiên từ xa vừa kết thúc. Đang chờ tải các thay đổi về trước khi mở hồ sơ này tại đây.",
"profileLockedByMember": "Hồ sơ này đang được {{email}} sử dụng.",
"profileLockedElsewhere": "Hồ sơ này đang được sử dụng trên thiết bị khác.",
"profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại."
"profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại.",
"fingerprintExitMismatch": "Nút thoát của proxy không khớp với dấu vân tay của hồ sơ này.",
"launchConsentExpired": "Xác nhận đó không còn hiệu lực. Hãy thử khởi chạy lại.",
"vpnWorkerStartFailed": "Không thể khởi động kết nối VPN: {{detail}}",
"exitProbeFailed": "Không thể kết nối tới nút thoát của proxy để kiểm tra vị trí."
},
"rail": {
"profiles": "Profile",
@@ -2135,14 +2142,9 @@
"description": "Xóa cookie, lịch sử và bộ nhớ đệm khi trình duyệt đóng. Tiện ích mở rộng và dấu trang được giữ lại."
},
"consistencyWarning": {
"title": "Vân tay không khớp",
"intro": "Điểm thoát proxy của \"{{name}}\" không khớp với vân tay của hồ sơ này:",
"timezoneTitle": "Múi giờ không khớp",
"timezoneDetail": "Nút thoát nằm ở {{exit}} nhưng vân tay báo là {{fingerprint}}.",
"languageTitle": "Ngôn ngữ không khớp",
"languageDetail": "Quốc gia thoát là {{country}} nhưng ngôn ngữ của vân tay là {{fingerprint}}.",
"explainer": "Múi giờ hoặc ngôn ngữ không khớp với IP thoát là một tín hiệu chống bot rất mạnh, dù thiết bị thật của bạn không bao giờ bị lộ. Hãy căn chỉnh vân tay theo vị trí proxy để giảm bị đối xử khắt khe.",
"dontWarnAgain": "Không cảnh báo lại cho hồ sơ này",
"matchToProxy": "Khớp vân tay với proxy",
"matching": "Đang khớp…",
"matchSuccess": "Đã cập nhật vân tay để khớp với proxy. Khởi động lại hồ sơ để áp dụng."
@@ -2428,5 +2430,31 @@
"cancelledByUser": "Đã dừng thủ công",
"unknown": "Lý do không xác định ({{code}})"
}
},
"prelaunchGate": {
"titleBlocked": "Đã chặn khởi chạy",
"titleWarning": "Trước khi khởi chạy",
"intro": "Hãy xem lại các vấn đề của \"{{name}}\" trước khi khởi động trình duyệt.",
"fingerprintHeading": "Điểm ra của proxy không khớp với dấu vân tay",
"vpnExtensionHeading": "Đã phát hiện tiện ích VPN",
"vpnExtensionIntro": "Các tiện ích trong hồ sơ này có thể định tuyến lại lưu lượng của trình duyệt:",
"vpnExtensionConfirmed": "Có thể thay đổi proxy",
"vpnExtensionLikely": "Có khả năng thay đổi proxy",
"vpnExtensionExplainer": "Nếu một trong số đó chuyển lưu lượng của bạn đi nơi khác, vị trí thực của trình duyệt sẽ không còn khớp với múi giờ, ngôn ngữ và vị trí địa lý mà hồ sơ này được tạo ra, và Donut không thể phát hiện điều đó từ bên ngoài.",
"sourceDonut": "Do Donut quản lý",
"sourceBrowser": "Đã cài trong hồ sơ",
"measurementUnreliable": "Vì tiện ích VPN có thể ghi đè proxy, kết quả kiểm tra điểm ra có thể không phản ánh tuyến đường mà trình duyệt thực sự dùng.",
"scanIncompleteEncrypted": "Hồ sơ này được mã hóa nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
"scanIncompleteEphemeral": "Hồ sơ này chưa có dữ liệu nên chỉ có thể kiểm tra các tiện ích do Donut quản lý.",
"scanIncompletePartial": "Quá trình quét tiện ích bị ngắt giữa chừng nên có thể thiếu một số tiện ích.",
"probePending": "Điểm ra của proxy chưa được đo. Donut sẽ kiểm tra trong lúc khởi động và dừng lại nếu không khớp.",
"launchAnyway": "Vẫn khởi chạy",
"dontBlockAgain": "Không chặn lại với đúng sai lệch này",
"dontWarnExtensions": "Không cảnh báo lại về các tiện ích này",
"applyToRemaining": "Áp dụng lựa chọn này cho các hồ sơ còn lại",
"cancelledSummary": "Đã hủy {{cancelled}} trên {{total}} lượt khởi chạy",
"cancelled": "Đã hủy khởi chạy",
"vpnExtensionEntry": " {{version}} — {{capability}}, {{source}}",
"scanIncompleteMissing": "Hồ sơ này chưa từng được khởi chạy nên chỉ có thể kiểm tra các tiện ích do Donut quản lý."
}
}
+37 -9
View File
@@ -199,11 +199,13 @@
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。",
"privacy": {
"consistencyWarning": "指纹一致性警告",
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不匹配时,在启动时发出警告。",
"consistencyWarning": "指纹不匹配时阻止启动",
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不一致时,阻止浏览器启动。你仍可以选择启动。",
"clearTraffic": "清除所有流量历史",
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
"clearTrafficSuccess": "流量历史已清除"
"clearTrafficSuccess": "流量历史已清除",
"vpnExtensionWarning": "VPN 扩展警告",
"vpnExtensionWarningDescription": "当配置文件中存在可改变浏览器流量路径的扩展时,在启动前发出警告。"
}
},
"header": {
@@ -1863,6 +1865,7 @@
"remoteRateLimited": "请求过于频繁。请稍候再试。",
"remoteNoCapacity": "当前没有空闲的远程机器。请几分钟后再试。",
"remoteNotEntitled": "你的套餐不包含远程运行。",
"remoteInteractiveNotEntitled": "您的套餐中的远程时长仅供 Cookie Bot 使用,不能用于手动远程会话。",
"remoteSessionRefused": "远程机器拒绝了此会话。",
"remoteSessionNotFound": "该远程会话已不存在。",
"remoteSessionConflict": "此配置文件已在别处打开。",
@@ -1889,7 +1892,11 @@
"profileRemoteSyncPending": "远程会话刚刚结束。正在等待其更改下载完成后才能在此打开该配置文件。",
"profileLockedByMember": "该配置文件正在被 {{email}} 使用。",
"profileLockedElsewhere": "该配置文件正在另一台设备上使用。",
"profileLockUnavailable": "无法检查该配置文件是否正在别处使用。请检查网络连接后重试。"
"profileLockUnavailable": "无法检查该配置文件是否正在别处使用。请检查网络连接后重试。",
"fingerprintExitMismatch": "代理出口节点与此配置文件的指纹不匹配。",
"launchConsentExpired": "该确认已失效。请重新启动。",
"vpnWorkerStartFailed": "无法启动 VPN 连接:{{detail}}",
"exitProbeFailed": "无法连接代理出口节点以检查其位置。"
},
"rail": {
"profiles": "配置文件",
@@ -2135,14 +2142,9 @@
"description": "浏览器关闭时清除 Cookie、历史记录和缓存。扩展和书签将被保留。"
},
"consistencyWarning": {
"title": "指纹不匹配",
"intro": "「{{name}}」的代理出口与此配置文件的指纹不匹配:",
"timezoneTitle": "时区不匹配",
"timezoneDetail": "出口节点位于 {{exit}},但指纹报告为 {{fingerprint}}。",
"languageTitle": "语言不匹配",
"languageDetail": "出口国家/地区为 {{country}},但指纹语言为 {{fingerprint}}。",
"explainer": "时区或语言与出口 IP 不一致是强烈的反机器人信号,即使您的真实设备信息从未泄露。请让指纹与代理位置保持一致,以减少被针对的风险。",
"dontWarnAgain": "不再为此配置文件发出警告",
"matchToProxy": "将指纹匹配到代理",
"matching": "匹配中…",
"matchSuccess": "指纹已更新以匹配代理。重新启动配置文件以生效。"
@@ -2428,5 +2430,31 @@
"cancelledByUser": "已手动停止",
"unknown": "未知原因({{code}}"
}
},
"prelaunchGate": {
"titleBlocked": "启动已阻止",
"titleWarning": "启动前请注意",
"intro": "启动浏览器前,请检查“{{name}}”的以下问题。",
"fingerprintHeading": "代理出口与指纹不匹配",
"vpnExtensionHeading": "检测到 VPN 扩展",
"vpnExtensionIntro": "此配置文件中可能改变浏览器流量路径的扩展:",
"vpnExtensionConfirmed": "可以更改代理",
"vpnExtensionLikely": "可能会更改代理",
"vpnExtensionExplainer": "如果其中之一将流量转发到别处,浏览器的真实位置将不再与创建此配置文件时使用的时区、语言和地理位置一致,而 Donut 无法从外部察觉。",
"sourceDonut": "由 Donut 管理",
"sourceBrowser": "已安装在配置文件中",
"measurementUnreliable": "由于 VPN 扩展可以覆盖代理设置,出口检测结果可能并非浏览器实际使用的线路。",
"scanIncompleteEncrypted": "此配置文件已加密,因此只能检查由 Donut 管理的扩展。",
"scanIncompleteEphemeral": "此配置文件尚无数据,因此只能检查由 Donut 管理的扩展。",
"scanIncompletePartial": "扩展扫描被中断,可能有部分扩展未列出。",
"probePending": "尚未测量代理出口。Donut 会在启动过程中检查,如不匹配则停止。",
"launchAnyway": "仍要启动",
"dontBlockAgain": "不再因这一完全相同的不匹配而阻止",
"dontWarnExtensions": "不再就这些扩展发出警告",
"applyToRemaining": "将此选择应用于其余配置文件",
"cancelledSummary": "已取消 {{total}} 次启动中的 {{cancelled}} 次",
"cancelled": "已取消启动",
"vpnExtensionEntry": " {{version}} — {{capability}}、{{source}}",
"scanIncompleteMissing": "此配置文件尚未启动过,因此只能检查由 Donut 管理的扩展。"
}
}
+24
View File
@@ -73,6 +73,7 @@ export type BackendErrorCode =
| "REMOTE_RATE_LIMITED"
| "REMOTE_NO_CAPACITY"
| "REMOTE_NOT_ENTITLED"
| "REMOTE_INTERACTIVE_NOT_ENTITLED"
| "REMOTE_SESSION_REFUSED"
| "REMOTE_SESSION_NOT_FOUND"
| "REMOTE_SESSION_CONFLICT"
@@ -105,6 +106,10 @@ export type BackendErrorCode =
// rendered as the raw machine identifier.
| "COOKIE_BOT_REQUIRES_PROXY"
| "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED"
| "FINGERPRINT_EXIT_MISMATCH"
| "LAUNCH_CONSENT_EXPIRED"
| "VPN_WORKER_START_FAILED"
| "EXIT_PROBE_FAILED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -306,6 +311,12 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.remoteNoCapacity");
case "REMOTE_NOT_ENTITLED":
return t("backendErrors.remoteNotEntitled");
// Distinct from the above: the plan HAS remote hours, it just may not spend
// them by hand (solo funds a nightly Cookie Bot only). Telling such a user
// "your plan does not include remote execution" while their bot visibly
// runs every night is the confusing case this code exists to avoid.
case "REMOTE_INTERACTIVE_NOT_ENTITLED":
return t("backendErrors.remoteInteractiveNotEntitled");
case "REMOTE_SESSION_REFUSED":
return t("backendErrors.remoteSessionRefused");
case "REMOTE_SESSION_NOT_FOUND":
@@ -380,6 +391,19 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.cookieBotRequiresExitNode");
case "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED":
return t("backendErrors.cookieBotTouchFingerprintUnsupported");
// The launch gate's block. The dialog renders the mismatch detail from
// `params` itself; this string is the fallback for anywhere that only has
// room for one sentence.
case "FINGERPRINT_EXIT_MISMATCH":
return t("backendErrors.fingerprintExitMismatch");
case "LAUNCH_CONSENT_EXPIRED":
return t("backendErrors.launchConsentExpired");
case "VPN_WORKER_START_FAILED":
return t("backendErrors.vpnWorkerStartFailed", {
detail: parsed.params?.detail ?? "",
});
case "EXIT_PROBE_FAILED":
return t("backendErrors.exitProbeFailed");
case "INTERNAL_ERROR":
return t("backendErrors.internal", {
detail: parsed.params?.detail ?? "",
+28 -10
View File
@@ -8,6 +8,7 @@ interface Capabilities {
cloudBackup: boolean;
teamCollaboration: boolean;
cookieBot: boolean;
remoteInteractive: boolean;
}
const NONE: Entitlements = {
@@ -17,6 +18,7 @@ const NONE: Entitlements = {
cloudBackup: false,
teamCollaboration: false,
cookieBot: false,
remoteInteractive: false,
profileLimit: 0,
requestsPerHour: 0,
remoteBrowserHours: 0,
@@ -25,12 +27,16 @@ const NONE: Entitlements = {
// Mirror of PLAN_CAPABILITIES in apps/backend/src/plans/entitlements.ts. Keep in
// sync — a new plan must be declared here too, or it falls back to DEFAULT_PAID.
const PLAN_CAPABILITIES: Record<string, Capabilities> = {
starter: {
// The one row where cookieBot, browserAutomation and remoteInteractive all
// disagree: solo pays for a nightly bot and nothing else that drives a
// browser. No fingerprint editing either.
solo: {
browserAutomation: false,
crossOsFingerprints: true,
crossOsFingerprints: false,
cloudBackup: true,
teamCollaboration: false,
cookieBot: false,
cookieBot: true,
remoteInteractive: false,
},
pro: {
browserAutomation: true,
@@ -38,6 +44,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cloudBackup: true,
teamCollaboration: false,
cookieBot: true,
remoteInteractive: true,
},
team: {
browserAutomation: true,
@@ -45,6 +52,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cloudBackup: true,
teamCollaboration: true,
cookieBot: true,
remoteInteractive: true,
},
enterprise: {
browserAutomation: true,
@@ -52,6 +60,7 @@ const PLAN_CAPABILITIES: Record<string, Capabilities> = {
cloudBackup: true,
teamCollaboration: true,
cookieBot: true,
remoteInteractive: true,
},
};
@@ -62,6 +71,7 @@ const DEFAULT_PAID: Capabilities = {
cloudBackup: true,
teamCollaboration: false,
cookieBot: true,
remoteInteractive: true,
};
/**
@@ -75,16 +85,23 @@ export function getEntitlements(
): Entitlements {
if (user?.entitlements) {
const server = user.entitlements;
// A backend (or a cached login) older than the cookie-bot release omits
// these two keys. Reading them as `undefined` would hide a paid feature
// from a paying customer with nothing logged anywhere, so resolve them
// here — the one place every caller already goes through. Cookie Bot is
// remote automation on leased hardware, so it tracks `browserAutomation`
// exactly; `remoteBrowserHours` stays 0 because the spendable figure is
// whatever `get_remote_hours_quota` reports, never a client guess.
// A backend (or a cached login) older than the current release omits these
// keys. Reading them as `undefined` would hide a paid feature from a paying
// customer with nothing logged anywhere, so resolve them here — the one
// place every caller already goes through.
//
// Both absent flags fall back to `browserAutomation`, which is what they
// were derived from before solo existed: on every plan a pre-solo backend
// knows about, automation implied both the bot and interactive remote
// control. A solo user never hits this branch — the backend that can put
// them on solo is by definition new enough to send both keys.
//
// `remoteBrowserHours` stays 0 because the spendable figure is whatever
// `get_remote_hours_quota` reports, never a client guess.
return {
...server,
cookieBot: server.cookieBot ?? server.browserAutomation,
remoteInteractive: server.remoteInteractive ?? server.browserAutomation,
remoteBrowserHours: server.remoteBrowserHours ?? 0,
};
}
@@ -103,6 +120,7 @@ export function getEntitlements(
cloudBackup: caps.cloudBackup,
teamCollaboration: caps.teamCollaboration,
cookieBot: caps.cookieBot,
remoteInteractive: caps.remoteInteractive,
profileLimit: user.profileLimit,
requestsPerHour: caps.browserAutomation ? DEFAULT_REQUESTS_PER_HOUR : 0,
remoteBrowserHours: 0,
+60 -10
View File
@@ -85,11 +85,11 @@ export interface SyncSettings {
/**
* Capability/limit set derived from the plan by the backend. Features are gated
* on these flags instead of a single "is paid?" check, so a plan like the future
* "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
* data. Mirrors `apps/backend/src/plans/entitlements.ts`. Resolve via
* `getEntitlements()` the desktop populates it, but it stays optional for
* safety on older state.
* on these flags instead of a single "is paid?" check, so a plan like "solo"
* (cloud backup + nightly cookie bot, no automation, no fingerprint editing, no
* hands-on remote session) is just data. Mirrors
* `apps/backend/src/plans/entitlements.ts`. Resolve via `getEntitlements()`
* the desktop populates it, but it stays optional for safety on older state.
*/
export interface Entitlements {
active: boolean;
@@ -99,6 +99,13 @@ export interface Entitlements {
teamCollaboration: boolean;
/** Overnight profile warming on a leased remote host. */
cookieBot: boolean;
/**
* May open a HANDS-ON remote session. Not implied by `cookieBot` or by a
* non-zero `remoteBrowserHours`: solo funds a nightly bot out of its hours and
* may not drive a remote browser itself, so any UI offering interactive remote
* control must read this flag.
*/
remoteInteractive: boolean;
profileLimit: number;
requestsPerHour: number;
/**
@@ -110,15 +117,22 @@ export interface Entitlements {
}
/**
* What a backend older than the cookie-bot release actually sends. Read it
* through `getEntitlements()`, which fills the gap never off `CloudUser`
* directly, or a paying customer's Cookie Bot silently reads `false`.
* What a backend older than the current release actually sends. Read it through
* `getEntitlements()`, which fills the gaps never off `CloudUser` directly, or
* a paying customer's Cookie Bot silently reads `false`.
*
* `remoteInteractive` joins the optional set for the same reason `cookieBot`
* did: a backend predating the solo tier omits it, and reading the absent key as
* `false` would take interactive remote sessions away from a Pro customer whose
* only mistake was a stale cached login.
*/
export type ServerEntitlements = Omit<
Entitlements,
"cookieBot" | "remoteBrowserHours"
"cookieBot" | "remoteBrowserHours" | "remoteInteractive"
> &
Partial<Pick<Entitlements, "cookieBot" | "remoteBrowserHours">>;
Partial<
Pick<Entitlements, "cookieBot" | "remoteBrowserHours" | "remoteInteractive">
>;
export interface CloudUser {
id: string;
@@ -645,3 +659,39 @@ export interface VpnStatus {
bytes_received?: number;
last_handshake?: number;
}
/** Result of comparing a proxy's exit node against a profile's fingerprint. */
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;
/** Which dimensions disagree: "timezone", "language". */
mismatches: string[];
}
/** A VPN/proxy extension found in a profile, which can reroute browser traffic. */
export interface DetectedVpnExtension {
/** Acknowledgement identity: `donut:<uuid>` or `crx:<id>`. */
key: string;
name: string;
version: string | null;
/** "donut" (managed by Donut) or "browser" (installed in the profile). */
source: string;
/** "confirmed" (holds the proxy permission) or "likely". */
confidence: string;
signals: string[];
}
/** Local-only checks answered before a launch starts any worker. */
export interface PreLaunchChecks {
vpn_extensions: DetectedVpnExtension[];
scan_state: string;
consistency: ConsistencyResult;
exit_probe_pending: boolean;
exit_measurement_unreliable: boolean;
consent_token: string | null;
}