Client settings (#7381)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-02-21 14:26:03 +00:00
committed by GitHub
parent c543586b6f
commit 45d59be201
16 changed files with 335 additions and 233 deletions
@@ -63,9 +63,7 @@ export default function AnnouncementModal() {
}); });
// Get list of seen announcement IDs // Get list of seen announcement IDs
const seenAnnouncementIds = JSON.parse( const seenAnnouncementIds = await window.electron.getSetting('seenAnnouncementIds');
localStorage.getItem('seenAnnouncementIds') || '[]'
) as string[];
// Find ALL unseen announcements (in order) // Find ALL unseen announcements (in order)
const unseenAnnouncementsList = applicableAnnouncements.filter( const unseenAnnouncementsList = applicableAnnouncements.filter(
@@ -101,13 +99,11 @@ export default function AnnouncementModal() {
loadAnnouncements(); loadAnnouncements();
}, []); }, []);
const handleCloseAnnouncement = () => { const handleCloseAnnouncement = async () => {
if (unseenAnnouncements.length === 0) return; if (unseenAnnouncements.length === 0) return;
// Get existing seen announcement IDs // Get existing seen announcement IDs
const seenAnnouncementIds = JSON.parse( const seenAnnouncementIds = await window.electron.getSetting('seenAnnouncementIds');
localStorage.getItem('seenAnnouncementIds') || '[]'
) as string[];
// Add all unseen announcement IDs to the seen list // Add all unseen announcement IDs to the seen list
const newSeenIds = [...seenAnnouncementIds]; 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); setShowAnnouncementModal(false);
}; };
@@ -428,20 +428,20 @@ function ToolCallView({
notifications, notifications,
isStreamingMessage = false, isStreamingMessage = false,
}: ToolCallViewProps) { }: ToolCallViewProps) {
const [responseStyle, setResponseStyle] = useState(() => localStorage.getItem('response_style')); const [responseStyle, setResponseStyle] = useState<string>('concise');
useEffect(() => { useEffect(() => {
const handleStorageChange = () => { // Load initial value from settings
setResponseStyle(localStorage.getItem('response_style')); window.electron.getSetting('responseStyle').then(setResponseStyle);
const handleStyleChange = () => {
window.electron.getSetting('responseStyle').then(setResponseStyle);
}; };
window.addEventListener('storage', handleStorageChange); window.addEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStyleChange);
window.addEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStorageChange);
return () => { return () => {
window.removeEventListener('storage', handleStorageChange); window.removeEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStyleChange);
window.removeEventListener(AppEvents.RESPONSE_STYLE_CHANGED, handleStorageChange);
}; };
}, []); }, []);
@@ -26,14 +26,19 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
// Check if pricing is enabled // Check if pricing is enabled
useEffect(() => { useEffect(() => {
const checkPricingSetting = () => { const loadPricingSetting = async () => {
const stored = localStorage.getItem('show_pricing'); const enabled = await window.electron.getSetting('showPricing');
setShowPricing(stored !== 'false'); setShowPricing(enabled);
}; };
checkPricingSetting(); loadPricingSetting();
window.addEventListener('storage', checkPricingSetting);
return () => window.removeEventListener('storage', checkPricingSetting); const handlePricingChange = () => {
loadPricingSetting();
};
window.addEventListener('showPricingChanged', handlePricingChange);
return () => window.removeEventListener('showPricingChanged', handlePricingChange);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -151,29 +151,18 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
const setView = useNavigation(); const setView = useNavigation();
useEffect(() => { useEffect(() => {
const savedSessionConfig = localStorage.getItem('session_sharing_config'); window.electron.getSetting('sessionSharing').then((config) => {
if (savedSessionConfig) {
try {
const config = JSON.parse(savedSessionConfig);
if (config.enabled && config.baseUrl) { if (config.enabled && config.baseUrl) {
setCanShare(true); setCanShare(true);
} }
} catch (error) { });
console.error('Error parsing session sharing config:', error);
}
}
}, []); }, []);
const handleShare = async () => { const handleShare = async () => {
setIsSharing(true); setIsSharing(true);
try { try {
const savedSessionConfig = localStorage.getItem('session_sharing_config'); const config = await window.electron.getSetting('sessionSharing');
if (!savedSessionConfig) {
throw new Error('Session sharing is not configured. Please configure it in settings.');
}
const config = JSON.parse(savedSessionConfig);
if (!config.enabled || !config.baseUrl) { if (!config.enabled || !config.baseUrl) {
throw new Error('Session sharing is not enabled or base URL is not configured.'); throw new Error('Session sharing is not enabled or base URL is not configured.');
} }
@@ -58,8 +58,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
// Load show pricing setting // Load show pricing setting
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem('show_pricing'); window.electron.getSetting('showPricing').then(setShowPricing);
setShowPricing(stored !== 'false');
}, []); }, []);
// Handle scrolling to update section // 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); setShowPricing(checked);
localStorage.setItem('show_pricing', String(checked)); await window.electron.setSetting('showPricing', checked);
trackSettingToggled('cost_tracking', checked); trackSettingToggled('cost_tracking', checked);
// Trigger storage event for other components // Trigger event for other components
window.dispatchEvent(new CustomEvent('storage')); window.dispatchEvent(new CustomEvent('showPricingChanged'));
}; };
return ( return (
@@ -3,33 +3,18 @@ import { Switch } from '../../ui/switch';
import { Input } from '../../ui/input'; import { Input } from '../../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { ExternalGoosedConfig } from '../../../utils/settings'; import { ExternalGoosedConfig, defaultSettings } from '../../../utils/settings';
import { WEB_PROTOCOLS } from '../../../utils/urlSecurity'; 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() { export default function ExternalBackendSection() {
const [config, setConfig] = useState<ExternalGoosedConfig>(DEFAULT_CONFIG); const [config, setConfig] = useState<ExternalGoosedConfig>(defaultSettings.externalGoosed);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null); const [urlError, setUrlError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
const settings = await window.electron.getSettings(); const externalGoosed = await window.electron.getSetting('externalGoosed');
setConfig(parseConfig(settings.externalGoosed)); setConfig(externalGoosed);
}; };
loadSettings(); loadSettings();
}, []); }, []);
@@ -56,11 +41,7 @@ export default function ExternalBackendSection() {
const saveConfig = async (newConfig: ExternalGoosedConfig): Promise<void> => { const saveConfig = async (newConfig: ExternalGoosedConfig): Promise<void> => {
setIsSaving(true); setIsSaving(true);
try { try {
const currentSettings = await window.electron.getSettings(); await window.electron.setSetting('externalGoosed', newConfig);
await window.electron.saveSettings({
...currentSettings,
externalGoosed: newConfig,
});
} catch (error) { } catch (error) {
console.error('Failed to save external backend settings:', error); console.error('Failed to save external backend settings:', error);
} finally { } finally {
@@ -122,8 +122,8 @@ export default function KeyboardShortcutsSection() {
const [showRestartNotice, setShowRestartNotice] = useState(false); const [showRestartNotice, setShowRestartNotice] = useState(false);
const loadShortcuts = useCallback(async () => { const loadShortcuts = useCallback(async () => {
const settings = await window.electron.getSettings(); const keyboardShortcuts = await window.electron.getSetting('keyboardShortcuts');
setShortcuts(settings.keyboardShortcuts || defaultKeyboardShortcuts); setShortcuts(keyboardShortcuts || defaultKeyboardShortcuts);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -163,16 +163,12 @@ export default function KeyboardShortcutsSection() {
newShortcuts[key] = null; newShortcuts[key] = null;
} }
const settings = await window.electron.getSettings(); await window.electron.setSetting('keyboardShortcuts', newShortcuts);
settings.keyboardShortcuts = newShortcuts;
const success = await window.electron.saveSettings(settings);
if (success) {
setShortcuts(newShortcuts); setShortcuts(newShortcuts);
trackSettingToggled(`shortcut_${key}`, enabled); trackSettingToggled(`shortcut_${key}`, enabled);
if (needsRestart.has(key)) { if (needsRestart.has(key)) {
setShowRestartNotice(true); setShowRestartNotice(true);
} }
}
}; };
const handleEdit = (key: keyof KeyboardShortcuts) => { const handleEdit = (key: keyof KeyboardShortcuts) => {
@@ -209,16 +205,12 @@ export default function KeyboardShortcutsSection() {
newShortcuts[editingKey] = shortcut || null; newShortcuts[editingKey] = shortcut || null;
const settings = await window.electron.getSettings(); await window.electron.setSetting('keyboardShortcuts', newShortcuts);
settings.keyboardShortcuts = newShortcuts;
const success = await window.electron.saveSettings(settings);
if (success) {
setShortcuts(newShortcuts); setShortcuts(newShortcuts);
setEditingKey(null); setEditingKey(null);
if (needsRestart.has(editingKey)) { if (needsRestart.has(editingKey)) {
setShowRestartNotice(true); setShowRestartNotice(true);
} }
}
}; };
const handleCancel = () => { const handleCancel = () => {
@@ -236,15 +228,11 @@ export default function KeyboardShortcutsSection() {
}); });
if (confirmed.response === 0) { if (confirmed.response === 0) {
const settings = await window.electron.getSettings(); await window.electron.setSetting('keyboardShortcuts', { ...defaultKeyboardShortcuts });
settings.keyboardShortcuts = { ...defaultKeyboardShortcuts };
const success = await window.electron.saveSettings(settings);
if (success) {
setShortcuts({ ...defaultKeyboardShortcuts }); setShortcuts({ ...defaultKeyboardShortcuts });
setShowRestartNotice(true); setShowRestartNotice(true);
trackSettingToggled('shortcuts_reset', true); trackSettingToggled('shortcuts_reset', true);
} }
}
}; };
const groupedShortcuts = shortcutConfigs.reduce( const groupedShortcuts = shortcutConfigs.reduce(
@@ -6,23 +6,24 @@ export const ResponseStylesSection = () => {
const [currentStyle, setCurrentStyle] = useState('concise'); const [currentStyle, setCurrentStyle] = useState('concise');
useEffect(() => { useEffect(() => {
const savedStyle = localStorage.getItem('response_style'); async function loadResponseStyle() {
if (savedStyle) {
try { try {
const savedStyle = await window.electron.getSetting('responseStyle');
setCurrentStyle(savedStyle); setCurrentStyle(savedStyle);
} catch (error) { } 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) => { const handleStyleChange = async (newStyle: string) => {
setCurrentStyle(newStyle); 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 // Dispatch custom event to notify other components of the change
window.dispatchEvent(new CustomEvent(AppEvents.RESPONSE_STYLE_CHANGED)); window.dispatchEvent(new CustomEvent(AppEvents.RESPONSE_STYLE_CHANGED));
@@ -27,25 +27,19 @@ export default function SessionSharingSection() {
sessionSharingConfig.enabled && sessionSharingConfig.enabled &&
isValidUrl(String(sessionSharingConfig.baseUrl)); 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(() => { useEffect(() => {
if (envBaseUrlShare) { 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 = { const forcedConfig = {
enabled: true, enabled: true,
baseUrl: typeof envBaseUrlShare === 'string' ? envBaseUrlShare : '', baseUrl: typeof envBaseUrlShare === 'string' ? envBaseUrlShare : '',
}; };
localStorage.setItem('session_sharing_config', JSON.stringify(forcedConfig)); window.electron.setSetting('sessionSharing', forcedConfig);
} else { } else {
const savedSessionConfig = localStorage.getItem('session_sharing_config'); window.electron.getSetting('sessionSharing').then((config) => {
if (savedSessionConfig) {
try {
const config = JSON.parse(savedSessionConfig);
setSessionSharingConfig(config); setSessionSharingConfig(config);
} catch (error) { });
console.error('Error parsing session sharing config:', error);
}
}
} }
}, [envBaseUrlShare]); }, [envBaseUrlShare]);
@@ -61,20 +55,18 @@ export default function SessionSharingSection() {
} }
// Toggle sharing (only allowed when env is not set). // Toggle sharing (only allowed when env is not set).
const toggleSharing = () => { const toggleSharing = async () => {
if (envBaseUrlShare) { if (envBaseUrlShare) {
return; // Do nothing if the environment variable forces sharing. return; // Do nothing if the environment variable forces sharing.
} }
setSessionSharingConfig((prev) => { const updated = { ...sessionSharingConfig, enabled: !sessionSharingConfig.enabled };
const updated = { ...prev, enabled: !prev.enabled }; setSessionSharingConfig(updated);
localStorage.setItem('session_sharing_config', JSON.stringify(updated)); await window.electron.setSetting('sessionSharing', updated);
trackSettingToggled('session_sharing', updated.enabled); trackSettingToggled('session_sharing', updated.enabled);
return updated;
});
}; };
// Handle changes to the base URL field // Handle changes to the base URL field
const handleBaseUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleBaseUrlChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const newBaseUrl = e.target.value; const newBaseUrl = e.target.value;
setSessionSharingConfig((prev) => ({ setSessionSharingConfig((prev) => ({
...prev, ...prev,
@@ -87,7 +79,7 @@ export default function SessionSharingSection() {
if (isValidUrl(newBaseUrl)) { if (isValidUrl(newBaseUrl)) {
setUrlError(''); setUrlError('');
const updated = { ...sessionSharingConfig, baseUrl: newBaseUrl }; const updated = { ...sessionSharingConfig, baseUrl: newBaseUrl };
localStorage.setItem('session_sharing_config', JSON.stringify(updated)); await window.electron.setSetting('sessionSharing', updated);
} else { } else {
setUrlError('Invalid URL format. Please enter a valid URL (e.g. https://example.com/api).'); setUrlError('Invalid URL format. Please enter a valid URL (e.g. https://example.com/api).');
} }
+49 -31
View File
@@ -22,29 +22,6 @@ function resolveTheme(preference: ThemePreference): ResolvedTheme {
return preference; 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 { function applyThemeToDocument(theme: ResolvedTheme): void {
const toRemove = theme === 'dark' ? 'light' : 'dark'; const toRemove = theme === 'dark' ? 'light' : 'dark';
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme);
@@ -56,19 +33,53 @@ interface ThemeProviderProps {
} }
export function ThemeProvider({ children }: ThemeProviderProps) { export function ThemeProvider({ children }: ThemeProviderProps) {
const [userThemePreference, setUserThemePreferenceState] = // Start with light theme to avoid flash, will update once settings load
useState<ThemePreference>(loadThemePreference); const [userThemePreference, setUserThemePreferenceState] = useState<ThemePreference>('light');
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() => const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');
resolveTheme(loadThemePreference())
); 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;
}
const setUserThemePreference = useCallback((preference: ThemePreference) => {
setUserThemePreferenceState(preference); setUserThemePreferenceState(preference);
saveThemePreference(preference); setResolvedTheme(resolveTheme(preference));
} catch (error) {
console.warn('[ThemeContext] Failed to load theme settings:', error);
}
}
loadThemeFromSettings();
}, []);
const setUserThemePreference = useCallback(async (preference: ThemePreference) => {
setUserThemePreferenceState(preference);
const resolved = resolveTheme(preference); const resolved = resolveTheme(preference);
setResolvedTheme(resolved); 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 // Broadcast to other windows via Electron
window.electron?.broadcastThemeChange({ window.electron?.broadcastThemeChange({
mode: resolved, mode: resolved,
@@ -104,8 +115,15 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
: 'light'; : 'light';
setUserThemePreferenceState(newPreference); setUserThemePreferenceState(newPreference);
saveThemePreference(newPreference);
setResolvedTheme(resolveTheme(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); window.electron.on('theme-changed', handleThemeChanged);
+53 -25
View File
@@ -30,8 +30,8 @@ import log from './utils/logger';
import { ensureWinShims } from './utils/winShims'; import { ensureWinShims } from './utils/winShims';
import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs';
import { formatAppName, errorMessage, formatErrorForLogging } from './utils/conversionUtils'; import { formatAppName, errorMessage, formatErrorForLogging } from './utils/conversionUtils';
import type { Settings } from './utils/settings'; import type { Settings, SettingKey } from './utils/settings';
import { defaultKeyboardShortcuts, getKeyboardShortcuts } from './utils/settings'; import { defaultSettings, getKeyboardShortcuts } from './utils/settings';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as yaml from 'yaml'; import * as yaml from 'yaml';
import windowStateKeeper from 'electron-window-state'; import windowStateKeeper from 'electron-window-state';
@@ -57,18 +57,27 @@ function shouldSetupUpdater(): boolean {
// Settings management // Settings management
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); 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 { function getSettings(): Settings {
if (fsSync.existsSync(SETTINGS_FILE)) { if (fsSync.existsSync(SETTINGS_FILE)) {
const data = fsSync.readFileSync(SETTINGS_FILE, 'utf8'); const data = fsSync.readFileSync(SETTINGS_FILE, 'utf8');
return JSON.parse(data); const stored = JSON.parse(data) as Partial<Settings>;
// 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; return defaultSettings;
} }
@@ -1192,25 +1201,44 @@ ipcMain.handle('add-recent-dir', (_event, dir: string) => {
} }
}); });
// Handle scheduling engine settings ipcMain.handle('get-setting', (_event, key: SettingKey) => {
ipcMain.handle('get-settings', () => { const settings = getSettings();
return getSettings(); // Always returns Settings (uses defaults as fallback) return settings[key];
}); });
ipcMain.handle('save-settings', (_event, settings) => { // Valid setting keys for runtime validation
const oldSettings = getSettings(); const validSettingKeys: Set<string> = new Set([
'showMenuBarIcon',
'showDockIcon',
'enableWakelock',
'spellcheckEnabled',
'externalGoosed',
'globalShortcut',
'keyboardShortcuts',
'theme',
'useSystemTheme',
'responseStyle',
'showPricing',
'sessionSharing',
'seenAnnouncementIds',
]);
const oldShortcuts = getKeyboardShortcuts(oldSettings); ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
const newShortcuts = getKeyboardShortcuts(settings); // Validate key at runtime to prevent prototype pollution
const shortcutsChanged = JSON.stringify(oldShortcuts) !== JSON.stringify(newShortcuts); if (!validSettingKeys.has(key)) {
console.error(`Invalid setting key rejected: ${key}`);
fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); return;
if (shortcutsChanged) {
registerGlobalShortcuts();
} }
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', () => { ipcMain.handle('get-secret-key', () => {
+68 -5
View File
@@ -1,7 +1,45 @@
import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron';
import { Recipe } from './recipe'; import { Recipe } from './recipe';
import { GooseApp } from './api'; 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<Record<SettingKey, string>> = {
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<K extends SettingKey>(
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 { interface NotificationData {
title: string; title: string;
@@ -86,8 +124,8 @@ type ElectronAPI = {
getMenuBarIconState: () => Promise<boolean>; getMenuBarIconState: () => Promise<boolean>;
setDockIcon: (show: boolean) => Promise<boolean>; setDockIcon: (show: boolean) => Promise<boolean>;
getDockIconState: () => Promise<boolean>; getDockIconState: () => Promise<boolean>;
getSettings: () => Promise<Settings>; getSetting: <K extends SettingKey>(key: K) => Promise<Settings[K]>;
saveSettings: (settings: Settings) => Promise<boolean>; setSetting: <K extends SettingKey>(key: K, value: Settings[K]) => Promise<void>;
getSecretKey: () => Promise<string>; getSecretKey: () => Promise<string>;
getGoosedHostPort: () => Promise<string | null>; getGoosedHostPort: () => Promise<string | null>;
setWakelock: (enable: boolean) => Promise<boolean>; setWakelock: (enable: boolean) => Promise<boolean>;
@@ -190,8 +228,33 @@ const electronAPI: ElectronAPI = {
getMenuBarIconState: () => ipcRenderer.invoke('get-menu-bar-icon-state'), getMenuBarIconState: () => ipcRenderer.invoke('get-menu-bar-icon-state'),
setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show), setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show),
getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'), getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'),
getSettings: () => ipcRenderer.invoke('get-settings'), getSetting: async <K extends SettingKey>(key: K): Promise<Settings[K]> => {
saveSettings: (settings: unknown) => ipcRenderer.invoke('save-settings', settings), 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 <K extends SettingKey>(key: K, value: Settings[K]): Promise<void> => {
// 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'), getSecretKey: () => ipcRenderer.invoke('get-secret-key'),
getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'), getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'),
setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable), setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable),
-1
View File
@@ -6,7 +6,6 @@ import SuspenseLoader from './suspense-loader';
import { client } from './api/client.gen'; import { client } from './api/client.gen';
import { setTelemetryEnabled } from './utils/analytics'; import { setTelemetryEnabled } from './utils/analytics';
import { readConfig } from './api'; import { readConfig } from './api';
const App = lazy(() => import('./App')); const App = lazy(() => import('./App'));
const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED'; const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED';
+2 -14
View File
@@ -26,12 +26,9 @@ export async function openSharedSessionFromDeepLink(
throw new Error('Invalid URL: Missing share token'); 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) { if (!baseUrl) {
const savedSessionConfig = localStorage.getItem('session_sharing_config'); const config = await window.electron.getSetting('sessionSharing');
if (savedSessionConfig) {
try {
const config = JSON.parse(savedSessionConfig);
if (config.enabled && config.baseUrl) { if (config.enabled && config.baseUrl) {
baseUrl = config.baseUrl; baseUrl = config.baseUrl;
} else { } else {
@@ -39,15 +36,6 @@ export async function openSharedSessionFromDeepLink(
'Session sharing is not enabled or base URL is not configured. Check the settings page.' '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.'
);
}
} else {
throw new Error('Session sharing is not configured');
}
} }
// Fetch the shared session details // Fetch the shared session details
+28 -14
View File
@@ -42,17 +42,8 @@ Object.assign(navigator, {
}, },
}); });
// Mock window.electron for renderer process // Mock settings store for tests
Object.defineProperty(window, 'electron', { const mockSettings: Record<string, unknown> = {
writable: true,
value: {
platform: 'darwin',
getSettings: vi.fn(() =>
Promise.resolve({
envToggles: {
GOOSE_SERVER__MEMORY: false,
GOOSE_SERVER__COMPUTER_CONTROLLER: false,
},
showMenuBarIcon: true, showMenuBarIcon: true,
showDockIcon: true, showDockIcon: true,
enableWakelock: false, enableWakelock: false,
@@ -69,9 +60,32 @@ Object.defineProperty(window, 'electron', {
findPrevious: 'CommandOrControl+Shift+G', findPrevious: 'CommandOrControl+Shift+G',
alwaysOnTop: 'CommandOrControl+Shift+T', alwaysOnTop: 'CommandOrControl+Shift+T',
}, },
}) externalGoosed: {
), enabled: false,
saveSettings: vi.fn(() => Promise.resolve(true)), 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',
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 })), showMessageBox: vi.fn(() => Promise.resolve({ response: 0 })),
}, },
}); });
+43 -2
View File
@@ -21,16 +21,32 @@ export type DefaultKeyboardShortcuts = {
[K in keyof KeyboardShortcuts]: string; [K in keyof KeyboardShortcuts]: string;
}; };
export interface SessionSharingConfig {
enabled: boolean;
baseUrl: string;
}
export interface Settings { export interface Settings {
// Desktop app settings
showMenuBarIcon: boolean; showMenuBarIcon: boolean;
showDockIcon: boolean; showDockIcon: boolean;
enableWakelock: boolean; enableWakelock: boolean;
spellcheckEnabled: boolean; spellcheckEnabled: boolean;
externalGoosed?: ExternalGoosedConfig; externalGoosed: ExternalGoosedConfig;
globalShortcut?: string | null; 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 = { export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = {
focusWindow: 'CommandOrControl+Alt+G', focusWindow: 'CommandOrControl+Alt+G',
quickLauncher: 'CommandOrControl+Alt+Shift+G', quickLauncher: 'CommandOrControl+Alt+Shift+G',
@@ -44,6 +60,31 @@ export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = {
alwaysOnTop: 'CommandOrControl+Shift+T', 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 { export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts {
if (!settings.keyboardShortcuts && settings.globalShortcut !== undefined) { if (!settings.keyboardShortcuts && settings.globalShortcut !== undefined) {
const focusShortcut = settings.globalShortcut; const focusShortcut = settings.globalShortcut;