"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 { BsCamera, BsMic } from "react-icons/bs"; import { LoadingButton } from "@/components/loading-button"; import { Badge } from "@/components/ui/badge"; 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 { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; 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; } 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; } export function SettingsDialog({ isOpen, onClose }: 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 [apiServerPort, setApiServerPort] = useState(null); const { setTheme } = useTheme(); const { requestPermission, isMicrophoneAccessGranted, isCameraAccessGranted, } = usePermissions(); 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, }); } } 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(async () => { 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"); // 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, }; const savedSettings = await invoke("save_app_settings", { settings: settingsToSave, }); // 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 ( customThemeState.colors && 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 {} } } else { try { const root = document.documentElement; THEME_VARIABLES.forEach(({ key }) => root.style.removeProperty(key as string), ); } catch {} } // Handle API server start/stop based on settings const wasApiEnabled = originalSettings.api_enabled; const isApiEnabled = settingsToSave.api_enabled; if (isApiEnabled && !wasApiEnabled) { // Start API server try { const port = await invoke("start_api_server", { port: settingsToSave.api_port, }); setApiServerPort(port); showSuccessToast(`Local API started on port ${port}`); } catch (error) { console.error("Failed to start API server:", error); showErrorToast("Failed to start API server", { description: error instanceof Error ? error.message : "Unknown error occurred", }); // Revert the API enabled setting if start failed settingsToSave.api_enabled = false; const revertedSettings = await invoke( "save_app_settings", { settings: settingsToSave }, ); setSettings(revertedSettings); settingsToSave = revertedSettings; } } else if (!isApiEnabled && wasApiEnabled) { // Stop API server try { await invoke("stop_api_server"); setApiServerPort(null); showSuccessToast("Local API stopped"); } catch (error) { console.error("Failed to stop API server:", error); showErrorToast("Failed to stop API server", { description: error instanceof Error ? error.message : "Unknown error occurred", }); } } setOriginalSettings(settingsToSave); onClose(); } catch (error) { console.error("Failed to save settings:", error); } finally { setIsSaving(false); } }, [onClose, setTheme, settings, customThemeState, originalSettings]); const updateSetting = useCallback( ( key: keyof AppSettings, value: boolean | string | Record | undefined, ) => { setSettings((prev) => ({ ...prev, [key]: value as unknown as never })); }, [], ); const loadApiServerStatus = useCallback(async () => { try { const port = await invoke("get_api_server_status"); setApiServerPort(port); } catch (error) { console.error("Failed to load API server status:", error); setApiServerPort(null); } }, []); 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(console.error); checkDefaultBrowserStatus().catch(console.error); loadApiServerStatus().catch(console.error); // Check if we're on macOS const userAgent = navigator.userAgent; const isMac = userAgent.includes("Mac"); setIsMacOS(isMac); if (isMac) { loadPermissions().catch(console.error); } // Set up interval to check default browser status const intervalId = setInterval(() => { checkDefaultBrowserStatus().catch(console.error); }, 500); // Check every 500ms // Cleanup interval on component unmount or dialog close return () => { clearInterval(intervalId); }; } }, [ isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings, loadApiServerStatus, ]); // 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 || (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 ?? {})); return ( Settings
{/* 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 (
); })}
)}
{/* Default Browser Section */}
{isDefaultBrowser ? "Active" : "Inactive"}
{ handleSetDefaultBrowser().catch(console.error); }} 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(console.error); }} > Grant )}
))}
)}

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

)} {/* Local API Section */}
{ updateSetting("api_enabled", checked); try { if (checked) { // Ask backend to enable API and return settings with token const next = await invoke( "save_app_settings", { settings: { ...settings, api_enabled: true }, }, ); setSettings(next); } else { const next = await invoke( "save_app_settings", { settings: { ...settings, api_enabled: false, api_token: null, }, }, ); setSettings(next); } } catch (e) { console.error("Failed to toggle API:", e); } }} />

Allow managing the application data externally via REST API. Server will start on port 10108 or a random port if unavailable. {apiServerPort && ( (Currently running on port {apiServerPort}) )}

{settings.api_enabled && settings.api_token && (
{ navigator.clipboard.writeText(settings.api_token || ""); showSuccessToast("API token copied to clipboard"); }} > Copy

Include this token in the Authorization header as "Bearer{" "} {settings.api_token}" for all API requests.

{/* Temporary in-app API docs */}
Temporary in-app API docs (alpha)
Base URL:{" "} {`http://127.0.0.1:${apiServerPort ?? settings.api_port ?? 10108}/v1`}
Auth:{" "} Authorization: Bearer {settings.api_token}
Profiles
  • GET /profiles — list profiles
  • GET /profiles/{"{"}id{"}"} {" "} — get one
  • POST /profiles — create (required: name, browser, version; optional: release_type, proxy_id, camoufox_config, group_id, tags)
  • PUT /profiles/{"{"}id{"}"} {" "} — update (any of: name, version, proxy_id, camoufox_config, group_id, tags)
  • DELETE /profiles/{"{"}id{"}"} {" "} — delete
  • POST /profiles/{"{"}id{"}"}/run?headless=true|false {" "} — launch with remote debugging
Groups
  • GET /groups — list
  • GET /groups/{"{"}id{"}"} {" "} — get one
  • POST /groups — create (required: name)
  • PUT /groups/{"{"}id{"}"} {" "} — rename (required: name)
  • DELETE /groups/{"{"}id{"}"} {" "} — delete
Tags
  • GET /tags — list
Proxies
  • GET /proxies — list
  • GET /proxies/{"{"}id{"}"} {" "} — get one
  • POST /proxies — create (required: name, proxy_settings object)
  • PUT /proxies/{"{"}id{"}"} {" "} — update (optional: name, proxy_settings)
  • DELETE /proxies/{"{"}id{"}"} {" "} — delete
Browsers
  • POST /browsers/download {" "} — download (required: browser, version)
  • GET /browsers/{"{"}browser{"}"}/versions {" "} — list versions
  • GET /browsers/{"{"}browser{"}"}/versions/{"{"}version {"}"}/downloaded {" "} — is downloaded
These docs are temporary and will be replaced with full documentation later.
)}
{/* Advanced Section */}
{ handleClearCache().catch(console.error); }} 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.

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