"use client"; import { invoke } from "@tauri-apps/api/core"; import Color from "color"; import { useTheme } from "next-themes"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { BsCamera, BsMic } from "react-icons/bs"; import { LoadingButton } from "@/components/loading-button"; 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, 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 { 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; } 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; } export function SettingsDialog({ isOpen, onClose, onIntegrationsOpen, }: 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 [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 [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 "Microphone"; case "camera": return "Camera"; } }, []); const getStatusBadge = useCallback((isGranted: boolean) => { if (isGranted) { return ( Granted ); } return Not Granted; }, []); const getPermissionDescription = useCallback((type: PermissionType) => { switch (type) { case "microphone": return "Access to microphone for browser applications"; case "camera": return "Access to camera for browser applications"; } }, []); 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("Failed to clear cache", { description: error instanceof Error ? error.message : "Unknown error occurred", duration: 4000, }); } finally { setIsClearingCache(false); } }, []); const handleRequestPermission = useCallback( async (permissionType: PermissionType) => { setRequestingPermission(permissionType); try { await requestPermission(permissionType); showSuccessToast( `${getPermissionDisplayName(permissionType)} access requested`, ); } catch (error) { console.error("Failed to request permission:", error); } finally { setRequestingPermission(null); } }, [getPermissionDisplayName, requestPermission], ); 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; setTheme(settings.theme === "custom" ? "dark" : 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" | "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); }); }, 500); // Check every 500ms // 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 ( {t("settings.title")}
{/* Appearance Section */}

Choose your preferred theme or follow your system settings. Custom theme changes are applied only when you save.

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

Choose your preferred language for the application interface.

{/* Default Browser Section - hidden in portable mode */} {!systemInfo?.portable && (
{isDefaultBrowser ? "Active" : "Inactive"}
{ handleSetDefaultBrowser().catch((err: unknown) => { console.error(err); }); }} disabled={isDefaultBrowser} variant={isDefaultBrowser ? "outline" : "default"} className="w-full" > {isDefaultBrowser ? "Already Default Browser" : "Set as Default Browser"}

When set as default, Donut Browser will handle web links and allow you to choose which profile to use.

)} {/* Permissions Section - Only show on macOS */} {isMacOS && (
{isLoadingPermissions ? (
Loading permissions...
) : (
{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 */}

Configure Local API and MCP (Model Context Protocol) for integrating with external tools and AI assistants.

Open Integrations Settings
{/* Sync Encryption Section */}

{t( "settings.encryption.description", "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.", )}

{!canUseEncryption ? (

{t( "settings.encryption.requiresProOrOwner", "Profile encryption is available for Pro users and team owners.", )}

) : hasE2ePassword ? (
{t("settings.encryption.passwordSet", "Active")} {t( "settings.encryption.passwordSetDescription", "E2E encryption password is set", )}
) : (
{ setE2ePassword(e.target.value); setE2eError(""); }} /> { setE2ePasswordConfirm(e.target.value); setE2eError(""); }} /> {e2eError && (

{e2eError}

)} { if (e2ePassword.length < 8) { setE2eError( t( "settings.encryption.passwordTooShort", "Password must be at least 8 characters", ), ); return; } if (e2ePassword !== e2ePasswordConfirm) { setE2eError( t( "settings.encryption.passwordMismatch", "Passwords do not match", ), ); return; } setIsSavingE2e(true); try { await invoke("set_e2e_password", { password: e2ePassword, }); setHasE2ePassword(true); setE2ePassword(""); setE2ePasswordConfirm(""); showSuccessToast( t( "settings.encryption.passwordSaved", "Encryption password set", ), ); } catch (error) { showErrorToast(String(error)); } finally { setIsSavingE2e(false); } }} > {t("settings.encryption.setPassword", "Set Password")}
)}
{/* Commercial License Section */}
{trialStatus?.type === "Active" ? (

Trial: {trialStatus.days_remaining} days,{" "} {trialStatus.hours_remaining} hours remaining

Commercial use is free during the trial period

) : (

Trial expired

Personal use remains free. Commercial use requires a license.

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

{t("settings.disableAutoUpdatesDescription")}

)} { handleClearCache().catch((err: unknown) => { console.error(err); }); }} variant="outline" className="w-full" > Clear All Version Cache

Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers.

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

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

)}
Cancel { handleSave().catch((err: unknown) => { console.error(err); }); }} disabled={isLoading || !hasChanges} > Save Settings
); }