"use client"; import { invoke } from "@tauri-apps/api/core"; import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager"; import Color from "color"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsCamera, BsMic } from "react-icons/bs"; import { DnsBlocklistDialog } from "@/components/dns-blocklist-dialog"; import { LoadingButton } from "@/components/loading-button"; import { useTheme } from "@/components/theme-provider"; 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 { 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 { useLanguage } from "@/hooks/use-language"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; import { getThemeByColors, getThemeById, THEME_VARIABLES, THEMES, } from "@/lib/themes"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import { RippleButton } from "./ui/ripple"; interface AppSettings { set_as_default_browser: boolean; theme: string; custom_theme?: Record; api_enabled: boolean; api_port: number; api_token?: string; disable_auto_updates?: boolean; keep_decrypted_profiles_in_ram?: boolean; } interface CustomThemeState { selectedThemeId: string | null; colors: Record; } 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; } export function SettingsDialog({ isOpen, onClose, onIntegrationsOpen, subPage, }: SettingsDialogProps) { const [settings, setSettings] = useState({ set_as_default_browser: false, theme: "system", custom_theme: undefined, api_enabled: false, api_port: 10108, api_token: undefined, }); const [originalSettings, setOriginalSettings] = useState({ set_as_default_browser: false, theme: "system", custom_theme: undefined, api_enabled: false, api_port: 10108, api_token: undefined, }); const [customThemeState, setCustomThemeState] = useState({ selectedThemeId: null, colors: {}, }); const [isDefaultBrowser, setIsDefaultBrowser] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isSettingDefault, setIsSettingDefault] = useState(false); const [isClearingCache, setIsClearingCache] = useState(false); const [permissions, setPermissions] = useState([]); const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); const [requestingPermission, setRequestingPermission] = useState(null); const [isMacOS, setIsMacOS] = useState(false); const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false); const [isLinux, setIsLinux] = 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); 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, } = usePermissions(); const { trialStatus } = useCommercialTrial(); const { user: cloudUser } = useCloudAuth(); // Encryption is available to everyone except team members who aren't owners const canUseEncryption = cloudUser == null || cloudUser.plan !== "team" || cloudUser.teamRole === "owner"; const { currentLanguage, changeLanguage, supportedLanguages, isLoading: isLanguageLoading, } = useLanguage(); const [selectedLanguage, setSelectedLanguage] = useState(null); const [originalLanguage, setOriginalLanguage] = useState(null); const getPermissionIcon = useCallback((type: PermissionType) => { switch (type) { case "microphone": return ; case "camera": return ; } }, []); 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 ( {t("common.status.granted")} ); } return {t("common.status.notGranted")}; }, [t], ); const getPermissionDescription = useCallback( (type: PermissionType) => { switch (type) { case "microphone": return t("settings.permissions.microphoneDescription"); case "camera": return t("settings.permissions.cameraDescription"); } }, [t], ); const loadSettings = useCallback(async () => { setIsLoading(true); try { const appSettings = await invoke("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 ? appSettings.custom_theme : tokyoNightTheme.colors, }; setSettings(merged); setOriginalSettings(merged); // 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, }); } 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("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); } }, []); const applyCustomTheme = useCallback((vars: Record) => { const root = document.documentElement; Object.entries(vars).forEach(([k, v]) => { root.style.setProperty(k, v, "important"); }); }, []); const clearCustomTheme = useCallback(() => { const root = document.documentElement; THEME_VARIABLES.forEach(({ key }) => { root.style.removeProperty(key as string); }); }, []); 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, ]); const checkDefaultBrowserStatus = useCallback(async () => { try { const isDefault = await invoke("is_default_browser"); setIsDefaultBrowser(isDefault); } catch (error) { console.error("Failed to check default browser status:", error); } }, []); const handleSetDefaultBrowser = useCallback(async () => { setIsSettingDefault(true); try { await invoke("set_as_default_browser"); await checkDefaultBrowserStatus(); } catch (error) { console.error("Failed to set as default browser:", error); } finally { setIsSettingDefault(false); } }, [checkDefaultBrowserStatus]); 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 { await requestPermission(permissionType); showSuccessToast( t("settings.permissions.accessRequested", { permission: getPermissionDisplayName(permissionType), }), ); } catch (error) { console.error("Failed to request permission:", error); } finally { setRequestingPermission(null); } }, [getPermissionDisplayName, requestPermission, t], ); const handleSave = useCallback(async () => { setIsSaving(true); 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("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; // 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 { const root = document.documentElement; // Clear any previous custom vars first THEME_VARIABLES.forEach(({ key }) => { root.style.removeProperty(key as string); }); Object.entries(customThemeState.colors).forEach(([k, v]) => { root.style.setProperty(k, v, "important"); }); } catch { /* empty */ } } } else { try { const root = document.documentElement; THEME_VARIABLES.forEach(({ key }) => { root.style.removeProperty(key as string); }); } catch { /* empty */ } } // Save language if changed if (selectedLanguage !== originalLanguage) { await changeLanguage( selectedLanguage === "system" ? null : (selectedLanguage as | "en" | "es" | "pt" | "fr" | "zh" | "ja" | "ko" | "ru"), ); setOriginalLanguage(selectedLanguage); } setOriginalSettings(settingsToSave); onClose(); } catch (error) { console.error("Failed to save settings:", error); } finally { setIsSaving(false); } }, [ onClose, setTheme, settings, customThemeState, selectedLanguage, originalLanguage, changeLanguage, ]); const updateSetting = useCallback( ( key: keyof AppSettings, value: boolean | string | Record | undefined, ) => { setSettings((prev) => ({ ...prev, [key]: value as unknown as never })); }, [], ); const handleClose = useCallback(() => { // Restore original theme when closing without saving if (originalSettings.theme === "custom" && originalSettings.custom_theme) { applyCustomTheme(originalSettings.custom_theme); } else { clearCustomTheme(); } // Reset custom theme state to original if (originalSettings.theme === "custom" && originalSettings.custom_theme) { const matchingTheme = getThemeByColors(originalSettings.custom_theme); setCustomThemeState({ selectedThemeId: matchingTheme?.id ?? null, colors: originalSettings.custom_theme, }); } onClose(); }, [ originalSettings.theme, originalSettings.custom_theme, applyCustomTheme, clearCustomTheme, onClose, ]); // Only clear custom theme when switching away from custom, don't apply live changes useEffect(() => { if (settings.theme !== "custom") { clearCustomTheme(); } }, [settings.theme, clearCustomTheme]); useEffect(() => { if (isOpen) { loadSettings().catch((err: unknown) => { console.error(err); }); checkDefaultBrowserStatus().catch((err: unknown) => { console.error(err); }); // Check if we're on macOS const userAgent = navigator.userAgent; const isMac = userAgent.includes("Mac"); setIsMacOS(isMac); const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win"); setIsLinux(isLin); if (isMac) { loadPermissions(); } // Set up interval to check default browser status const intervalId = setInterval(() => { checkDefaultBrowserStatus().catch((err: unknown) => { console.error(err); }); }, 2000); // Cleanup interval on component unmount or dialog close return () => { clearInterval(intervalId); }; } }, [isOpen, 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, ]); // Check if settings have changed (excluding default browser setting) const hasChanges = settings.theme !== originalSettings.theme || settings.api_enabled !== originalSettings.api_enabled || selectedLanguage !== originalLanguage || (settings.theme === "custom" && JSON.stringify(customThemeState.colors) !== JSON.stringify(originalSettings.custom_theme ?? {})) || (settings.theme !== "custom" && JSON.stringify(settings.custom_theme ?? {}) !== JSON.stringify(originalSettings.custom_theme ?? {})) || settings.disable_auto_updates !== originalSettings.disable_auto_updates; return ( <> {!subPage && ( {t("settings.title")} )}
{/* Appearance Section */}

{t("settings.appearance.themeDescription")}

{settings.theme === "custom" && (
{t("settings.appearance.customColors")}
{THEME_VARIABLES.map(({ key, label }) => { const colorValue = customThemeState.colors[key] ?? "#000000"; return (
); })}
)}
{/* Language Section */}

{t("settings.language.description")}

{/* Default Browser Section - hidden in portable mode */} {!systemInfo?.portable && (
{isDefaultBrowser ? t("common.status.active") : t("common.status.inactive")}
{ 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")}

{t("settings.defaultBrowser.description")}

)} {/* Permissions Section - Only show on macOS */} {isMacOS && (
{isLoadingPermissions ? (
{t("settings.permissions.loading")}
) : (
{permissions.map((permission) => (
{getPermissionIcon(permission.permission_type)}
{getPermissionDisplayName( permission.permission_type, )}
{permission.description}
{getStatusBadge(permission.isGranted)} {!permission.isGranted && ( { handleRequestPermission( permission.permission_type, ).catch((err: unknown) => { console.error(err); }); }} > Grant )}
))}
)}

These permissions allow browsers launched from Donut Browser to access system resources. Each website will still ask for your permission individually.

)} {/* Integrations Section */}

{t("settings.integrations.description")}

{t("integrations.openSettings")}
{/* DNS Blocklist Section */}

{t("dnsBlocklist.settingsDescription")}

setDnsBlocklistDialogOpen(true)} > {t("dnsBlocklist.manageLists")}
{/* Sync Encryption Section */}

{t("settings.encryption.description")}

{!canUseEncryption ? (

{t("settings.encryption.requiresProOrOwner")}

) : hasE2ePassword ? (
{t("settings.encryption.passwordSet")} {t("settings.encryption.passwordSetDescription")}
{ 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 — // the source of issue #360 "completely bugged". 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")}
) : (
{ setE2ePassword(e.target.value); setE2eError(""); }} /> { setE2ePasswordConfirm(e.target.value); setE2eError(""); }} /> {e2eError && (

{e2eError}

)} { 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")}
)}
{/* Commercial License Section */}
{cloudUser != null && cloudUser.plan !== "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.

{t("settings.commercial.subscriptionActive", { plan: cloudUser.plan, })}

{t("settings.commercial.subscriptionActiveDescription")}

) : trialStatus?.type === "Active" ? (

{t("settings.commercial.trialActive", { days: trialStatus.days_remaining, hours: trialStatus.hours_remaining, })}

{t("settings.commercial.trialActiveDescription")}

) : (

{t("settings.commercial.trialExpired")}

{t("settings.commercial.trialExpiredDescription")}

)}
{/* Advanced Section */}
{!isLinux && (
{ updateSetting("disable_auto_updates", checked as boolean); }} />

{t("settings.disableAutoUpdatesDescription")}

)}
{ updateSetting( "keep_decrypted_profiles_in_ram", checked as boolean, ); }} />

{t("settings.keepDecryptedProfilesInRamDescription")}

{ handleClearCache().catch((err: unknown) => { console.error(err); }); }} variant="outline" className="w-full" > {t("settings.advanced.clearCache")}

{t("settings.advanced.clearCacheDescription")}

{ try { const content = await invoke("read_log_files"); await writeClipboardText(content); showSuccessToast(t("settings.advanced.copyLogsSuccess")); } catch (err) { showErrorToast(String(err)); } }} > {t("settings.advanced.copyLogs")} { try { await invoke("open_log_directory"); } catch (err) { showErrorToast(String(err)); } }} > {t("settings.advanced.openLogDir")}

{t("settings.advanced.copyLogsDescription")}

{/* System Info */} {systemInfo && (

{`Donut Browser ${systemInfo.app_version}\n${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}`}

)}
{subPage ? (
{ handleSave().catch((err: unknown) => { console.error(err); }); }} disabled={isLoading || !hasChanges} > {t("common.buttons.saveSettings")}
) : ( {t("common.buttons.cancel")} { handleSave().catch((err: unknown) => { console.error(err); }); }} disabled={isLoading || !hasChanges} > {t("common.buttons.saveSettings")} )}
setDnsBlocklistDialogOpen(false)} /> { if (!isVerifyingE2e) { setIsVerifyE2eOpen(open); if (!open) setVerifyE2ePassword(""); } }} > {t("settings.encryption.validateDialog.title")} {t("settings.encryption.validateDialog.description")}
setVerifyE2ePassword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && verifyE2ePassword.length > 0) { e.preventDefault(); void (async () => { setIsVerifyingE2e(true); try { const ok = await invoke("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); } })(); } }} />
{ setIsVerifyingE2e(true); try { const ok = await invoke("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")}
); }