From 45d59be20100c86798664611bbc70dadf4ec9ecc Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Sat, 21 Feb 2026 14:26:03 +0000 Subject: [PATCH] Client settings (#7381) Co-authored-by: Douwe Osinga --- .../src/components/AnnouncementModal.tsx | 12 +-- .../src/components/ToolCallWithResponse.tsx | 16 ++-- .../components/bottom_menu/CostTracker.tsx | 17 ++-- .../sessions/SessionHistoryView.tsx | 21 ++--- .../settings/app/AppSettingsSection.tsx | 11 ++- .../settings/app/ExternalBackendSection.tsx | 29 ++----- .../keyboard/KeyboardShortcutsSection.tsx | 44 ++++------ .../response_styles/ResponseStylesSection.tsx | 17 ++-- .../sessions/SessionSharingSection.tsx | 34 +++----- ui/desktop/src/contexts/ThemeContext.tsx | 80 ++++++++++++------- ui/desktop/src/main.ts | 78 ++++++++++++------ ui/desktop/src/preload.ts | 73 +++++++++++++++-- ui/desktop/src/renderer.tsx | 1 - ui/desktop/src/sessionLinks.ts | 26 ++---- ui/desktop/src/test/setup.ts | 64 +++++++++------ ui/desktop/src/utils/settings.ts | 45 ++++++++++- 16 files changed, 335 insertions(+), 233 deletions(-) diff --git a/ui/desktop/src/components/AnnouncementModal.tsx b/ui/desktop/src/components/AnnouncementModal.tsx index 33f38122fc..197a60c8ac 100644 --- a/ui/desktop/src/components/AnnouncementModal.tsx +++ b/ui/desktop/src/components/AnnouncementModal.tsx @@ -63,9 +63,7 @@ export default function AnnouncementModal() { }); // Get list of seen announcement IDs - const seenAnnouncementIds = JSON.parse( - localStorage.getItem('seenAnnouncementIds') || '[]' - ) as string[]; + const seenAnnouncementIds = await window.electron.getSetting('seenAnnouncementIds'); // Find ALL unseen announcements (in order) const unseenAnnouncementsList = applicableAnnouncements.filter( @@ -101,13 +99,11 @@ export default function AnnouncementModal() { loadAnnouncements(); }, []); - const handleCloseAnnouncement = () => { + const handleCloseAnnouncement = async () => { if (unseenAnnouncements.length === 0) return; // Get existing seen announcement IDs - const seenAnnouncementIds = JSON.parse( - localStorage.getItem('seenAnnouncementIds') || '[]' - ) as string[]; + const seenAnnouncementIds = await window.electron.getSetting('seenAnnouncementIds'); // Add all unseen announcement IDs to the seen list const newSeenIds = [...seenAnnouncementIds]; @@ -117,7 +113,7 @@ export default function AnnouncementModal() { } }); - localStorage.setItem('seenAnnouncementIds', JSON.stringify(newSeenIds)); + await window.electron.setSetting('seenAnnouncementIds', newSeenIds); setShowAnnouncementModal(false); }; diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 8e8ddc8042..9b5e13f56e 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -428,20 +428,20 @@ function ToolCallView({ notifications, isStreamingMessage = false, }: ToolCallViewProps) { - const [responseStyle, setResponseStyle] = useState(() => localStorage.getItem('response_style')); + const [responseStyle, setResponseStyle] = useState('concise'); useEffect(() => { - const handleStorageChange = () => { - setResponseStyle(localStorage.getItem('response_style')); + // Load initial value from settings + window.electron.getSetting('responseStyle').then(setResponseStyle); + + const handleStyleChange = () => { + window.electron.getSetting('responseStyle').then(setResponseStyle); }; - window.addEventListener('storage', handleStorageChange); - - window.addEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStorageChange); + window.addEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStyleChange); return () => { - window.removeEventListener('storage', handleStorageChange); - window.removeEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStorageChange); + window.removeEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStyleChange); }; }, []); diff --git a/ui/desktop/src/components/bottom_menu/CostTracker.tsx b/ui/desktop/src/components/bottom_menu/CostTracker.tsx index f8f17047a2..1f1cfd16e4 100644 --- a/ui/desktop/src/components/bottom_menu/CostTracker.tsx +++ b/ui/desktop/src/components/bottom_menu/CostTracker.tsx @@ -26,14 +26,19 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: // Check if pricing is enabled useEffect(() => { - const checkPricingSetting = () => { - const stored = localStorage.getItem('show_pricing'); - setShowPricing(stored !== 'false'); + const loadPricingSetting = async () => { + const enabled = await window.electron.getSetting('showPricing'); + setShowPricing(enabled); }; - checkPricingSetting(); - window.addEventListener('storage', checkPricingSetting); - return () => window.removeEventListener('storage', checkPricingSetting); + loadPricingSetting(); + + const handlePricingChange = () => { + loadPricingSetting(); + }; + + window.addEventListener('showPricingChanged', handlePricingChange); + return () => window.removeEventListener('showPricingChanged', handlePricingChange); }, []); useEffect(() => { diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 74ea116ad8..bd98c1ab43 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -151,29 +151,18 @@ const SessionHistoryView: React.FC = ({ const setView = useNavigation(); useEffect(() => { - const savedSessionConfig = localStorage.getItem('session_sharing_config'); - if (savedSessionConfig) { - try { - const config = JSON.parse(savedSessionConfig); - if (config.enabled && config.baseUrl) { - setCanShare(true); - } - } catch (error) { - console.error('Error parsing session sharing config:', error); + window.electron.getSetting('sessionSharing').then((config) => { + if (config.enabled && config.baseUrl) { + setCanShare(true); } - } + }); }, []); const handleShare = async () => { setIsSharing(true); try { - const savedSessionConfig = localStorage.getItem('session_sharing_config'); - if (!savedSessionConfig) { - throw new Error('Session sharing is not configured. Please configure it in settings.'); - } - - const config = JSON.parse(savedSessionConfig); + const config = await window.electron.getSetting('sessionSharing'); if (!config.enabled || !config.baseUrl) { throw new Error('Session sharing is not enabled or base URL is not configured.'); } diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 5655346010..ac59aa93e2 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -58,8 +58,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti // Load show pricing setting useEffect(() => { - const stored = localStorage.getItem('show_pricing'); - setShowPricing(stored !== 'false'); + window.electron.getSetting('showPricing').then(setShowPricing); }, []); // Handle scrolling to update section @@ -140,12 +139,12 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti } }; - const handleShowPricingToggle = (checked: boolean) => { + const handleShowPricingToggle = async (checked: boolean) => { setShowPricing(checked); - localStorage.setItem('show_pricing', String(checked)); + await window.electron.setSetting('showPricing', checked); trackSettingToggled('cost_tracking', checked); - // Trigger storage event for other components - window.dispatchEvent(new CustomEvent('storage')); + // Trigger event for other components + window.dispatchEvent(new CustomEvent('showPricingChanged')); }; return ( diff --git a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx index 2e485cadbc..184537a9f8 100644 --- a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx +++ b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx @@ -3,33 +3,18 @@ import { Switch } from '../../ui/switch'; import { Input } from '../../ui/input'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { AlertCircle } from 'lucide-react'; -import { ExternalGoosedConfig } from '../../../utils/settings'; +import { ExternalGoosedConfig, defaultSettings } from '../../../utils/settings'; import { WEB_PROTOCOLS } from '../../../utils/urlSecurity'; -const DEFAULT_CONFIG: ExternalGoosedConfig = { - enabled: false, - url: '', - secret: '', -}; - -function parseConfig(config: ExternalGoosedConfig | undefined): ExternalGoosedConfig { - if (!config) return DEFAULT_CONFIG; - return { - enabled: config.enabled ?? DEFAULT_CONFIG.enabled, - url: config.url ?? DEFAULT_CONFIG.url, - secret: config.secret ?? DEFAULT_CONFIG.secret, - }; -} - export default function ExternalBackendSection() { - const [config, setConfig] = useState(DEFAULT_CONFIG); + const [config, setConfig] = useState(defaultSettings.externalGoosed); const [isSaving, setIsSaving] = useState(false); const [urlError, setUrlError] = useState(null); useEffect(() => { const loadSettings = async () => { - const settings = await window.electron.getSettings(); - setConfig(parseConfig(settings.externalGoosed)); + const externalGoosed = await window.electron.getSetting('externalGoosed'); + setConfig(externalGoosed); }; loadSettings(); }, []); @@ -56,11 +41,7 @@ export default function ExternalBackendSection() { const saveConfig = async (newConfig: ExternalGoosedConfig): Promise => { setIsSaving(true); try { - const currentSettings = await window.electron.getSettings(); - await window.electron.saveSettings({ - ...currentSettings, - externalGoosed: newConfig, - }); + await window.electron.setSetting('externalGoosed', newConfig); } catch (error) { console.error('Failed to save external backend settings:', error); } finally { diff --git a/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx b/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx index 38f5c32819..61d7a72da0 100644 --- a/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx +++ b/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx @@ -122,8 +122,8 @@ export default function KeyboardShortcutsSection() { const [showRestartNotice, setShowRestartNotice] = useState(false); const loadShortcuts = useCallback(async () => { - const settings = await window.electron.getSettings(); - setShortcuts(settings.keyboardShortcuts || defaultKeyboardShortcuts); + const keyboardShortcuts = await window.electron.getSetting('keyboardShortcuts'); + setShortcuts(keyboardShortcuts || defaultKeyboardShortcuts); }, []); useEffect(() => { @@ -163,15 +163,11 @@ export default function KeyboardShortcutsSection() { newShortcuts[key] = null; } - const settings = await window.electron.getSettings(); - settings.keyboardShortcuts = newShortcuts; - const success = await window.electron.saveSettings(settings); - if (success) { - setShortcuts(newShortcuts); - trackSettingToggled(`shortcut_${key}`, enabled); - if (needsRestart.has(key)) { - setShowRestartNotice(true); - } + await window.electron.setSetting('keyboardShortcuts', newShortcuts); + setShortcuts(newShortcuts); + trackSettingToggled(`shortcut_${key}`, enabled); + if (needsRestart.has(key)) { + setShowRestartNotice(true); } }; @@ -209,15 +205,11 @@ export default function KeyboardShortcutsSection() { newShortcuts[editingKey] = shortcut || null; - const settings = await window.electron.getSettings(); - settings.keyboardShortcuts = newShortcuts; - const success = await window.electron.saveSettings(settings); - if (success) { - setShortcuts(newShortcuts); - setEditingKey(null); - if (needsRestart.has(editingKey)) { - setShowRestartNotice(true); - } + await window.electron.setSetting('keyboardShortcuts', newShortcuts); + setShortcuts(newShortcuts); + setEditingKey(null); + if (needsRestart.has(editingKey)) { + setShowRestartNotice(true); } }; @@ -236,14 +228,10 @@ export default function KeyboardShortcutsSection() { }); if (confirmed.response === 0) { - const settings = await window.electron.getSettings(); - settings.keyboardShortcuts = { ...defaultKeyboardShortcuts }; - const success = await window.electron.saveSettings(settings); - if (success) { - setShortcuts({ ...defaultKeyboardShortcuts }); - setShowRestartNotice(true); - trackSettingToggled('shortcuts_reset', true); - } + await window.electron.setSetting('keyboardShortcuts', { ...defaultKeyboardShortcuts }); + setShortcuts({ ...defaultKeyboardShortcuts }); + setShowRestartNotice(true); + trackSettingToggled('shortcuts_reset', true); } }; diff --git a/ui/desktop/src/components/settings/response_styles/ResponseStylesSection.tsx b/ui/desktop/src/components/settings/response_styles/ResponseStylesSection.tsx index 0ba8754c1d..3bd033ecad 100644 --- a/ui/desktop/src/components/settings/response_styles/ResponseStylesSection.tsx +++ b/ui/desktop/src/components/settings/response_styles/ResponseStylesSection.tsx @@ -6,23 +6,24 @@ export const ResponseStylesSection = () => { const [currentStyle, setCurrentStyle] = useState('concise'); useEffect(() => { - const savedStyle = localStorage.getItem('response_style'); - if (savedStyle) { + async function loadResponseStyle() { try { + const savedStyle = await window.electron.getSetting('responseStyle'); setCurrentStyle(savedStyle); } catch (error) { - console.error('Error parsing response style:', error); + console.error('Error loading response style:', error); } - } else { - // Set default to concise for new users - localStorage.setItem('response_style', 'concise'); - setCurrentStyle('concise'); } + loadResponseStyle(); }, []); const handleStyleChange = async (newStyle: string) => { setCurrentStyle(newStyle); - localStorage.setItem('response_style', newStyle); + try { + await window.electron.setSetting('responseStyle', newStyle); + } catch (error) { + console.error('Error saving response style:', error); + } // Dispatch custom event to notify other components of the change window.dispatchEvent(new CustomEvent(AppEvents.RESPONSE_STYLE_CHANGED)); diff --git a/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx index 7cc5da1e37..432bc49ac6 100644 --- a/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx +++ b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx @@ -27,25 +27,19 @@ export default function SessionSharingSection() { sessionSharingConfig.enabled && isValidUrl(String(sessionSharingConfig.baseUrl)); - // Only load saved config from localStorage if the env variable is not provided. + // Only load saved config from settings if the env variable is not provided. useEffect(() => { if (envBaseUrlShare) { - // If env variable is set, save the forced configuration to localStorage + // If env variable is set, save the forced configuration to settings const forcedConfig = { enabled: true, baseUrl: typeof envBaseUrlShare === 'string' ? envBaseUrlShare : '', }; - localStorage.setItem('session_sharing_config', JSON.stringify(forcedConfig)); + window.electron.setSetting('sessionSharing', forcedConfig); } else { - const savedSessionConfig = localStorage.getItem('session_sharing_config'); - if (savedSessionConfig) { - try { - const config = JSON.parse(savedSessionConfig); - setSessionSharingConfig(config); - } catch (error) { - console.error('Error parsing session sharing config:', error); - } - } + window.electron.getSetting('sessionSharing').then((config) => { + setSessionSharingConfig(config); + }); } }, [envBaseUrlShare]); @@ -61,20 +55,18 @@ export default function SessionSharingSection() { } // Toggle sharing (only allowed when env is not set). - const toggleSharing = () => { + const toggleSharing = async () => { if (envBaseUrlShare) { return; // Do nothing if the environment variable forces sharing. } - setSessionSharingConfig((prev) => { - const updated = { ...prev, enabled: !prev.enabled }; - localStorage.setItem('session_sharing_config', JSON.stringify(updated)); - trackSettingToggled('session_sharing', updated.enabled); - return updated; - }); + const updated = { ...sessionSharingConfig, enabled: !sessionSharingConfig.enabled }; + setSessionSharingConfig(updated); + await window.electron.setSetting('sessionSharing', updated); + trackSettingToggled('session_sharing', updated.enabled); }; // Handle changes to the base URL field - const handleBaseUrlChange = (e: React.ChangeEvent) => { + const handleBaseUrlChange = async (e: React.ChangeEvent) => { const newBaseUrl = e.target.value; setSessionSharingConfig((prev) => ({ ...prev, @@ -87,7 +79,7 @@ export default function SessionSharingSection() { if (isValidUrl(newBaseUrl)) { setUrlError(''); const updated = { ...sessionSharingConfig, baseUrl: newBaseUrl }; - localStorage.setItem('session_sharing_config', JSON.stringify(updated)); + await window.electron.setSetting('sessionSharing', updated); } else { setUrlError('Invalid URL format. Please enter a valid URL (e.g. https://example.com/api).'); } diff --git a/ui/desktop/src/contexts/ThemeContext.tsx b/ui/desktop/src/contexts/ThemeContext.tsx index 982545e4dd..6c807c9b6a 100644 --- a/ui/desktop/src/contexts/ThemeContext.tsx +++ b/ui/desktop/src/contexts/ThemeContext.tsx @@ -22,29 +22,6 @@ function resolveTheme(preference: ThemePreference): ResolvedTheme { return preference; } -function loadThemePreference(): ThemePreference { - const useSystemTheme = localStorage.getItem('use_system_theme'); - if (useSystemTheme === 'true') { - return 'system'; - } - - const savedTheme = localStorage.getItem('theme'); - if (savedTheme === 'dark') { - return 'dark'; - } - - return 'light'; -} - -function saveThemePreference(preference: ThemePreference): void { - if (preference === 'system') { - localStorage.setItem('use_system_theme', 'true'); - } else { - localStorage.setItem('use_system_theme', 'false'); - localStorage.setItem('theme', preference); - } -} - function applyThemeToDocument(theme: ResolvedTheme): void { const toRemove = theme === 'dark' ? 'light' : 'dark'; document.documentElement.classList.add(theme); @@ -56,19 +33,53 @@ interface ThemeProviderProps { } export function ThemeProvider({ children }: ThemeProviderProps) { - const [userThemePreference, setUserThemePreferenceState] = - useState(loadThemePreference); - const [resolvedTheme, setResolvedTheme] = useState(() => - resolveTheme(loadThemePreference()) - ); + // Start with light theme to avoid flash, will update once settings load + const [userThemePreference, setUserThemePreferenceState] = useState('light'); + const [resolvedTheme, setResolvedTheme] = useState('light'); - const setUserThemePreference = useCallback((preference: ThemePreference) => { + useEffect(() => { + async function loadThemeFromSettings() { + try { + const [useSystemTheme, savedTheme] = await Promise.all([ + window.electron.getSetting('useSystemTheme'), + window.electron.getSetting('theme'), + ]); + + let preference: ThemePreference; + if (useSystemTheme) { + preference = 'system'; + } else { + preference = savedTheme; + } + + setUserThemePreferenceState(preference); + setResolvedTheme(resolveTheme(preference)); + } catch (error) { + console.warn('[ThemeContext] Failed to load theme settings:', error); + } + } + + loadThemeFromSettings(); + }, []); + + const setUserThemePreference = useCallback(async (preference: ThemePreference) => { setUserThemePreferenceState(preference); - saveThemePreference(preference); const resolved = resolveTheme(preference); setResolvedTheme(resolved); + // Save to settings + try { + if (preference === 'system') { + await window.electron.setSetting('useSystemTheme', true); + } else { + await window.electron.setSetting('useSystemTheme', false); + await window.electron.setSetting('theme', preference); + } + } catch (error) { + console.warn('[ThemeContext] Failed to save theme settings:', error); + } + // Broadcast to other windows via Electron window.electron?.broadcastThemeChange({ mode: resolved, @@ -104,8 +115,15 @@ export function ThemeProvider({ children }: ThemeProviderProps) { : 'light'; setUserThemePreferenceState(newPreference); - saveThemePreference(newPreference); setResolvedTheme(resolveTheme(newPreference)); + + // Save to settings (don't await, fire and forget) + if (newPreference === 'system') { + window.electron.setSetting('useSystemTheme', true); + } else { + window.electron.setSetting('useSystemTheme', false); + window.electron.setSetting('theme', newPreference); + } }; window.electron.on('theme-changed', handleThemeChanged); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index bd1086fb26..b17dff7a6c 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -30,8 +30,8 @@ import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; import { formatAppName, errorMessage, formatErrorForLogging } from './utils/conversionUtils'; -import type { Settings } from './utils/settings'; -import { defaultKeyboardShortcuts, getKeyboardShortcuts } from './utils/settings'; +import type { Settings, SettingKey } from './utils/settings'; +import { defaultSettings, getKeyboardShortcuts } from './utils/settings'; import * as crypto from 'crypto'; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; @@ -57,18 +57,27 @@ function shouldSetupUpdater(): boolean { // Settings management const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); -const defaultSettings: Settings = { - showMenuBarIcon: true, - showDockIcon: true, - enableWakelock: false, - spellcheckEnabled: true, - keyboardShortcuts: defaultKeyboardShortcuts, -}; - function getSettings(): Settings { if (fsSync.existsSync(SETTINGS_FILE)) { const data = fsSync.readFileSync(SETTINGS_FILE, 'utf8'); - return JSON.parse(data); + const stored = JSON.parse(data) as Partial; + // Deep merge to ensure nested objects get their defaults too + return { + ...defaultSettings, + ...stored, + externalGoosed: { + ...defaultSettings.externalGoosed, + ...(stored.externalGoosed ?? {}), + }, + keyboardShortcuts: { + ...defaultSettings.keyboardShortcuts, + ...(stored.keyboardShortcuts ?? {}), + }, + sessionSharing: { + ...defaultSettings.sessionSharing, + ...(stored.sessionSharing ?? {}), + }, + }; } return defaultSettings; } @@ -1192,25 +1201,44 @@ ipcMain.handle('add-recent-dir', (_event, dir: string) => { } }); -// Handle scheduling engine settings -ipcMain.handle('get-settings', () => { - return getSettings(); // Always returns Settings (uses defaults as fallback) +ipcMain.handle('get-setting', (_event, key: SettingKey) => { + const settings = getSettings(); + return settings[key]; }); -ipcMain.handle('save-settings', (_event, settings) => { - const oldSettings = getSettings(); +// Valid setting keys for runtime validation +const validSettingKeys: Set = new Set([ + 'showMenuBarIcon', + 'showDockIcon', + 'enableWakelock', + 'spellcheckEnabled', + 'externalGoosed', + 'globalShortcut', + 'keyboardShortcuts', + 'theme', + 'useSystemTheme', + 'responseStyle', + 'showPricing', + 'sessionSharing', + 'seenAnnouncementIds', +]); - const oldShortcuts = getKeyboardShortcuts(oldSettings); - const newShortcuts = getKeyboardShortcuts(settings); - const shortcutsChanged = JSON.stringify(oldShortcuts) !== JSON.stringify(newShortcuts); - - fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); - - if (shortcutsChanged) { - registerGlobalShortcuts(); +ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => { + // Validate key at runtime to prevent prototype pollution + if (!validSettingKeys.has(key)) { + console.error(`Invalid setting key rejected: ${key}`); + return; } - return true; + const settings = getSettings(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (settings as any)[key] = value; + fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); + + // Re-register shortcuts if keyboard shortcuts changed + if (key === 'keyboardShortcuts') { + registerGlobalShortcuts(); + } }); ipcMain.handle('get-secret-key', () => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 185b7f6723..95a9dbf9df 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,7 +1,45 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; import { Recipe } from './recipe'; import { GooseApp } from './api'; -import type { Settings } from './utils/settings'; +import type { Settings, SettingKey } from './utils/settings'; +import { defaultSettings } from './utils/settings'; + +// Mapping from settings keys to their old localStorage keys for lazy migration +const localStorageKeyMap: Partial> = { + theme: 'theme', + useSystemTheme: 'use_system_theme', + responseStyle: 'response_style', + showPricing: 'show_pricing', + sessionSharing: 'session_sharing_config', + seenAnnouncementIds: 'seenAnnouncementIds', +}; + +// Parse localStorage value based on the setting key +function parseLocalStorageValue( + key: K, + rawValue: string +): Settings[K] | null { + try { + switch (key) { + case 'theme': + return (rawValue === 'dark' || rawValue === 'light' ? rawValue : null) as Settings[K]; + case 'useSystemTheme': + return (rawValue === 'true') as unknown as Settings[K]; + case 'responseStyle': + return rawValue as Settings[K]; + case 'showPricing': + return (rawValue === 'true') as unknown as Settings[K]; + case 'sessionSharing': + return JSON.parse(rawValue) as Settings[K]; + case 'seenAnnouncementIds': + return JSON.parse(rawValue) as Settings[K]; + default: + return null; + } + } catch { + return null; + } +} interface NotificationData { title: string; @@ -86,8 +124,8 @@ type ElectronAPI = { getMenuBarIconState: () => Promise; setDockIcon: (show: boolean) => Promise; getDockIconState: () => Promise; - getSettings: () => Promise; - saveSettings: (settings: Settings) => Promise; + getSetting: (key: K) => Promise; + setSetting: (key: K, value: Settings[K]) => Promise; getSecretKey: () => Promise; getGoosedHostPort: () => Promise; setWakelock: (enable: boolean) => Promise; @@ -190,8 +228,33 @@ const electronAPI: ElectronAPI = { getMenuBarIconState: () => ipcRenderer.invoke('get-menu-bar-icon-state'), setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show), getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'), - getSettings: () => ipcRenderer.invoke('get-settings'), - saveSettings: (settings: unknown) => ipcRenderer.invoke('save-settings', settings), + getSetting: async (key: K): Promise => { + try { + // Check for localStorage value first (lazy migration) + const localStorageKey = localStorageKeyMap[key]; + if (localStorageKey) { + const rawValue = localStorage.getItem(localStorageKey); + if (rawValue !== null) { + const parsed = parseLocalStorageValue(key, rawValue); + if (parsed !== null) { + return parsed; + } + } + } + return await ipcRenderer.invoke('get-setting', key); + } catch (error) { + console.error(`Failed to get setting '${key}', using default`, error); + return defaultSettings[key]; + } + }, + setSetting: async (key: K, value: Settings[K]): Promise => { + // Clear any localStorage version when writing + const localStorageKey = localStorageKeyMap[key]; + if (localStorageKey) { + localStorage.removeItem(localStorageKey); + } + return ipcRenderer.invoke('set-setting', key, value); + }, getSecretKey: () => ipcRenderer.invoke('get-secret-key'), getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'), setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable), diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index 0aa41f58ca..f45198e4b6 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -6,7 +6,6 @@ import SuspenseLoader from './suspense-loader'; import { client } from './api/client.gen'; import { setTelemetryEnabled } from './utils/analytics'; import { readConfig } from './api'; - const App = lazy(() => import('./App')); const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; diff --git a/ui/desktop/src/sessionLinks.ts b/ui/desktop/src/sessionLinks.ts index 851bbba3f5..bdbec03502 100644 --- a/ui/desktop/src/sessionLinks.ts +++ b/ui/desktop/src/sessionLinks.ts @@ -26,27 +26,15 @@ export async function openSharedSessionFromDeepLink( throw new Error('Invalid URL: Missing share token'); } - // If no baseUrl is provided, check if there's one in localStorage + // If no baseUrl is provided, check if there's one in settings if (!baseUrl) { - const savedSessionConfig = localStorage.getItem('session_sharing_config'); - if (savedSessionConfig) { - try { - const config = JSON.parse(savedSessionConfig); - if (config.enabled && config.baseUrl) { - baseUrl = config.baseUrl; - } else { - throw new Error( - 'Session sharing is not enabled or base URL is not configured. Check the settings page.' - ); - } - } catch (error) { - console.error('Error parsing session sharing config:', error); - throw new Error( - 'Session sharing is not enabled or base URL is not configured. Check the settings page.' - ); - } + const config = await window.electron.getSetting('sessionSharing'); + if (config.enabled && config.baseUrl) { + baseUrl = config.baseUrl; } else { - throw new Error('Session sharing is not configured'); + throw new Error( + 'Session sharing is not enabled or base URL is not configured. Check the settings page.' + ); } } diff --git a/ui/desktop/src/test/setup.ts b/ui/desktop/src/test/setup.ts index 65d845c34a..cae38d82ae 100644 --- a/ui/desktop/src/test/setup.ts +++ b/ui/desktop/src/test/setup.ts @@ -42,36 +42,50 @@ Object.assign(navigator, { }, }); +// Mock settings store for tests +const mockSettings: Record = { + showMenuBarIcon: true, + showDockIcon: true, + enableWakelock: false, + spellcheckEnabled: true, + keyboardShortcuts: { + focusWindow: 'CommandOrControl+Alt+G', + quickLauncher: 'CommandOrControl+Alt+Shift+G', + newChat: 'CommandOrControl+T', + newChatWindow: 'CommandOrControl+N', + openDirectory: 'CommandOrControl+O', + settings: 'CommandOrControl+,', + find: 'CommandOrControl+F', + findNext: 'CommandOrControl+G', + findPrevious: 'CommandOrControl+Shift+G', + alwaysOnTop: 'CommandOrControl+Shift+T', + }, + externalGoosed: { + enabled: false, + url: '', + secret: '', + }, + theme: 'light', + useSystemTheme: true, + responseStyle: 'concise', + showPricing: true, + sessionSharing: { + enabled: false, + baseUrl: '', + }, + seenAnnouncementIds: [], +}; + // Mock window.electron for renderer process Object.defineProperty(window, 'electron', { writable: true, value: { platform: 'darwin', - getSettings: vi.fn(() => - Promise.resolve({ - envToggles: { - GOOSE_SERVER__MEMORY: false, - GOOSE_SERVER__COMPUTER_CONTROLLER: false, - }, - showMenuBarIcon: true, - showDockIcon: true, - enableWakelock: false, - spellcheckEnabled: true, - keyboardShortcuts: { - focusWindow: 'CommandOrControl+Alt+G', - quickLauncher: 'CommandOrControl+Alt+Shift+G', - newChat: 'CommandOrControl+T', - newChatWindow: 'CommandOrControl+N', - openDirectory: 'CommandOrControl+O', - settings: 'CommandOrControl+,', - find: 'CommandOrControl+F', - findNext: 'CommandOrControl+G', - findPrevious: 'CommandOrControl+Shift+G', - alwaysOnTop: 'CommandOrControl+Shift+T', - }, - }) - ), - saveSettings: vi.fn(() => Promise.resolve(true)), + getSetting: vi.fn((key: string) => Promise.resolve(mockSettings[key])), + setSetting: vi.fn((key: string, value: unknown) => { + mockSettings[key] = value; + return Promise.resolve(); + }), showMessageBox: vi.fn(() => Promise.resolve({ response: 0 })), }, }); diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 99f4971c5d..996e81d4de 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -21,16 +21,32 @@ export type DefaultKeyboardShortcuts = { [K in keyof KeyboardShortcuts]: string; }; +export interface SessionSharingConfig { + enabled: boolean; + baseUrl: string; +} + export interface Settings { + // Desktop app settings showMenuBarIcon: boolean; showDockIcon: boolean; enableWakelock: boolean; spellcheckEnabled: boolean; - externalGoosed?: ExternalGoosedConfig; + externalGoosed: ExternalGoosedConfig; globalShortcut?: string | null; - keyboardShortcuts?: KeyboardShortcuts; + keyboardShortcuts: KeyboardShortcuts; + + // UI preferences (migrated from localStorage) + theme: 'dark' | 'light'; + useSystemTheme: boolean; + responseStyle: string; + showPricing: boolean; + sessionSharing: SessionSharingConfig; + seenAnnouncementIds: string[]; } +export type SettingKey = keyof Settings; + export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = { focusWindow: 'CommandOrControl+Alt+G', quickLauncher: 'CommandOrControl+Alt+Shift+G', @@ -44,6 +60,31 @@ export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = { alwaysOnTop: 'CommandOrControl+Shift+T', }; +export const defaultSettings: Settings = { + // Desktop app settings + showMenuBarIcon: true, + showDockIcon: true, + enableWakelock: false, + spellcheckEnabled: true, + keyboardShortcuts: defaultKeyboardShortcuts, + externalGoosed: { + enabled: false, + url: '', + secret: '', + }, + + // UI preferences + theme: 'light', + useSystemTheme: true, + responseStyle: 'concise', + showPricing: true, + sessionSharing: { + enabled: false, + baseUrl: '', + }, + seenAnnouncementIds: [], +}; + export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts { if (!settings.keyboardShortcuts && settings.globalShortcut !== undefined) { const focusShortcut = settings.globalShortcut;