mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-12 20:58:59 +02:00
2091 lines
78 KiB
TypeScript
2091 lines
78 KiB
TypeScript
"use client";
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
|
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
import Color from "color";
|
|
import { motion, useReducedMotion } from "motion/react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { BsCamera, BsMic } from "react-icons/bs";
|
|
import { DataRootSetting } from "@/components/data-root-setting";
|
|
import { DnsBlocklistDialog } from "@/components/dns-blocklist-dialog";
|
|
import { LoadingButton } from "@/components/loading-button";
|
|
import { useTheme } from "@/components/theme-provider";
|
|
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
ColorPicker,
|
|
ColorPickerAlpha,
|
|
ColorPickerEyeDropper,
|
|
ColorPickerFormat,
|
|
ColorPickerHue,
|
|
ColorPickerOutput,
|
|
ColorPickerSelection,
|
|
} from "@/components/ui/color-picker";
|
|
import { ConfirmationMark } from "@/components/ui/confirmation-mark";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
|
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
|
import { useInputModality } from "@/hooks/use-input-modality";
|
|
import { useLanguage } from "@/hooks/use-language";
|
|
import type { PermissionType } from "@/hooks/use-permissions";
|
|
import { usePermissions } from "@/hooks/use-permissions";
|
|
import { translateBackendError } from "@/lib/backend-errors";
|
|
import { effectivePlanOf } from "@/lib/entitlements";
|
|
import { MOTION_SPRING_POSITION } from "@/lib/motion";
|
|
import {
|
|
applyThemeColors,
|
|
clearThemeColors,
|
|
getThemeByColors,
|
|
getThemeById,
|
|
normalizeThemeColors,
|
|
THEME_VARIABLES,
|
|
THEMES,
|
|
withThemeTransition,
|
|
} from "@/lib/themes";
|
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import type { SetDefaultBrowserOutcome } from "@/types";
|
|
import { RippleButton } from "./ui/ripple";
|
|
|
|
interface AppSettings {
|
|
set_as_default_browser: boolean;
|
|
theme: string;
|
|
custom_theme?: Record<string, string>;
|
|
api_enabled: boolean;
|
|
api_port: number;
|
|
api_token?: string;
|
|
disable_auto_updates?: boolean;
|
|
keep_decrypted_profiles_in_ram?: boolean;
|
|
fingerprint_gate_disabled?: boolean;
|
|
vpn_extension_warning_disabled?: boolean;
|
|
trash_retention_days?: number;
|
|
}
|
|
|
|
/** Retention choices offered for the trash, in days. The backend clamps to 1..=365. */
|
|
const TRASH_RETENTION_OPTIONS = [7, 14, 30, 90, 365];
|
|
const DEFAULT_TRASH_RETENTION_DAYS = 30;
|
|
|
|
/** The offered choices, plus the stored value when it is not one of them. */
|
|
function trashRetentionOptions(current: number): number[] {
|
|
if (TRASH_RETENTION_OPTIONS.includes(current)) return TRASH_RETENTION_OPTIONS;
|
|
return [...TRASH_RETENTION_OPTIONS, current].sort((a, b) => a - b);
|
|
}
|
|
|
|
interface CustomThemeState {
|
|
selectedThemeId: string | null;
|
|
colors: Record<string, string>;
|
|
}
|
|
|
|
interface PermissionInfo {
|
|
permission_type: PermissionType;
|
|
isGranted: boolean;
|
|
description: string;
|
|
}
|
|
|
|
// Version update progress toasts are handled globally via useVersionUpdater
|
|
|
|
interface SettingsDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onIntegrationsOpen?: () => void;
|
|
subPage?: boolean;
|
|
/** A section to scroll to and focus as soon as the page opens. */
|
|
initialSection?: string | null;
|
|
}
|
|
|
|
export function SettingsDialog({
|
|
isOpen,
|
|
onClose,
|
|
onIntegrationsOpen,
|
|
subPage,
|
|
initialSection = null,
|
|
}: SettingsDialogProps) {
|
|
const [settings, setSettings] = useState<AppSettings>({
|
|
set_as_default_browser: false,
|
|
theme: "system",
|
|
custom_theme: undefined,
|
|
api_enabled: false,
|
|
api_port: 10108,
|
|
api_token: undefined,
|
|
});
|
|
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
|
set_as_default_browser: false,
|
|
theme: "system",
|
|
custom_theme: undefined,
|
|
api_enabled: false,
|
|
api_port: 10108,
|
|
api_token: undefined,
|
|
});
|
|
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
|
|
selectedThemeId: null,
|
|
colors: {},
|
|
});
|
|
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
const [saved, setSaved] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [jumpTo, setJumpTo] = useState<string | null>(initialSection);
|
|
const sectionsRef = useRef<HTMLFieldSetElement>(null);
|
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
// The section the reader is in, so the nav can point at it. A click pins
|
|
// its target until the reader scrolls away, because the last sections can
|
|
// never reach the top of the scroller on their own.
|
|
const [activeSection, setActiveSection] = useState<string | null>(null);
|
|
const pinnedSectionRef = useRef<{ id: string; scrollTop: number } | null>(
|
|
null,
|
|
);
|
|
const reduceMotion = useReducedMotion();
|
|
const inputModality = useInputModality();
|
|
const animateNav = !reduceMotion && inputModality === "pointer";
|
|
const [isSettingDefault, setIsSettingDefault] = useState(false);
|
|
const [isClearingCache, setIsClearingCache] = useState(false);
|
|
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
|
|
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
|
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
|
const [requestingPermission, setRequestingPermission] =
|
|
useState<PermissionType | null>(null);
|
|
const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false);
|
|
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
|
const [e2ePassword, setE2ePassword] = useState("");
|
|
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
|
const [e2eError, setE2eError] = useState("");
|
|
const [isSavingE2e, setIsSavingE2e] = useState(false);
|
|
const [isRemovingE2e, setIsRemovingE2e] = useState(false);
|
|
const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false);
|
|
const [verifyE2ePassword, setVerifyE2ePassword] = useState("");
|
|
const [isVerifyingE2e, setIsVerifyingE2e] = useState(false);
|
|
// Theme-restore guards: settings load asynchronously, and the dialog can be
|
|
// unmounted directly by rail navigation (bypassing handleClose). Refs mirror
|
|
// the loaded state so the unmount cleanup can restore the persisted theme.
|
|
const [hasLoadedSettings, setHasLoadedSettings] = useState(false);
|
|
const hasLoadedSettingsRef = useRef(false);
|
|
const originalSettingsRef = useRef<AppSettings | null>(null);
|
|
const [systemInfo, setSystemInfo] = useState<{
|
|
app_version: string;
|
|
os: string;
|
|
arch: string;
|
|
portable: boolean;
|
|
} | null>(null);
|
|
|
|
const { t } = useTranslation();
|
|
const { setTheme } = useTheme();
|
|
const {
|
|
requestPermission,
|
|
isMicrophoneAccessGranted,
|
|
isCameraAccessGranted,
|
|
currentOS,
|
|
} = usePermissions(isOpen);
|
|
const isMacOS = currentOS === "macos";
|
|
const isLinux = currentOS === "linux";
|
|
const { trialStatus } = useCommercialTrial();
|
|
const { user: cloudUser } = useCloudAuth();
|
|
// Encryption is available to everyone except team members who aren't owners
|
|
const canUseEncryption =
|
|
cloudUser == null ||
|
|
effectivePlanOf(cloudUser) !== "team" ||
|
|
cloudUser.teamRole === "owner";
|
|
const {
|
|
currentLanguage,
|
|
changeLanguage,
|
|
supportedLanguages,
|
|
isLoading: isLanguageLoading,
|
|
} = useLanguage();
|
|
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
|
|
const [originalLanguage, setOriginalLanguage] = useState<string | null>(null);
|
|
|
|
const getPermissionIcon = useCallback((type: PermissionType) => {
|
|
switch (type) {
|
|
case "microphone":
|
|
return <BsMic className="size-4" />;
|
|
case "camera":
|
|
return <BsCamera className="size-4" />;
|
|
}
|
|
}, []);
|
|
|
|
const getPermissionDisplayName = useCallback(
|
|
(type: PermissionType) => {
|
|
switch (type) {
|
|
case "microphone":
|
|
return t("settings.permissions.microphone");
|
|
case "camera":
|
|
return t("settings.permissions.camera");
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const getStatusBadge = useCallback(
|
|
(isGranted: boolean) => {
|
|
if (isGranted) {
|
|
return (
|
|
<Badge
|
|
variant="default"
|
|
className="bg-success text-success-foreground"
|
|
>
|
|
{t("common.status.granted")}
|
|
</Badge>
|
|
);
|
|
}
|
|
return <Badge variant="secondary">{t("common.status.notGranted")}</Badge>;
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const getPermissionDescription = useCallback(
|
|
(type: PermissionType) => {
|
|
switch (type) {
|
|
case "microphone":
|
|
return t("settings.permissions.microphoneDescription");
|
|
case "camera":
|
|
return t("settings.permissions.cameraDescription");
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// `animate: false` on the restore paths. Opening Settings re-applies the
|
|
// theme already on screen, so a whole-document cross-fade to an identical
|
|
// palette animates nothing. Worse, the mount effect below did it twice in a
|
|
// row, and the second transition aborts the first mid-snapshot.
|
|
const applyCustomTheme = useCallback(
|
|
(vars: Record<string, string>, options?: { animate?: boolean }) => {
|
|
const apply = () => {
|
|
applyThemeColors(vars);
|
|
};
|
|
if (options?.animate === false) {
|
|
apply();
|
|
return;
|
|
}
|
|
withThemeTransition(apply);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const clearCustomTheme = useCallback((options?: { animate?: boolean }) => {
|
|
if (options?.animate === false) {
|
|
clearThemeColors();
|
|
return;
|
|
}
|
|
withThemeTransition(clearThemeColors);
|
|
}, []);
|
|
|
|
const loadSettings = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const appSettings = await invoke<AppSettings>("get_app_settings");
|
|
const tokyoNightTheme = getThemeById("tokyo-night");
|
|
if (!tokyoNightTheme) {
|
|
throw new Error("Tokyo Night theme not found");
|
|
}
|
|
const merged: AppSettings = {
|
|
...appSettings,
|
|
custom_theme:
|
|
appSettings.custom_theme &&
|
|
Object.keys(appSettings.custom_theme).length > 0
|
|
? normalizeThemeColors(appSettings.custom_theme)
|
|
: tokyoNightTheme.colors,
|
|
};
|
|
// 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);
|
|
|
|
// Initialize custom theme state
|
|
if (merged.theme === "custom" && merged.custom_theme) {
|
|
const matchingTheme = getThemeByColors(merged.custom_theme);
|
|
setCustomThemeState({
|
|
selectedThemeId: matchingTheme?.id ?? null,
|
|
colors: merged.custom_theme,
|
|
});
|
|
// Keep the user's custom colors applied while Settings is open —
|
|
// without this the page shows the default palette until save/close.
|
|
applyCustomTheme(merged.custom_theme);
|
|
} else if (merged.theme === "custom") {
|
|
// Initialize with Tokyo Night if no custom theme exists
|
|
setCustomThemeState({
|
|
selectedThemeId: "tokyo-night",
|
|
colors: tokyoNightTheme.colors,
|
|
});
|
|
}
|
|
// Check E2E password status
|
|
try {
|
|
const hasPassword = await invoke<boolean>("check_has_e2e_password");
|
|
setHasE2ePassword(hasPassword);
|
|
} catch {
|
|
setHasE2ePassword(false);
|
|
}
|
|
// Load system info
|
|
try {
|
|
const info = await invoke<{
|
|
app_version: string;
|
|
os: string;
|
|
arch: string;
|
|
portable: boolean;
|
|
}>("get_system_info");
|
|
setSystemInfo(info);
|
|
} catch {
|
|
setSystemInfo(null);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load settings:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [applyCustomTheme]);
|
|
|
|
const loadPermissions = useCallback(() => {
|
|
setIsLoadingPermissions(true);
|
|
try {
|
|
if (!isMacOS) {
|
|
// On non-macOS platforms, don't show permissions
|
|
setPermissions([]);
|
|
return;
|
|
}
|
|
|
|
const permissionList: PermissionInfo[] = [
|
|
{
|
|
permission_type: "microphone",
|
|
isGranted: isMicrophoneAccessGranted,
|
|
description: getPermissionDescription("microphone"),
|
|
},
|
|
{
|
|
permission_type: "camera",
|
|
isGranted: isCameraAccessGranted,
|
|
description: getPermissionDescription("camera"),
|
|
},
|
|
];
|
|
|
|
setPermissions(permissionList);
|
|
} catch (error) {
|
|
console.error("Failed to load permissions:", error);
|
|
} finally {
|
|
setIsLoadingPermissions(false);
|
|
}
|
|
}, [
|
|
getPermissionDescription,
|
|
isCameraAccessGranted,
|
|
isMacOS,
|
|
isMicrophoneAccessGranted,
|
|
]);
|
|
|
|
// The Linux implementation shells out to `which` plus two `xdg-mime query`
|
|
// calls, and `xdg-mime` is a shell script that forks further. Without this
|
|
// guard a slow desktop lets the poll below stack one unfinished call on top
|
|
// of another every few seconds, and each one occupies a worker of the same
|
|
// runtime every other Tauri command shares.
|
|
const defaultBrowserCheckInFlight = useRef(false);
|
|
const checkDefaultBrowserStatus = useCallback(async () => {
|
|
if (defaultBrowserCheckInFlight.current) {
|
|
return;
|
|
}
|
|
defaultBrowserCheckInFlight.current = true;
|
|
try {
|
|
const isDefault = await invoke<boolean>("is_default_browser");
|
|
setIsDefaultBrowser(isDefault);
|
|
} catch (error) {
|
|
console.error("Failed to check default browser status:", error);
|
|
} finally {
|
|
defaultBrowserCheckInFlight.current = false;
|
|
}
|
|
}, []);
|
|
|
|
const handleSetDefaultBrowser = useCallback(async () => {
|
|
setIsSettingDefault(true);
|
|
try {
|
|
// Windows keeps the final choice for its own settings page, so a call
|
|
// that succeeded does not always mean Donut is the default yet. Say which
|
|
// of the two happened. Saying nothing at all is what left the user
|
|
// watching the badge stay "Inactive" with no explanation.
|
|
const outcome = await invoke<SetDefaultBrowserOutcome>(
|
|
"set_as_default_browser",
|
|
);
|
|
await checkDefaultBrowserStatus();
|
|
|
|
if (outcome.status === "awaitingSystemSettings") {
|
|
showSuccessToast(t("settings.defaultBrowser.finishInSystemSettings"), {
|
|
description: t(
|
|
"settings.defaultBrowser.finishInSystemSettingsDescription",
|
|
),
|
|
duration: 8000,
|
|
});
|
|
} else {
|
|
showSuccessToast(t("settings.defaultBrowser.setSuccess"));
|
|
}
|
|
} catch (error) {
|
|
showErrorToast(t("settings.defaultBrowser.setFailed"), {
|
|
description:
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === "string"
|
|
? error
|
|
: t("common.errors.unknown"),
|
|
duration: 8000,
|
|
});
|
|
} finally {
|
|
setIsSettingDefault(false);
|
|
}
|
|
}, [checkDefaultBrowserStatus, t]);
|
|
|
|
const handleClearTraffic = useCallback(async () => {
|
|
setIsClearingTraffic(true);
|
|
try {
|
|
await invoke("clear_all_traffic_stats");
|
|
showSuccessToast(t("settings.privacy.clearTrafficSuccess"));
|
|
} catch (error) {
|
|
showErrorToast(
|
|
error instanceof Error ? error.message : t("common.errors.unknown"),
|
|
);
|
|
} finally {
|
|
setIsClearingTraffic(false);
|
|
}
|
|
}, [t]);
|
|
|
|
const handleClearCache = useCallback(async () => {
|
|
setIsClearingCache(true);
|
|
try {
|
|
await invoke("clear_all_version_cache_and_refetch");
|
|
// Also clear traffic stats cache
|
|
await invoke("clear_all_traffic_stats");
|
|
// Don't show immediate success toast - let the version update progress events handle it
|
|
} catch (error) {
|
|
console.error("Failed to clear cache:", error);
|
|
showErrorToast(t("settings.advanced.clearCacheFailed"), {
|
|
description:
|
|
error instanceof Error ? error.message : t("common.errors.unknown"),
|
|
duration: 4000,
|
|
});
|
|
} finally {
|
|
setIsClearingCache(false);
|
|
}
|
|
}, [t]);
|
|
|
|
const handleRequestPermission = useCallback(
|
|
async (permissionType: PermissionType) => {
|
|
setRequestingPermission(permissionType);
|
|
try {
|
|
const granted = await requestPermission(permissionType);
|
|
if (granted) {
|
|
showSuccessToast(
|
|
permissionType === "microphone"
|
|
? t("permissionDialog.grantedToastMicrophone")
|
|
: t("permissionDialog.grantedToastCamera"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await openUrl(
|
|
`x-apple.systempreferences:com.apple.preference.security?${
|
|
permissionType === "microphone"
|
|
? "Privacy_Microphone"
|
|
: "Privacy_Camera"
|
|
}`,
|
|
);
|
|
showErrorToast(
|
|
permissionType === "microphone"
|
|
? t("permissionDialog.stillNotGrantedMicrophone")
|
|
: t("permissionDialog.stillNotGrantedCamera"),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to request permission:", error);
|
|
showErrorToast(t("permissionDialog.requestFailed"));
|
|
} finally {
|
|
setRequestingPermission(null);
|
|
}
|
|
},
|
|
[requestPermission, t],
|
|
);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
setIsSaving(true);
|
|
setSaveError(null);
|
|
try {
|
|
// Update settings with current custom theme state
|
|
let settingsToSave: AppSettings = {
|
|
...settings,
|
|
custom_theme:
|
|
settings.theme === "custom"
|
|
? customThemeState.colors
|
|
: settings.custom_theme,
|
|
};
|
|
|
|
console.log("[settings-dialog] Saving settings:", {
|
|
theme: settingsToSave.theme,
|
|
hasCustomTheme: !!settingsToSave.custom_theme,
|
|
customThemeKeys: settingsToSave.custom_theme
|
|
? Object.keys(settingsToSave.custom_theme).length
|
|
: 0,
|
|
});
|
|
|
|
const savedSettings = await invoke<AppSettings>("save_app_settings", {
|
|
settings: settingsToSave,
|
|
});
|
|
|
|
console.log("[settings-dialog] Saved settings response:", {
|
|
theme: savedSettings.theme,
|
|
hasCustomTheme: !!savedSettings.custom_theme,
|
|
customThemeKeys: savedSettings.custom_theme
|
|
? Object.keys(savedSettings.custom_theme).length
|
|
: 0,
|
|
});
|
|
|
|
// Update settings with any generated tokens
|
|
setSettings(savedSettings);
|
|
settingsToSave = savedSettings;
|
|
setOriginalSettings(savedSettings);
|
|
originalSettingsRef.current = savedSettings;
|
|
// Pass the actual theme value through. Calling setTheme("dark") here
|
|
// when the user is on "custom" pushes the provider state to "dark",
|
|
// which triggers its clear-custom-vars effect and wipes the CSS
|
|
// variables we set just below — that's the bug where saving a custom
|
|
// theme made it disappear until the app was restarted.
|
|
setTheme(settings.theme);
|
|
|
|
// Apply or clear custom variables only on Save
|
|
if (settings.theme === "custom") {
|
|
if (Object.keys(customThemeState.colors).length > 0) {
|
|
try {
|
|
clearThemeColors();
|
|
applyThemeColors(customThemeState.colors);
|
|
} catch {
|
|
/* empty */
|
|
}
|
|
}
|
|
} else {
|
|
try {
|
|
clearThemeColors();
|
|
} catch {
|
|
/* empty */
|
|
}
|
|
}
|
|
|
|
// Save language if changed
|
|
if (selectedLanguage !== originalLanguage) {
|
|
await changeLanguage(
|
|
selectedLanguage === "system"
|
|
? null
|
|
: (selectedLanguage as
|
|
| "en"
|
|
| "es"
|
|
| "pt"
|
|
| "fr"
|
|
| "zh"
|
|
| "ja"
|
|
| "ko"
|
|
| "ru"
|
|
| "vi"),
|
|
);
|
|
setOriginalLanguage(selectedLanguage);
|
|
}
|
|
|
|
setOriginalSettings(settingsToSave);
|
|
// Keep the ref in sync so the unmount-restore effect re-applies the
|
|
// just-SAVED theme, not the pre-save one (which reverted the theme
|
|
// until app restart).
|
|
originalSettingsRef.current = settingsToSave;
|
|
setSaved(true);
|
|
if (!subPage) onClose();
|
|
} catch (error) {
|
|
console.error("Failed to save settings:", error);
|
|
const message = translateBackendError(t, error);
|
|
setSaveError(message);
|
|
showErrorToast(t("appFeedback.saveFailed"), { description: message });
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [
|
|
onClose,
|
|
setTheme,
|
|
settings,
|
|
customThemeState,
|
|
selectedLanguage,
|
|
originalLanguage,
|
|
changeLanguage,
|
|
subPage,
|
|
t,
|
|
]);
|
|
|
|
const updateSetting = useCallback(
|
|
(
|
|
key: keyof AppSettings,
|
|
value: boolean | number | string | Record<string, string> | undefined,
|
|
) => {
|
|
setSaveError(null);
|
|
setSettings((prev) => ({ ...prev, [key]: value as unknown as never }));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const discardChanges = useCallback(() => {
|
|
// Restore original theme when closing without saving
|
|
// Only a revert the user can see is worth animating.
|
|
const changed = originalSettings.theme !== settings.theme;
|
|
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
|
applyCustomTheme(originalSettings.custom_theme, { animate: changed });
|
|
} else {
|
|
clearCustomTheme({ animate: false });
|
|
setTheme(originalSettings.theme, { animate: changed });
|
|
}
|
|
|
|
setSettings(originalSettings);
|
|
setSelectedLanguage(originalLanguage);
|
|
setCustomThemeState({
|
|
selectedThemeId: originalSettings.custom_theme
|
|
? (getThemeByColors(originalSettings.custom_theme)?.id ?? null)
|
|
: null,
|
|
colors: originalSettings.custom_theme ?? {},
|
|
});
|
|
setSaveError(null);
|
|
setSaved(false);
|
|
}, [
|
|
applyCustomTheme,
|
|
clearCustomTheme,
|
|
originalSettings,
|
|
originalLanguage,
|
|
setTheme,
|
|
settings.theme,
|
|
]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
discardChanges();
|
|
onClose();
|
|
}, [discardChanges, onClose]);
|
|
|
|
// Only clear custom theme when switching away from custom, don't apply live
|
|
// changes. Gated on the async settings load: before it resolves the state
|
|
// still holds the "system" default, and clearing then wipes the user's
|
|
// custom theme vars on every Settings visit (the theme-reverts-to-dark bug).
|
|
//
|
|
// This effect is both the restore-on-open and the live switch when the user
|
|
// picks a theme, so it animates only a real change: the first run after the
|
|
// settings load is re-applying the palette already on screen. Clearing the
|
|
// inline custom vars is never the animated half — switching to a stylesheet
|
|
// palette makes them invisible either way, and running two transitions
|
|
// back to back just aborts the first one mid-snapshot.
|
|
const appliedThemeRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (hasLoadedSettings && settings.theme !== "custom") {
|
|
const previous = appliedThemeRef.current;
|
|
appliedThemeRef.current = settings.theme;
|
|
clearCustomTheme({ animate: false });
|
|
setTheme(settings.theme, {
|
|
animate: previous !== null && previous !== settings.theme,
|
|
});
|
|
}
|
|
}, [hasLoadedSettings, settings.theme, clearCustomTheme, setTheme]);
|
|
|
|
// Unmount-safe theme restore: rail navigation unmounts this dialog without
|
|
// calling handleClose, which used to leave the theme vars wiped for the
|
|
// rest of the session.
|
|
useEffect(() => {
|
|
return () => {
|
|
const s = originalSettingsRef.current;
|
|
if (!hasLoadedSettingsRef.current || !s) return;
|
|
if (s.theme === "custom" && s.custom_theme) {
|
|
applyThemeColors(s.custom_theme);
|
|
} else {
|
|
// A non-custom persisted theme (light/dark/system) uses the
|
|
// stylesheet palette — strip any leftover inline custom vars so a
|
|
// just-saved switch away from custom isn't reverted on unmount.
|
|
clearThemeColors();
|
|
setTheme(s.theme, { animate: false });
|
|
}
|
|
};
|
|
}, [setTheme]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadSettings().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
checkDefaultBrowserStatus().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
|
|
if (isMacOS) {
|
|
loadPermissions();
|
|
}
|
|
|
|
// Re-check periodically so the badge follows a change the user made in
|
|
// their desktop settings. Ten seconds rather than two: on Linux each
|
|
// check is three subprocesses, and nobody flips their default browser
|
|
// often enough to notice the difference.
|
|
const intervalId = setInterval(() => {
|
|
checkDefaultBrowserStatus().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}, 10000);
|
|
|
|
// Cleanup interval on component unmount or dialog close
|
|
return () => {
|
|
clearInterval(intervalId);
|
|
};
|
|
}
|
|
}, [
|
|
isOpen,
|
|
isMacOS,
|
|
loadPermissions,
|
|
checkDefaultBrowserStatus,
|
|
loadSettings,
|
|
]);
|
|
|
|
// Initialize language selection when dialog opens or language loads
|
|
useEffect(() => {
|
|
if (isOpen && !isLanguageLoading) {
|
|
setSelectedLanguage(currentLanguage);
|
|
setOriginalLanguage(currentLanguage);
|
|
}
|
|
}, [isOpen, currentLanguage, isLanguageLoading]);
|
|
|
|
// Update permissions when the permission states change
|
|
useEffect(() => {
|
|
if (isMacOS) {
|
|
const permissionList: PermissionInfo[] = [
|
|
{
|
|
permission_type: "microphone",
|
|
isGranted: isMicrophoneAccessGranted,
|
|
description: getPermissionDescription("microphone"),
|
|
},
|
|
{
|
|
permission_type: "camera",
|
|
isGranted: isCameraAccessGranted,
|
|
description: getPermissionDescription("camera"),
|
|
},
|
|
];
|
|
setPermissions(permissionList);
|
|
} else {
|
|
setPermissions([]);
|
|
}
|
|
}, [
|
|
isMacOS,
|
|
isMicrophoneAccessGranted,
|
|
isCameraAccessGranted,
|
|
getPermissionDescription,
|
|
]);
|
|
|
|
const draftColors =
|
|
settings.theme === "custom"
|
|
? customThemeState.colors
|
|
: (settings.custom_theme ?? {});
|
|
const savedColors = originalSettings.custom_theme ?? {};
|
|
const themeChanged =
|
|
settings.theme !== originalSettings.theme ||
|
|
Object.keys(draftColors).length !== Object.keys(savedColors).length ||
|
|
Object.entries(draftColors).some(
|
|
([key, value]) => savedColors[key] !== value,
|
|
);
|
|
const pendingChanges = [
|
|
...(themeChanged ? [t("settings.appearance.title")] : []),
|
|
...(selectedLanguage !== originalLanguage
|
|
? [t("settings.language.title")]
|
|
: []),
|
|
...(
|
|
[
|
|
["api_enabled", "settings.integrations.title"],
|
|
["disable_auto_updates", "settings.disableAutoUpdates"],
|
|
[
|
|
"keep_decrypted_profiles_in_ram",
|
|
"settings.keepDecryptedProfilesInRam",
|
|
],
|
|
["fingerprint_gate_disabled", "settings.privacy.consistencyWarning"],
|
|
[
|
|
"vpn_extension_warning_disabled",
|
|
"settings.privacy.vpnExtensionWarning",
|
|
],
|
|
] as const
|
|
)
|
|
.filter(
|
|
([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]),
|
|
)
|
|
.map(([, key]) => t(key)),
|
|
...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !==
|
|
(originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS)
|
|
? [t("settings.trashRetention")]
|
|
: []),
|
|
];
|
|
const hasChanges = pendingChanges.length > 0;
|
|
const sections = [
|
|
["appearance", "settings.appearance.title", ["settings.appearance"]],
|
|
["language", "settings.language.title", ["settings.language"]],
|
|
...(!systemInfo?.portable
|
|
? [
|
|
[
|
|
"default",
|
|
"settings.defaultBrowser.title",
|
|
["settings.defaultBrowser"],
|
|
],
|
|
]
|
|
: []),
|
|
...(isMacOS
|
|
? [
|
|
[
|
|
"permissions",
|
|
"settings.permissions.title",
|
|
["settings.permissions"],
|
|
],
|
|
]
|
|
: []),
|
|
["integrations", "settings.integrations.title", ["settings.integrations"]],
|
|
["dns", "dnsBlocklist.title", ["dnsBlocklist"]],
|
|
["encryption", "settings.encryption.title", ["settings.encryption"]],
|
|
["commercial", "settings.commercial.title", ["settings.commercial"]],
|
|
["dataRoot", "settings.dataRoot.title", ["settings.dataRoot"]],
|
|
[
|
|
"advanced",
|
|
"settings.advanced.title",
|
|
[
|
|
"settings.advanced",
|
|
"settings.disableAutoUpdates",
|
|
"settings.disableAutoUpdatesDescription",
|
|
"settings.keepDecryptedProfilesInRam",
|
|
"settings.keepDecryptedProfilesInRamDescription",
|
|
"settings.trashRetention",
|
|
"settings.trashRetentionDescription",
|
|
"settings.privacy",
|
|
],
|
|
],
|
|
] as Array<[string, string, string[]]>;
|
|
const needle = search.trim().toLocaleLowerCase();
|
|
const matchingSections = sections.filter(([, , keys]) => {
|
|
const text = keys
|
|
.map((key) => JSON.stringify(t(key, { returnObjects: true })))
|
|
.join(" ")
|
|
.toLocaleLowerCase();
|
|
return needle.split(/\s+/).every((word) => text.includes(word));
|
|
});
|
|
const sectionVisible = (id: string) =>
|
|
matchingSections.some(([key]) => key === id);
|
|
const visibleSectionIds = matchingSections.map(([id]) => id).join(",");
|
|
|
|
const syncActiveSection = useCallback(() => {
|
|
const scroller = scrollerRef.current;
|
|
const root = sectionsRef.current;
|
|
if (!scroller || !root) return;
|
|
const nodes = [
|
|
...root.querySelectorAll<HTMLElement>("[data-settings-section]"),
|
|
].filter((node) => !node.hidden);
|
|
if (nodes.length === 0) {
|
|
setActiveSection(null);
|
|
return;
|
|
}
|
|
const pinned = pinnedSectionRef.current;
|
|
if (pinned && Math.abs(scroller.scrollTop - pinned.scrollTop) < 4) {
|
|
setActiveSection(pinned.id);
|
|
return;
|
|
}
|
|
pinnedSectionRef.current = null;
|
|
const atBottom =
|
|
scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
|
|
if (atBottom) {
|
|
setActiveSection(nodes[nodes.length - 1].dataset.settingsSection ?? null);
|
|
return;
|
|
}
|
|
const threshold = scroller.getBoundingClientRect().top + 12;
|
|
let current = nodes[0];
|
|
for (const node of nodes) {
|
|
if (node.getBoundingClientRect().top <= threshold) current = node;
|
|
else break;
|
|
}
|
|
setActiveSection(current.dataset.settingsSection ?? null);
|
|
}, []);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: the section list changes with the search filter and the settings load, and the sync must run again then
|
|
useEffect(() => {
|
|
syncActiveSection();
|
|
}, [syncActiveSection, visibleSectionIds, isLoading]);
|
|
|
|
useEffect(() => {
|
|
if (!jumpTo) return;
|
|
const section = sectionsRef.current?.querySelector<HTMLElement>(
|
|
`[data-settings-section="${jumpTo}"]`,
|
|
);
|
|
section?.scrollIntoView({ block: "start" });
|
|
section?.focus({ preventScroll: true });
|
|
if (scrollerRef.current) {
|
|
pinnedSectionRef.current = {
|
|
id: jumpTo,
|
|
scrollTop: scrollerRef.current.scrollTop,
|
|
};
|
|
}
|
|
setActiveSection(jumpTo);
|
|
setJumpTo(null);
|
|
}, [jumpTo]);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
|
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-md flex-col">
|
|
{!subPage && (
|
|
<DialogHeader className="shrink-0">
|
|
<DialogTitle>{t("settings.title")}</DialogTitle>
|
|
</DialogHeader>
|
|
)}
|
|
|
|
<div className="mx-auto flex w-full max-w-4xl shrink-0 flex-col gap-2 pb-3">
|
|
<Input
|
|
data-slot="settings-search"
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
// Escape empties the filter before it can close the page.
|
|
if (event.key === "Escape" && search) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setSearch("");
|
|
}
|
|
}}
|
|
placeholder={t("appFeedback.searchSettings")}
|
|
aria-label={t("appFeedback.searchSettings")}
|
|
className="h-8"
|
|
/>
|
|
<nav
|
|
aria-label={t("settings.title")}
|
|
className="flex flex-wrap gap-x-1 gap-y-1"
|
|
>
|
|
{sections.map(([id, label]) => {
|
|
const active = activeSection === id;
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={id}
|
|
data-slot="settings-nav-item"
|
|
data-section={id}
|
|
aria-current={active ? "location" : undefined}
|
|
onClick={() => {
|
|
setSearch("");
|
|
setJumpTo(id);
|
|
}}
|
|
className={cn(
|
|
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
|
|
active
|
|
? "text-accent-foreground"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
>
|
|
{active && (
|
|
<motion.span
|
|
aria-hidden="true"
|
|
data-slot="settings-nav-indicator"
|
|
layoutId={
|
|
animateNav ? "settings-nav-indicator" : undefined
|
|
}
|
|
initial={false}
|
|
transition={
|
|
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
|
|
}
|
|
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
|
/>
|
|
)}
|
|
{t(label)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* The scroller spans the full width (so the wheel works over the
|
|
side gutters); the width cap lives on the inner column. Fusing
|
|
them was the dead-wheel-zone bug. */}
|
|
<div
|
|
ref={scrollerRef}
|
|
onScroll={syncActiveSection}
|
|
className={cn(
|
|
"min-h-0 flex-1 overflow-y-auto",
|
|
subPage ? "py-2" : "py-4",
|
|
)}
|
|
>
|
|
<fieldset
|
|
ref={sectionsRef}
|
|
disabled={isSaving || isLoading}
|
|
className={cn(
|
|
"grid min-w-0 gap-6 pb-4",
|
|
subPage && "mx-auto w-full max-w-4xl",
|
|
)}
|
|
>
|
|
{/* Appearance Section */}
|
|
<div
|
|
data-settings-section="appearance"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("appearance")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.appearance.title")}
|
|
</Label>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="theme-select" className="text-sm">
|
|
{t("settings.appearance.theme")}
|
|
</Label>
|
|
<Select
|
|
value={settings.theme}
|
|
onValueChange={(value) => {
|
|
updateSetting("theme", value);
|
|
if (value === "custom") {
|
|
const tokyoNightTheme = getThemeById("tokyo-night");
|
|
if (tokyoNightTheme) {
|
|
setCustomThemeState({
|
|
selectedThemeId: "tokyo-night",
|
|
colors: tokyoNightTheme.colors,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger id="theme-select">
|
|
<SelectValue
|
|
placeholder={t("settings.appearance.selectTheme")}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="light">
|
|
{t("settings.appearance.light")}
|
|
</SelectItem>
|
|
<SelectItem value="dark">
|
|
{t("settings.appearance.dark")}
|
|
</SelectItem>
|
|
<SelectItem value="system">
|
|
{t("settings.appearance.system")}
|
|
</SelectItem>
|
|
<SelectItem value="custom">
|
|
{t("common.labels.custom")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.appearance.themeDescription")}
|
|
</p>
|
|
|
|
{settings.theme === "custom" && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label
|
|
htmlFor="theme-preset-select"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("settings.appearance.themePreset")}
|
|
</Label>
|
|
<Select
|
|
value={customThemeState.selectedThemeId ?? "custom"}
|
|
onValueChange={(value) => {
|
|
if (value === "custom") {
|
|
setCustomThemeState((prev) => ({
|
|
...prev,
|
|
selectedThemeId: null,
|
|
}));
|
|
} else {
|
|
const theme = getThemeById(value);
|
|
if (theme) {
|
|
setCustomThemeState({
|
|
selectedThemeId: value,
|
|
colors: theme.colors,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger id="theme-preset-select">
|
|
<SelectValue
|
|
placeholder={t(
|
|
"settings.appearance.selectThemePreset",
|
|
)}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{THEMES.map((theme) => (
|
|
<SelectItem key={theme.id} value={theme.id}>
|
|
{theme.name}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="custom">
|
|
{t("settings.appearance.yourOwn")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="text-sm font-medium">
|
|
{t("settings.appearance.customColors")}
|
|
</div>
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-3">
|
|
{THEME_VARIABLES.map(({ key, label }) => {
|
|
const colorValue =
|
|
customThemeState.colors[key] ?? "#000000";
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="flex flex-col items-center gap-1"
|
|
>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
className="size-8 cursor-pointer rounded-md border shadow-sm"
|
|
style={{ backgroundColor: colorValue }}
|
|
/>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-[320px] p-3"
|
|
sideOffset={6}
|
|
>
|
|
<ColorPicker
|
|
className="rounded-md border bg-background p-3 shadow-sm"
|
|
value={colorValue}
|
|
onColorChange={([r, g, b, a]) => {
|
|
const next = Color({ r, g, b }).alpha(a);
|
|
const nextStr = next.hexa();
|
|
const newColors = {
|
|
...customThemeState.colors,
|
|
[key]: nextStr,
|
|
};
|
|
|
|
// Check if colors match any preset theme
|
|
const matchingTheme =
|
|
getThemeByColors(newColors);
|
|
|
|
setCustomThemeState({
|
|
selectedThemeId:
|
|
matchingTheme?.id ?? null,
|
|
colors: newColors,
|
|
});
|
|
}}
|
|
>
|
|
<ColorPickerSelection className="h-36 rounded" />
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<ColorPickerEyeDropper />
|
|
<div className="grid w-full gap-1">
|
|
<ColorPickerHue />
|
|
<ColorPickerAlpha />
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<ColorPickerOutput />
|
|
<ColorPickerFormat />
|
|
</div>
|
|
</ColorPicker>
|
|
</PopoverContent>
|
|
</Popover>
|
|
<div className="text-center text-[10px] leading-tight text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Language Section */}
|
|
<div
|
|
data-settings-section="language"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("language")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.language.title")}
|
|
</Label>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="language-select" className="text-sm">
|
|
{t("settings.language.interface")}
|
|
</Label>
|
|
<Select
|
|
value={selectedLanguage ?? "system"}
|
|
onValueChange={(value) => {
|
|
setSelectedLanguage(value);
|
|
}}
|
|
disabled={isLanguageLoading}
|
|
>
|
|
<SelectTrigger id="language-select">
|
|
<SelectValue
|
|
placeholder={t("settings.language.selectLanguage")}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="system">
|
|
{t("settings.language.systemDefault")}
|
|
</SelectItem>
|
|
{supportedLanguages.map((lang) => (
|
|
<SelectItem key={lang.code} value={lang.code}>
|
|
{lang.nativeName} ({lang.name})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.language.description")}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Default Browser Section - hidden in portable mode */}
|
|
{!systemInfo?.portable && (
|
|
<div
|
|
data-settings-section="default"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("default")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-base font-medium">
|
|
{t("settings.defaultBrowser.title")}
|
|
</Label>
|
|
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
|
|
{isDefaultBrowser
|
|
? t("common.status.active")
|
|
: t("common.status.inactive")}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("appFeedback.appliesImmediately")}
|
|
</p>
|
|
|
|
<LoadingButton
|
|
isLoading={isSettingDefault}
|
|
onClick={() => {
|
|
handleSetDefaultBrowser().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
disabled={isDefaultBrowser}
|
|
variant={isDefaultBrowser ? "outline" : "default"}
|
|
className="w-full"
|
|
>
|
|
{isDefaultBrowser
|
|
? t("settings.defaultBrowser.alreadyDefault")
|
|
: t("settings.defaultBrowser.setAsDefault")}
|
|
</LoadingButton>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.defaultBrowser.description")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Permissions Section - Only show on macOS */}
|
|
{isMacOS && (
|
|
<div
|
|
data-settings-section="permissions"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("permissions")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.permissions.title")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("appFeedback.appliesImmediately")}
|
|
</p>
|
|
|
|
{isLoadingPermissions ? (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("settings.permissions.loading")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{permissions.map((permission) => (
|
|
<div
|
|
key={permission.permission_type}
|
|
className="flex items-center justify-between rounded-lg border p-3"
|
|
>
|
|
<div className="flex items-center gap-x-3">
|
|
{getPermissionIcon(permission.permission_type)}
|
|
<div>
|
|
<div className="text-sm font-medium">
|
|
{getPermissionDisplayName(
|
|
permission.permission_type,
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{permission.description}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-x-2">
|
|
{getStatusBadge(permission.isGranted)}
|
|
{!permission.isGranted && (
|
|
<LoadingButton
|
|
size="sm"
|
|
isLoading={
|
|
requestingPermission ===
|
|
permission.permission_type
|
|
}
|
|
onClick={() => {
|
|
handleRequestPermission(
|
|
permission.permission_type,
|
|
).catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
>
|
|
{t("common.buttons.grant")}
|
|
</LoadingButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
|
{t("settings.permissions.description")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Integrations Section */}
|
|
<div
|
|
data-settings-section="integrations"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("integrations")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.integrations.title")}
|
|
</Label>
|
|
<p className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
|
|
{t("settings.integrations.description")}
|
|
</p>
|
|
<RippleButton
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={onIntegrationsOpen}
|
|
>
|
|
{t("integrations.openSettings")}
|
|
</RippleButton>
|
|
</div>
|
|
|
|
{/* DNS Blocklist Section */}
|
|
<div
|
|
data-settings-section="dns"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("dns")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("dnsBlocklist.title")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("dnsBlocklist.settingsDescription")}
|
|
</p>
|
|
<RippleButton
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => setDnsBlocklistDialogOpen(true)}
|
|
>
|
|
{t("dnsBlocklist.manageLists")}
|
|
</RippleButton>
|
|
</div>
|
|
|
|
{/* Sync Encryption Section */}
|
|
<div
|
|
data-settings-section="encryption"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("encryption")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.encryption.title")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("appFeedback.appliesImmediately")}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.encryption.description")}
|
|
</p>
|
|
|
|
{!canUseEncryption ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("settings.encryption.requiresProOrOwner")}
|
|
</p>
|
|
) : hasE2ePassword ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="default">
|
|
{t("settings.encryption.passwordSet")}
|
|
</Badge>
|
|
<span className="text-sm text-muted-foreground">
|
|
{t("settings.encryption.passwordSetDescription")}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isRemovingE2e}
|
|
onClick={() => {
|
|
setVerifyE2ePassword("");
|
|
setIsVerifyE2eOpen(true);
|
|
}}
|
|
>
|
|
{t("settings.encryption.validatePassword")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isRemovingE2e}
|
|
onClick={() => {
|
|
setHasE2ePassword(false);
|
|
setE2ePassword("");
|
|
setE2ePasswordConfirm("");
|
|
setE2eError("");
|
|
}}
|
|
>
|
|
{t("settings.encryption.changePassword")}
|
|
</Button>
|
|
<LoadingButton
|
|
variant="destructive"
|
|
size="sm"
|
|
isLoading={isRemovingE2e}
|
|
onClick={async () => {
|
|
setIsRemovingE2e(true);
|
|
try {
|
|
// Await the rollover so the user sees an error if
|
|
// re-syncing fails. Previously the rollover was
|
|
// fire-and-forget (`void invoke(...)`) which left
|
|
// half-removed state on screen with no feedback.
|
|
await invoke("delete_e2e_password");
|
|
setHasE2ePassword(false);
|
|
try {
|
|
await invoke(
|
|
"rollover_encryption_for_all_entities",
|
|
);
|
|
} catch (rolloverErr) {
|
|
console.error(
|
|
"Rollover after password removal failed:",
|
|
rolloverErr,
|
|
);
|
|
showErrorToast(String(rolloverErr));
|
|
}
|
|
showSuccessToast(t("settings.encryption.removed"));
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsRemovingE2e(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.encryption.removePassword")}
|
|
</LoadingButton>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<Input
|
|
type="password"
|
|
placeholder={t("settings.encryption.passwordPlaceholder")}
|
|
value={e2ePassword}
|
|
onChange={(e) => {
|
|
setE2ePassword(e.target.value);
|
|
setE2eError("");
|
|
}}
|
|
/>
|
|
<Input
|
|
type="password"
|
|
placeholder={t("settings.encryption.confirmPlaceholder")}
|
|
value={e2ePasswordConfirm}
|
|
onChange={(e) => {
|
|
setE2ePasswordConfirm(e.target.value);
|
|
setE2eError("");
|
|
}}
|
|
/>
|
|
{e2eError && (
|
|
<p className="text-sm text-destructive-text">
|
|
{e2eError}
|
|
</p>
|
|
)}
|
|
<LoadingButton
|
|
variant="default"
|
|
size="sm"
|
|
isLoading={isSavingE2e}
|
|
onClick={async () => {
|
|
if (e2ePassword.length < 8) {
|
|
setE2eError(
|
|
t("settings.encryption.passwordTooShort"),
|
|
);
|
|
return;
|
|
}
|
|
if (e2ePassword !== e2ePasswordConfirm) {
|
|
setE2eError(
|
|
t("settings.encryption.passwordMismatch"),
|
|
);
|
|
return;
|
|
}
|
|
setIsSavingE2e(true);
|
|
try {
|
|
await invoke("set_e2e_password", {
|
|
password: e2ePassword,
|
|
});
|
|
setHasE2ePassword(true);
|
|
setE2ePassword("");
|
|
setE2ePasswordConfirm("");
|
|
try {
|
|
// Await rollover so any failure surfaces to the
|
|
// user instead of being lost via fire-and-forget.
|
|
// Without this, "change password" leaves entities
|
|
// half-re-encrypted with no visible error.
|
|
await invoke(
|
|
"rollover_encryption_for_all_entities",
|
|
);
|
|
} catch (rolloverErr) {
|
|
console.error(
|
|
"Rollover after password set failed:",
|
|
rolloverErr,
|
|
);
|
|
showErrorToast(String(rolloverErr));
|
|
}
|
|
showSuccessToast(
|
|
t("settings.encryption.passwordSaved"),
|
|
);
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsSavingE2e(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.encryption.setPassword")}
|
|
</LoadingButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Commercial License Section */}
|
|
<div
|
|
data-settings-section="commercial"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("commercial")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.commercial.title")}
|
|
</Label>
|
|
|
|
<div className="flex items-center justify-between rounded-md border bg-muted/40 p-3">
|
|
{cloudUser != null &&
|
|
effectivePlanOf(cloudUser) !== "free" ? (
|
|
// Paid Donut plan supersedes the local commercial trial —
|
|
// the trial only exists to gate commercial use until the
|
|
// user subscribes. Showing "Trial expired" to a paying
|
|
// customer reads like a billing error, so swap in a
|
|
// subscription-active badge instead.
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-success-text">
|
|
{t("settings.commercial.subscriptionActive", {
|
|
plan: effectivePlanOf(cloudUser),
|
|
})}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.commercial.subscriptionActiveDescription")}
|
|
</p>
|
|
</div>
|
|
) : trialStatus?.type === "Active" ? (
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">
|
|
{t("settings.commercial.trialActive", {
|
|
days: trialStatus.days_remaining,
|
|
hours: trialStatus.hours_remaining,
|
|
})}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.commercial.trialActiveDescription")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-warning-text">
|
|
{t("settings.commercial.trialExpired")}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.commercial.trialExpiredDescription")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Data directory Section */}
|
|
<div
|
|
data-settings-section="dataRoot"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("dataRoot")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.dataRoot.title")}
|
|
</Label>
|
|
<DataRootSetting />
|
|
</div>
|
|
|
|
{/* Advanced Section */}
|
|
<div
|
|
data-settings-section="advanced"
|
|
tabIndex={-1}
|
|
hidden={!sectionVisible("advanced")}
|
|
className="scroll-mt-2 space-y-4 focus:outline-none"
|
|
>
|
|
<Label className="text-base font-medium">
|
|
{t("settings.advanced.title")}
|
|
</Label>
|
|
|
|
{!isLinux && (
|
|
<div className="flex items-start gap-x-3 rounded-lg border p-3">
|
|
<Checkbox
|
|
id="disable-auto-updates"
|
|
checked={settings.disable_auto_updates ?? false}
|
|
onCheckedChange={(checked) => {
|
|
updateSetting(
|
|
"disable_auto_updates",
|
|
checked as boolean,
|
|
);
|
|
}}
|
|
/>
|
|
<div className="space-y-1">
|
|
<Label
|
|
htmlFor="disable-auto-updates"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("settings.disableAutoUpdates")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.disableAutoUpdatesDescription")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-start gap-x-3 rounded-lg border p-3">
|
|
<Checkbox
|
|
id="keep-decrypted-profiles-in-ram"
|
|
checked={settings.keep_decrypted_profiles_in_ram ?? false}
|
|
onCheckedChange={(checked) => {
|
|
updateSetting(
|
|
"keep_decrypted_profiles_in_ram",
|
|
checked as boolean,
|
|
);
|
|
}}
|
|
/>
|
|
<div className="space-y-1">
|
|
<Label
|
|
htmlFor="keep-decrypted-profiles-in-ram"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("settings.keepDecryptedProfilesInRam")}
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.keepDecryptedProfilesInRamDescription")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2 rounded-lg border p-3">
|
|
<Label
|
|
htmlFor="trash-retention-select"
|
|
className="text-sm font-medium"
|
|
>
|
|
{t("settings.trashRetention")}
|
|
</Label>
|
|
<Select
|
|
value={String(
|
|
settings.trash_retention_days ??
|
|
DEFAULT_TRASH_RETENTION_DAYS,
|
|
)}
|
|
onValueChange={(value) => {
|
|
updateSetting("trash_retention_days", Number(value));
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
id="trash-retention-select"
|
|
data-slot="trash-retention-select"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{trashRetentionOptions(
|
|
settings.trash_retention_days ??
|
|
DEFAULT_TRASH_RETENTION_DAYS,
|
|
).map((days) => (
|
|
<SelectItem key={days} value={String(days)}>
|
|
{t("settings.trashRetentionDays", { count: days })}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.trashRetentionDescription")}
|
|
</p>
|
|
</div>
|
|
|
|
<LoadingButton
|
|
isLoading={isClearingCache}
|
|
onClick={() => {
|
|
handleClearCache().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
{t("settings.advanced.clearCache")}
|
|
</LoadingButton>
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.advanced.clearCacheDescription")}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
|
<RippleButton
|
|
variant="outline"
|
|
className="text-xs"
|
|
onClick={async () => {
|
|
try {
|
|
const content = await invoke<string>("read_log_files");
|
|
await writeClipboardText(content);
|
|
showSuccessToast(
|
|
t("settings.advanced.copyLogsSuccess"),
|
|
);
|
|
} catch (err) {
|
|
showErrorToast(String(err));
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.advanced.copyLogs")}
|
|
</RippleButton>
|
|
<RippleButton
|
|
variant="outline"
|
|
className="text-xs"
|
|
onClick={async () => {
|
|
try {
|
|
await invoke("open_log_directory");
|
|
} catch (err) {
|
|
showErrorToast(String(err));
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.advanced.openLogDir")}
|
|
</RippleButton>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.advanced.copyLogsDescription")}
|
|
</p>
|
|
|
|
<div className="flex items-center justify-between gap-3 border-t pt-3">
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-sm font-medium">
|
|
{t("settings.privacy.consistencyWarning")}
|
|
</span>
|
|
<span className="block text-xs text-muted-foreground">
|
|
{t("settings.privacy.consistencyWarningDescription")}
|
|
</span>
|
|
</div>
|
|
<AnimatedSwitch
|
|
aria-label={t("settings.privacy.consistencyWarning")}
|
|
checked={!(settings.fingerprint_gate_disabled ?? false)}
|
|
onCheckedChange={(v) => {
|
|
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>
|
|
|
|
<div className="border-t pt-3">
|
|
<LoadingButton
|
|
isLoading={isClearingTraffic}
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => {
|
|
handleClearTraffic().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
>
|
|
{t("settings.privacy.clearTraffic")}
|
|
</LoadingButton>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
{t("settings.privacy.clearTrafficDescription")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{matchingSections.length === 0 && (
|
|
<p role="status" className="py-6 text-sm text-muted-foreground">
|
|
{t("common.noResults")}
|
|
</p>
|
|
)}
|
|
{/* System Info */}
|
|
{systemInfo && !needle && (
|
|
<div className="border-t pt-2">
|
|
<p className="font-mono text-xs whitespace-pre-line text-muted-foreground select-all">
|
|
{`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</fieldset>
|
|
</div>
|
|
|
|
<div
|
|
data-slot="settings-feedback"
|
|
className="mx-auto w-full max-w-4xl shrink-0 py-2 text-xs"
|
|
>
|
|
{saveError ? (
|
|
<p role="alert" className="break-words text-destructive-text">
|
|
{saveError}
|
|
</p>
|
|
) : (
|
|
<p
|
|
role="status"
|
|
className="flex min-h-5 items-center gap-2 text-muted-foreground"
|
|
>
|
|
{!hasChanges && saved && <ConfirmationMark />}
|
|
{isLoading
|
|
? t("common.buttons.loading")
|
|
: isSaving
|
|
? t("common.buttons.saving")
|
|
: hasChanges
|
|
? t("appFeedback.pendingChanges", {
|
|
count: pendingChanges.length,
|
|
})
|
|
: saved
|
|
? t("common.buttons.saved")
|
|
: t("appFeedback.noChanges")}
|
|
</p>
|
|
)}
|
|
{hasChanges && (
|
|
<details className="mt-1">
|
|
<summary className="cursor-pointer py-1 font-medium">
|
|
{t("appFeedback.reviewChanges")}
|
|
</summary>
|
|
<ul className="max-h-28 overflow-y-auto py-1">
|
|
{pendingChanges.map((label) => (
|
|
<li key={label} className="py-0.5">
|
|
{label}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</details>
|
|
)}
|
|
</div>
|
|
{subPage ? (
|
|
<div className="mx-auto flex w-full max-w-4xl shrink-0 items-center justify-end gap-2 pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
data-slot="settings-discard"
|
|
onClick={discardChanges}
|
|
disabled={!hasChanges || isSaving}
|
|
>
|
|
{t("appFeedback.discardChanges")}
|
|
</Button>
|
|
<LoadingButton
|
|
size="sm"
|
|
isLoading={isSaving}
|
|
onClick={() => {
|
|
handleSave().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
disabled={isLoading || !hasChanges}
|
|
>
|
|
{t("common.buttons.saveSettings")}
|
|
</LoadingButton>
|
|
</div>
|
|
) : (
|
|
<DialogFooter className="shrink-0">
|
|
<RippleButton variant="outline" onClick={handleClose}>
|
|
{t("common.buttons.cancel")}
|
|
</RippleButton>
|
|
<LoadingButton
|
|
isLoading={isSaving}
|
|
onClick={() => {
|
|
handleSave().catch((err: unknown) => {
|
|
console.error(err);
|
|
});
|
|
}}
|
|
disabled={isLoading || !hasChanges}
|
|
>
|
|
{t("common.buttons.saveSettings")}
|
|
</LoadingButton>
|
|
</DialogFooter>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
<DnsBlocklistDialog
|
|
isOpen={dnsBlocklistDialogOpen}
|
|
onClose={() => setDnsBlocklistDialogOpen(false)}
|
|
/>
|
|
<Dialog
|
|
open={isVerifyE2eOpen}
|
|
onOpenChange={(open) => {
|
|
if (!isVerifyingE2e) {
|
|
setIsVerifyE2eOpen(open);
|
|
if (!open) setVerifyE2ePassword("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("settings.encryption.validateDialog.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("settings.encryption.validateDialog.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<Input
|
|
type="password"
|
|
placeholder={t("settings.encryption.passwordPlaceholder")}
|
|
value={verifyE2ePassword}
|
|
autoFocus
|
|
onChange={(e) => setVerifyE2ePassword(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && verifyE2ePassword.length > 0) {
|
|
e.preventDefault();
|
|
void (async () => {
|
|
setIsVerifyingE2e(true);
|
|
try {
|
|
const ok = await invoke<boolean>("verify_e2e_password", {
|
|
password: verifyE2ePassword,
|
|
});
|
|
if (ok) {
|
|
showSuccessToast(
|
|
t("settings.encryption.validateDialog.matchToast"),
|
|
);
|
|
setIsVerifyE2eOpen(false);
|
|
setVerifyE2ePassword("");
|
|
} else {
|
|
showErrorToast(
|
|
t("settings.encryption.validateDialog.mismatchToast"),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsVerifyingE2e(false);
|
|
}
|
|
})();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
disabled={isVerifyingE2e}
|
|
onClick={() => {
|
|
setIsVerifyE2eOpen(false);
|
|
setVerifyE2ePassword("");
|
|
}}
|
|
>
|
|
{t("common.buttons.cancel")}
|
|
</Button>
|
|
<LoadingButton
|
|
isLoading={isVerifyingE2e}
|
|
disabled={verifyE2ePassword.length === 0}
|
|
onClick={async () => {
|
|
setIsVerifyingE2e(true);
|
|
try {
|
|
const ok = await invoke<boolean>("verify_e2e_password", {
|
|
password: verifyE2ePassword,
|
|
});
|
|
if (ok) {
|
|
showSuccessToast(
|
|
t("settings.encryption.validateDialog.matchToast"),
|
|
);
|
|
setIsVerifyE2eOpen(false);
|
|
setVerifyE2ePassword("");
|
|
} else {
|
|
showErrorToast(
|
|
t("settings.encryption.validateDialog.mismatchToast"),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsVerifyingE2e(false);
|
|
}
|
|
}}
|
|
>
|
|
{t("settings.encryption.validateDialog.submit")}
|
|
</LoadingButton>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|