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) { if (config.enabled && config.baseUrl) {
try { setCanShare(true);
const config = JSON.parse(savedSessionConfig);
if (config.enabled && config.baseUrl) {
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,15 +163,11 @@ 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; setShortcuts(newShortcuts);
const success = await window.electron.saveSettings(settings); trackSettingToggled(`shortcut_${key}`, enabled);
if (success) { if (needsRestart.has(key)) {
setShortcuts(newShortcuts); setShowRestartNotice(true);
trackSettingToggled(`shortcut_${key}`, enabled);
if (needsRestart.has(key)) {
setShowRestartNotice(true);
}
} }
}; };
@@ -209,15 +205,11 @@ 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; setShortcuts(newShortcuts);
const success = await window.electron.saveSettings(settings); setEditingKey(null);
if (success) { if (needsRestart.has(editingKey)) {
setShortcuts(newShortcuts); setShowRestartNotice(true);
setEditingKey(null);
if (needsRestart.has(editingKey)) {
setShowRestartNotice(true);
}
} }
}; };
@@ -236,14 +228,10 @@ 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 }; setShortcuts({ ...defaultKeyboardShortcuts });
const success = await window.electron.saveSettings(settings); setShowRestartNotice(true);
if (success) { trackSettingToggled('shortcuts_reset', true);
setShortcuts({ ...defaultKeyboardShortcuts });
setShowRestartNotice(true);
trackSettingToggled('shortcuts_reset', true);
}
} }
}; };
@@ -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) { setSessionSharingConfig(config);
try { });
const config = JSON.parse(savedSessionConfig);
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())
);
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); setUserThemePreferenceState(preference);
saveThemePreference(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';
+7 -19
View File
@@ -26,27 +26,15 @@ 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) { if (config.enabled && config.baseUrl) {
try { baseUrl = config.baseUrl;
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.'
);
}
} else { } 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.'
);
} }
} }
+39 -25
View File
@@ -42,36 +42,50 @@ Object.assign(navigator, {
}, },
}); });
// Mock settings store for tests
const mockSettings: Record<string, unknown> = {
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 // Mock window.electron for renderer process
Object.defineProperty(window, 'electron', { Object.defineProperty(window, 'electron', {
writable: true, writable: true,
value: { value: {
platform: 'darwin', platform: 'darwin',
getSettings: vi.fn(() => getSetting: vi.fn((key: string) => Promise.resolve(mockSettings[key])),
Promise.resolve({ setSetting: vi.fn((key: string, value: unknown) => {
envToggles: { mockSettings[key] = value;
GOOSE_SERVER__MEMORY: false, return Promise.resolve();
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)),
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;