diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index 8233fc44..2e04ab65 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -9,9 +9,10 @@ import ConfigSettings from './config/ConfigSettings'; import PromptsSettingsSection from './PromptsSettingsSection'; import { ExtensionConfig } from '../../api'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; -import { Bot, Share2, Monitor, MessageSquare, FileText } from 'lucide-react'; +import { Bot, Share2, Monitor, MessageSquare, FileText, Keyboard } from 'lucide-react'; import { useState, useEffect, useRef } from 'react'; import ChatSettingsSection from './chat/ChatSettingsSection'; +import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection'; import { CONFIGURATION_ENABLED } from '../../updates'; import { trackSettingsTabViewed } from '../../utils/analytics'; @@ -52,6 +53,7 @@ export default function SettingsView({ app: 'app', chat: 'chat', prompts: 'prompts', + keyboard: 'keyboard', }; const targetTab = sectionToTab[viewOptions.section]; @@ -130,6 +132,14 @@ export default function SettingsView({ Prompts + + + Keyboard + App @@ -169,6 +179,13 @@ export default function SettingsView({ + + + + ; -} +import { ExternalGoosedConfig } from '../../../utils/settings'; const DEFAULT_CONFIG: ExternalGoosedConfig = { enabled: false, @@ -20,11 +11,12 @@ const DEFAULT_CONFIG: ExternalGoosedConfig = { secret: '', }; -function parseConfig(partial: Partial | undefined): ExternalGoosedConfig { +function parseConfig(config: ExternalGoosedConfig | undefined): ExternalGoosedConfig { + if (!config) return DEFAULT_CONFIG; return { - enabled: partial?.enabled ?? DEFAULT_CONFIG.enabled, - url: partial?.url ?? DEFAULT_CONFIG.url, - secret: partial?.secret ?? DEFAULT_CONFIG.secret, + enabled: config.enabled ?? DEFAULT_CONFIG.enabled, + url: config.url ?? DEFAULT_CONFIG.url, + secret: config.secret ?? DEFAULT_CONFIG.secret, }; } @@ -35,8 +27,8 @@ export default function ExternalBackendSection() { useEffect(() => { const loadSettings = async () => { - const settings = (await window.electron.getSettings()) as Settings | null; - setConfig(parseConfig(settings?.externalGoosed)); + const settings = await window.electron.getSettings(); + setConfig(parseConfig(settings.externalGoosed)); }; loadSettings(); }, []); @@ -63,7 +55,7 @@ export default function ExternalBackendSection() { const saveConfig = async (newConfig: ExternalGoosedConfig): Promise => { setIsSaving(true); try { - const currentSettings = ((await window.electron.getSettings()) as Settings) || {}; + const currentSettings = await window.electron.getSettings(); await window.electron.saveSettings({ ...currentSettings, externalGoosed: newConfig, diff --git a/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx b/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx new file mode 100644 index 00000000..38f5c328 --- /dev/null +++ b/ui/desktop/src/components/settings/keyboard/KeyboardShortcutsSection.tsx @@ -0,0 +1,377 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; +import { Button } from '../../ui/button'; +import { Switch } from '../../ui/switch'; +import { ShortcutRecorder } from './ShortcutRecorder'; +import { KeyboardShortcuts, defaultKeyboardShortcuts } from '../../../utils/settings'; +import { trackSettingToggled } from '../../../utils/analytics'; + +interface ShortcutConfig { + key: keyof KeyboardShortcuts; + label: string; + description: string; + category: 'global' | 'application' | 'search' | 'window'; +} + +const shortcutConfigs: ShortcutConfig[] = [ + { + key: 'focusWindow', + label: 'Focus Goose Window', + description: 'Bring Goose window to front from anywhere', + category: 'global', + }, + { + key: 'quickLauncher', + label: 'Quick Launcher', + description: 'Open the quick launcher overlay', + category: 'global', + }, + { + key: 'newChat', + label: 'New Chat', + description: 'Create a new chat in the current window', + category: 'application', + }, + { + key: 'newChatWindow', + label: 'New Chat Window', + description: 'Open a new Goose window', + category: 'application', + }, + { + key: 'openDirectory', + label: 'Open Directory', + description: 'Open directory selection dialog', + category: 'application', + }, + { + key: 'settings', + label: 'Settings', + description: 'Open settings panel', + category: 'application', + }, + { + key: 'find', + label: 'Find', + description: 'Open search in conversation', + category: 'search', + }, + { + key: 'findNext', + label: 'Find Next', + description: 'Jump to next search result', + category: 'search', + }, + { + key: 'findPrevious', + label: 'Find Previous', + description: 'Jump to previous search result', + category: 'search', + }, + { + key: 'alwaysOnTop', + label: 'Always on Top', + description: 'Toggle window always on top', + category: 'window', + }, +]; + +const needsRestart = new Set([ + 'newChat', + 'newChatWindow', + 'openDirectory', + 'settings', + 'find', + 'findNext', + 'findPrevious', + 'alwaysOnTop', +]); + +export const getShortcutLabel = (key: string): string => { + const config = shortcutConfigs.find((c) => c.key === key); + return config?.label || key; +}; + +export const formatShortcut = (shortcut: string): string => { + const isMac = window.electron.platform === 'darwin'; + return shortcut + .replace('CommandOrControl', isMac ? '⌘' : 'Ctrl') + .replace('Command', '⌘') + .replace('Control', 'Ctrl') + .replace('Alt', isMac ? '⌥' : 'Alt') + .replace('Shift', isMac ? '⇧' : 'Shift'); +}; + +const categoryLabels = { + global: 'Global Shortcuts', + application: 'Application Shortcuts', + search: 'Search Shortcuts', + window: 'Window Shortcuts', +}; + +const categoryDescriptions = { + global: 'These shortcuts work system-wide, even when Goose is not focused', + application: 'These shortcuts work when Goose is the active application', + search: 'These shortcuts work when searching in a conversation', + window: 'These shortcuts control window behavior', +}; + +export default function KeyboardShortcutsSection() { + const [shortcuts, setShortcuts] = useState(null); + const [editingKey, setEditingKey] = useState(null); + const [showRestartNotice, setShowRestartNotice] = useState(false); + + const loadShortcuts = useCallback(async () => { + const settings = await window.electron.getSettings(); + setShortcuts(settings.keyboardShortcuts || defaultKeyboardShortcuts); + }, []); + + useEffect(() => { + loadShortcuts(); + }, [loadShortcuts]); + + const handleToggle = async (key: keyof KeyboardShortcuts, enabled: boolean) => { + if (!shortcuts) return; + + const defaultValue = defaultKeyboardShortcuts[key]; + const newShortcuts = { ...shortcuts }; + + if (enabled) { + const conflictingKey = Object.entries(shortcuts).find( + ([k, value]) => k !== key && value === defaultValue + )?.[0]; + + if (conflictingKey) { + const confirmed = await window.electron.showMessageBox({ + type: 'warning', + title: 'Shortcut Conflict', + message: `The shortcut ${formatShortcut(defaultValue)} is already assigned to "${getShortcutLabel(conflictingKey)}".`, + detail: `Enabling this will remove the shortcut from "${getShortcutLabel(conflictingKey)}" and assign it to "${getShortcutLabel(key)}". Do you want to continue?`, + buttons: ['Reassign Shortcut', 'Cancel'], + defaultId: 1, + }); + + if (confirmed.response !== 0) { + return; + } + + newShortcuts[conflictingKey as keyof KeyboardShortcuts] = null; + } + + newShortcuts[key] = defaultValue; + } else { + 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); + } + } + }; + + const handleEdit = (key: keyof KeyboardShortcuts) => { + setEditingKey(key); + }; + + const handleSave = async (shortcut: string) => { + if (!shortcuts || !editingKey) return; + + const conflictingKey = Object.entries(shortcuts).find( + ([key, value]) => key !== editingKey && value === shortcut + )?.[0]; + + if (conflictingKey) { + const confirmed = await window.electron.showMessageBox({ + type: 'warning', + title: 'Shortcut Conflict', + message: `The shortcut ${formatShortcut(shortcut)} is already assigned to "${getShortcutLabel(conflictingKey)}".`, + detail: `Saving this will remove the shortcut from "${getShortcutLabel(conflictingKey)}" and assign it to "${getShortcutLabel(editingKey)}". Do you want to continue?`, + buttons: ['Reassign Shortcut', 'Cancel'], + defaultId: 1, + }); + + if (confirmed.response !== 0) { + return; + } + } + + const newShortcuts = { ...shortcuts }; + + if (conflictingKey) { + newShortcuts[conflictingKey as keyof KeyboardShortcuts] = null; + } + + 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); + } + } + }; + + const handleCancel = () => { + setEditingKey(null); + }; + + const handleResetToDefaults = async () => { + const confirmed = await window.electron.showMessageBox({ + type: 'question', + title: 'Reset Keyboard Shortcuts', + message: 'Reset all keyboard shortcuts to their default values?', + detail: 'This will restore all shortcuts to their original configuration.', + buttons: ['Reset to Defaults', 'Cancel'], + defaultId: 1, + }); + + 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); + } + } + }; + + const groupedShortcuts = shortcutConfigs.reduce( + (acc, config) => { + if (!acc[config.category]) { + acc[config.category] = []; + } + acc[config.category].push(config); + return acc; + }, + {} as Record + ); + + if (!shortcuts) { + return
Loading...
; + } + + return ( +
+ {showRestartNotice && ( + + +
+
+

Restart Required

+

+ Changes to application shortcuts (like New Chat, Settings, etc.) require + restarting Goose to take effect. Global shortcuts (Focus Window, Quick Launcher) + work immediately. +

+
+ +
+
+
+ )} + {Object.entries(groupedShortcuts).map(([category, configs]) => ( + + + {categoryLabels[category as keyof typeof categoryLabels]} + + {categoryDescriptions[category as keyof typeof categoryDescriptions]} + + + + {configs.map((config) => { + const shortcut = shortcuts[config.key]; + const isEditing = editingKey === config.key; + + return ( +
+
+

{config.label}

+

+ {config.description} +

+
+
+ {!isEditing ? ( + <> + {shortcut ? ( + + {formatShortcut(shortcut)} + + ) : ( + + Disabled + + )} + + handleToggle(config.key, checked)} + variant="mono" + /> + + ) : ( + + )} +
+
+ ); + })} +
+
+ ))} + + + +
+
+

Reset to Defaults

+

+ Restore all keyboard shortcuts to their original configuration +

+
+ +
+
+
+
+ ); +} diff --git a/ui/desktop/src/components/settings/keyboard/ShortcutRecorder.tsx b/ui/desktop/src/components/settings/keyboard/ShortcutRecorder.tsx new file mode 100644 index 00000000..a25bdb80 --- /dev/null +++ b/ui/desktop/src/components/settings/keyboard/ShortcutRecorder.tsx @@ -0,0 +1,191 @@ +import { useState, useEffect, useRef } from 'react'; +import { Button } from '../../ui/button'; +import { KeyboardShortcuts } from '../../../utils/settings'; +import { getShortcutLabel, formatShortcut } from './KeyboardShortcutsSection'; + +interface ShortcutRecorderProps { + value: string; + onSave: (shortcut: string) => void; + onCancel: () => void; + allShortcuts?: KeyboardShortcuts; + currentKey?: keyof KeyboardShortcuts; +} + +export function ShortcutRecorder({ + value, + onSave, + onCancel, + allShortcuts, + currentKey, +}: ShortcutRecorderProps) { + const [recording, setRecording] = useState(true); + const [capturedShortcut, setCapturedShortcut] = useState(value); + const [displayShortcut, setDisplayShortcut] = useState(''); + const [conflict, setConflict] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + useEffect(() => { + if (recording && inputRef.current) { + inputRef.current.focus(); + } + }, [recording]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!recording) return; + + e.preventDefault(); + e.stopPropagation(); + + // Ignore modifier-only presses + if (['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) { + return; + } + + const parts: string[] = []; + + if (e.ctrlKey || e.metaKey) { + parts.push('CommandOrControl'); + } + if (e.altKey) { + parts.push('Alt'); + } + if (e.shiftKey) { + parts.push('Shift'); + } + + let key = e.code && e.code.startsWith('Key') ? e.code.replace('Key', '') : e.key; + + const keyMap: Record = { + ' ': 'Space', + Space: 'Space', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + Escape: 'Esc', + Delete: 'Delete', + Backspace: 'Backspace', + Tab: 'Tab', + Enter: 'Return', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Comma: ',', + Period: '.', + Slash: '/', + Backquote: '`', + }; + + if (e.code && e.code.startsWith('Digit')) { + key = e.code.replace('Digit', ''); + } else if (keyMap[key] || keyMap[e.code]) { + key = keyMap[key] || keyMap[e.code]; + } else if (key.length === 1) { + key = key.toUpperCase(); + } + + parts.push(key); + + const accelerator = parts.join('+'); + setCapturedShortcut(accelerator); + + if (allShortcuts && currentKey) { + const conflictingKey = Object.entries(allShortcuts).find( + ([key, shortcut]) => key !== currentKey && shortcut === accelerator + ); + if (conflictingKey) { + setConflict(conflictingKey[0]); + } else { + setConflict(null); + } + } + + setDisplayShortcut(formatShortcut(accelerator)); + setRecording(false); + }; + + const handleStartRecording = () => { + setRecording(true); + setCapturedShortcut(''); + setDisplayShortcut(''); + setConflict(null); + }; + + const handleSave = () => { + onSave(capturedShortcut); + }; + + const handleCancel = () => { + onCancel(); + }; + + return ( +
+
+
+ {recording ? ( + Press shortcut... + ) : displayShortcut ? ( + + {displayShortcut} + + ) : capturedShortcut ? ( + + {formatShortcut(capturedShortcut)} + + ) : ( + Click to record... + )} +
+ + +
+ {conflict && ( +
+ ⚠️ + + This shortcut is already used by {getShortcutLabel(conflict)}. Saving + will reassign it to this action. + +
+ )} +
+ ); +} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 1a0c0ec6..0da4edeb 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -16,7 +16,6 @@ import { Tray, } from 'electron'; import { pathToFileURL, format as formatUrl, URLSearchParams } from 'node:url'; -import { Buffer } from 'node:buffer'; import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import started from 'electron-squirrel-startup'; @@ -30,14 +29,9 @@ import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; import { addRecentDir, loadRecentDirs } from './utils/recentDirs'; import { formatAppName } from './utils/conversionUtils'; -import { - EnvToggles, - loadSettings, - saveSettings, - updateEnvironmentVariables, -} from './utils/settings'; +import type { Settings } from './utils/settings'; +import { defaultKeyboardShortcuts, getKeyboardShortcuts } from './utils/settings'; import * as crypto from 'crypto'; -// import electron from "electron"; import * as yaml from 'yaml'; import windowStateKeeper from 'electron-window-state'; import { @@ -53,85 +47,34 @@ import { Client, createClient, createConfig } from './api/client'; import { GooseApp } from './api'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; -// Updater functions (moved here to keep updates.ts minimal for release replacement) function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled return UPDATES_ENABLED || process.env.ENABLE_DEV_UPDATES === 'true'; } -// Define temp directory for pasted images -const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images'); +// Settings management +const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); -// Function to ensure the temporary directory exists -async function ensureTempDirExists(): Promise { - try { - // Check if the path already exists - try { - const stats = await fs.stat(gooseTempDir); +const defaultSettings: Settings = { + showMenuBarIcon: true, + showDockIcon: true, + enableWakelock: false, + spellcheckEnabled: true, + keyboardShortcuts: defaultKeyboardShortcuts, +}; - // If it exists but is not a directory, remove it and recreate - if (!stats.isDirectory()) { - await fs.unlink(gooseTempDir); - await fs.mkdir(gooseTempDir, { recursive: true }); - } - - // Startup cleanup: remove old files and any symlinks - const files = await fs.readdir(gooseTempDir); - const now = Date.now(); - const MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - - for (const file of files) { - const filePath = path.join(gooseTempDir, file); - try { - const fileStats = await fs.lstat(filePath); - - // Always remove symlinks - if (fileStats.isSymbolicLink()) { - console.warn( - `[Main] Found symlink in temp directory during startup: ${filePath}. Removing it.` - ); - await fs.unlink(filePath); - continue; - } - - // Remove old files (older than 24 hours) - if (fileStats.isFile()) { - const fileAge = now - fileStats.mtime.getTime(); - if (fileAge > MAX_AGE) { - console.log( - `[Main] Removing old temp file during startup: ${filePath} (age: ${Math.round(fileAge / (60 * 60 * 1000))} hours)` - ); - await fs.unlink(filePath); - } - } - } catch (fileError) { - // If we can't stat the file, try to remove it anyway - console.warn(`[Main] Could not stat file ${filePath}, attempting to remove:`, fileError); - try { - await fs.unlink(filePath); - } catch (unlinkError) { - console.error(`[Main] Failed to remove problematic file ${filePath}:`, unlinkError); - } - } - } - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - // Directory doesn't exist, create it - await fs.mkdir(gooseTempDir, { recursive: true }); - } else { - throw error; - } - } - - // Set proper permissions on the directory (0755 = rwxr-xr-x) - await fs.chmod(gooseTempDir, 0o755); - - console.log('[Main] Temporary directory for pasted images ensured:', gooseTempDir); - } catch (error) { - console.error('[Main] Failed to create temp directory:', gooseTempDir, error); - throw error; // Propagate error +function getSettings(): Settings { + if (fsSync.existsSync(SETTINGS_FILE)) { + const data = fsSync.readFileSync(SETTINGS_FILE, 'utf8'); + return JSON.parse(data); } - return gooseTempDir; + return defaultSettings; +} + +function updateSettings(modifier: (settings: Settings) => void): void { + const settings = getSettings(); + modifier(settings); + fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); } async function configureProxy() { @@ -451,9 +394,6 @@ async function handleFileOpen(filePath: string) { declare var MAIN_WINDOW_VITE_DEV_SERVER_URL: string; declare var MAIN_WINDOW_VITE_NAME: string; -// State for environment variable toggles -let envToggles: EnvToggles = loadSettings().envToggles; - // Parse command line arguments const parseArgs = () => { let dirPath = null; @@ -496,7 +436,7 @@ const { defaultProvider, defaultModel, predefinedModels, baseUrlShare, version } const GENERATED_SECRET = crypto.randomBytes(32).toString('hex'); -const getServerSecret = (settings: ReturnType): string => { +const getServerSecret = (settings: Settings): string => { if (settings.externalGoosed?.enabled && settings.externalGoosed.secret) { return settings.externalGoosed.secret; } @@ -536,9 +476,7 @@ const createChat = async ( recipeId?: string, recipeParameters?: Record // Recipe parameter values from deeplink URL ) => { - updateEnvironmentVariables(envToggles); - - const settings = loadSettings(); + const settings = getSettings(); const serverSecret = getServerSecret(settings); const goosedResult = await startGoosed({ @@ -630,15 +568,11 @@ const createChat = async ( }); if (response === 0) { - const updatedSettings = { - ...settings, - externalGoosed: { - enabled: false, - url: settings.externalGoosed?.url || '', - secret: settings.externalGoosed?.secret || '', - }, - }; - saveSettings(updatedSettings); + updateSettings((s) => { + if (s.externalGoosed) { + s.externalGoosed.enabled = false; + } + }); mainWindow.destroy(); return createChat(app, initialMessage, dir); } @@ -937,9 +871,9 @@ const destroyTray = () => { }; const disableTray = () => { - const settings = loadSettings(); - settings.showMenuBarIcon = false; - saveSettings(settings); + updateSettings((s) => { + s.showMenuBarIcon = false; + }); }; const createTray = () => { @@ -1257,26 +1191,27 @@ ipcMain.handle('add-recent-dir', (_event, dir: string) => { // Handle scheduling engine settings ipcMain.handle('get-settings', () => { - try { - return loadSettings(); - } catch (error) { - console.error('Error getting settings:', error); - return null; - } + return getSettings(); // Always returns Settings (uses defaults as fallback) }); ipcMain.handle('save-settings', (_event, settings) => { - try { - saveSettings(settings); - return true; - } catch (error) { - console.error('Error saving settings:', error); - return false; + const oldSettings = getSettings(); + + 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(); } + + return true; }); ipcMain.handle('get-secret-key', () => { - const settings = loadSettings(); + const settings = getSettings(); return getServerSecret(settings); }); @@ -1294,26 +1229,21 @@ ipcMain.handle('get-goosed-host-port', async (event) => { // Handle menu bar icon visibility ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => { - try { - const settings = loadSettings(); - settings.showMenuBarIcon = show; - saveSettings(settings); + updateSettings((s) => { + s.showMenuBarIcon = show; + }); - if (show) { - createTray(); - } else { - destroyTray(); - } - return true; - } catch (error) { - console.error('Error setting menu bar icon:', error); - return false; + if (show) { + createTray(); + } else { + destroyTray(); } + return true; }); ipcMain.handle('get-menu-bar-icon-state', () => { try { - const settings = loadSettings(); + const settings = getSettings(); return settings.showMenuBarIcon ?? true; } catch (error) { console.error('Error getting menu bar icon state:', error); @@ -1323,35 +1253,31 @@ ipcMain.handle('get-menu-bar-icon-state', () => { // Handle dock icon visibility (macOS only) ipcMain.handle('set-dock-icon', async (_event, show: boolean) => { - try { - if (process.platform !== 'darwin') return false; + if (process.platform !== 'darwin') return false; - const settings = loadSettings(); - settings.showDockIcon = show; - saveSettings(settings); + const settings = getSettings(); + updateSettings((s) => { + s.showDockIcon = show; + }); - if (show) { - app.dock?.show(); - } else { - // Only hide the dock if we have a menu bar icon to maintain accessibility - if (settings.showMenuBarIcon) { - app.dock?.hide(); - setTimeout(() => { - focusWindow(); - }, 50); - } + if (show) { + app.dock?.show(); + } else { + // Only hide the dock if we have a menu bar icon to maintain accessibility + if (settings.showMenuBarIcon) { + app.dock?.hide(); + setTimeout(() => { + focusWindow(); + }, 50); } - return true; - } catch (error) { - console.error('Error setting dock icon:', error); - return false; } + return true; }); ipcMain.handle('get-dock-icon-state', () => { try { if (process.platform !== 'darwin') return true; - const settings = loadSettings(); + const settings = getSettings(); return settings.showDockIcon ?? true; } catch (error) { console.error('Error getting dock icon state:', error); @@ -1417,39 +1343,34 @@ ipcMain.handle('open-notifications-settings', async () => { // Handle wakelock setting ipcMain.handle('set-wakelock', async (_event, enable: boolean) => { - try { - const settings = loadSettings(); - settings.enableWakelock = enable; - saveSettings(settings); + updateSettings((s) => { + s.enableWakelock = enable; + }); - // Stop all existing power save blockers when disabling the setting - if (!enable) { - for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { - try { - powerSaveBlocker.stop(blockerId); - console.log( - `[Main] Stopped power save blocker ${blockerId} for window ${windowId} due to wakelock setting disabled` - ); - } catch (error) { - console.error( - `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, - error - ); - } + // Stop all existing power save blockers when disabling the setting + if (!enable) { + for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) { + try { + powerSaveBlocker.stop(blockerId); + console.log( + `[Main] Stopped power save blocker ${blockerId} for window ${windowId} due to wakelock setting disabled` + ); + } catch (error) { + console.error( + `[Main] Failed to stop power save blocker ${blockerId} for window ${windowId}:`, + error + ); } - windowPowerSaveBlockers.clear(); } - - return true; - } catch (error) { - console.error('Error setting wakelock:', error); - return false; + windowPowerSaveBlockers.clear(); } + + return true; }); ipcMain.handle('get-wakelock-state', () => { try { - const settings = loadSettings(); + const settings = getSettings(); return settings.enableWakelock ?? false; } catch (error) { console.error('Error getting wakelock state:', error); @@ -1458,20 +1379,15 @@ ipcMain.handle('get-wakelock-state', () => { }); ipcMain.handle('set-spellcheck', async (_event, enable: boolean) => { - try { - const settings = loadSettings(); - settings.spellcheckEnabled = enable; - saveSettings(settings); - return true; - } catch (error) { - console.error('Error setting spellcheck:', error); - return false; - } + updateSettings((s) => { + s.spellcheckEnabled = enable; + }); + return true; }); ipcMain.handle('get-spellcheck-state', () => { try { - const settings = loadSettings(); + const settings = getSettings(); return settings.spellcheckEnabled ?? true; } catch (error) { console.error('Error getting spellcheck state:', error); @@ -1514,130 +1430,6 @@ ipcMain.handle('select-file-or-directory', async (_event, defaultPath?: string) return null; }); -// IPC handler to save data URL to a temporary file -ipcMain.handle('save-data-url-to-temp', async (_event, dataUrl: string, uniqueId: string) => { - console.log(`[Main] Received save-data-url-to-temp for ID: ${uniqueId}`); - try { - // Input validation for uniqueId - only allow alphanumeric characters and hyphens - if (!uniqueId || !/^[a-zA-Z0-9-]+$/.test(uniqueId) || uniqueId.length > 50) { - console.error('[Main] Invalid uniqueId format received.'); - return { id: uniqueId, error: 'Invalid uniqueId format' }; - } - - // Input validation for dataUrl - if (!dataUrl || typeof dataUrl !== 'string' || dataUrl.length > 10 * 1024 * 1024) { - // 10MB limit - console.error('[Main] Invalid or too large data URL received.'); - return { id: uniqueId, error: 'Invalid or too large data URL' }; - } - - const tempDir = await ensureTempDirExists(); - const matches = dataUrl.match(/^data:(image\/(png|jpeg|jpg|gif|webp));base64,(.*)$/); - - if (!matches || matches.length < 4) { - console.error('[Main] Invalid data URL format received.'); - return { id: uniqueId, error: 'Invalid data URL format or unsupported image type' }; - } - - const imageExtension = matches[2]; // e.g., "png", "jpeg" - const base64Data = matches[3]; - - // Validate base64 data - if (!base64Data || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64Data)) { - console.error('[Main] Invalid base64 data received.'); - return { id: uniqueId, error: 'Invalid base64 data' }; - } - - const buffer = Buffer.from(base64Data, 'base64'); - - // Validate image size (max 5MB) - if (buffer.length > 5 * 1024 * 1024) { - console.error('[Main] Image too large.'); - return { id: uniqueId, error: 'Image too large (max 5MB)' }; - } - - const randomString = crypto.randomBytes(8).toString('hex'); - const fileName = `pasted-${uniqueId}-${randomString}.${imageExtension}`; - const filePath = path.join(tempDir, fileName); - - // Ensure the resolved path is still within the temp directory - const resolvedPath = path.resolve(filePath); - const resolvedTempDir = path.resolve(tempDir); - if (!resolvedPath.startsWith(resolvedTempDir + path.sep)) { - console.error('[Main] Attempted path traversal detected.'); - return { id: uniqueId, error: 'Invalid file path' }; - } - - await fs.writeFile(filePath, buffer); - console.log(`[Main] Saved image for ID ${uniqueId} to: ${filePath}`); - return { id: uniqueId, filePath: filePath }; - } catch (error) { - console.error(`[Main] Failed to save image to temp for ID ${uniqueId}:`, error); - return { id: uniqueId, error: error instanceof Error ? error.message : 'Failed to save image' }; - } -}); - -ipcMain.on('delete-temp-file', async (_event, filePath: string) => { - console.log(`[Main] Received delete-temp-file for path: ${filePath}`); - - // Input validation - if (!filePath || typeof filePath !== 'string') { - console.warn('[Main] Invalid file path provided for deletion'); - return; - } - - // Ensure the path is within the designated temp directory - const resolvedPath = path.resolve(filePath); - const resolvedTempDir = path.resolve(gooseTempDir); - - if (!resolvedPath.startsWith(resolvedTempDir + path.sep)) { - console.warn(`[Main] Attempted to delete file outside designated temp directory: ${filePath}`); - return; - } - - try { - // Check if it's a regular file first, before trying realpath - const stats = await fs.lstat(filePath); - if (!stats.isFile()) { - console.warn(`[Main] Not a regular file, refusing to delete: ${filePath}`); - return; - } - - // Get the real paths for both the temp directory and the file to handle symlinks properly - let actualPath = filePath; - - try { - const realTempDir = await fs.realpath(gooseTempDir); - const realPath = await fs.realpath(filePath); - - // Double-check that the real path is still within our real temp directory - if (!realPath.startsWith(realTempDir + path.sep)) { - console.warn( - `[Main] Real path is outside designated temp directory: ${realPath} not in ${realTempDir}` - ); - return; - } - actualPath = realPath; - } catch (realpathError) { - // If realpath fails, use the original path validation - console.log( - `[Main] realpath failed for ${filePath}, using original path validation:`, - realpathError instanceof Error ? realpathError.message : String(realpathError) - ); - } - - await fs.unlink(actualPath); - console.log(`[Main] Deleted temp file: ${filePath}`); - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code !== 'ENOENT') { - // ENOENT means file doesn't exist, which is fine - console.error(`[Main] Failed to delete temp file: ${filePath}`, error); - } else { - console.log(`[Main] Temp file already deleted or not found: ${filePath}`); - } - } -}); - ipcMain.handle('check-ollama', async () => { try { return new Promise((resolve) => { @@ -1807,6 +1599,33 @@ const focusWindow = () => { } }; +const registerGlobalShortcuts = () => { + globalShortcut.unregisterAll(); + + const settings = getSettings(); + const shortcuts = getKeyboardShortcuts(settings); + + if (shortcuts.focusWindow) { + try { + globalShortcut.register(shortcuts.focusWindow, () => { + focusWindow(); + }); + } catch (e) { + console.error('Error registering focus window hotkey:', e); + } + } + + if (shortcuts.quickLauncher) { + try { + globalShortcut.register(shortcuts.quickLauncher, () => { + createLauncher(); + }); + } catch (e) { + console.error('Error registering launcher hotkey:', e); + } + } +}; + async function appMain() { await configureProxy(); @@ -1836,7 +1655,7 @@ async function appMain() { 'https://objects.githubusercontent.com', ]; - const settings = loadSettings(); + const settings = getSettings(); if (settings.externalGoosed?.enabled && settings.externalGoosed.url) { try { const externalUrl = new URL(settings.externalGoosed.url); @@ -1873,28 +1692,23 @@ async function appMain() { }); }); - try { - globalShortcut.register('CommandOrControl+Alt+Shift+G', () => { - createLauncher(); + // Migrate old settings format if needed (one-time migration) + const settings = getSettings(); + if (!settings.keyboardShortcuts && settings.globalShortcut !== undefined) { + updateSettings((s) => { + s.keyboardShortcuts = getKeyboardShortcuts(s); + delete s.globalShortcut; }); - } catch (e) { - console.error('Error registering launcher hotkey:', e); } - try { - globalShortcut.register('CommandOrControl+Alt+G', () => { - focusWindow(); - }); - } catch (e) { - console.error('Error registering focus window hotkey:', e); - } + // Register global shortcuts based on settings + registerGlobalShortcuts(); session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => { details.requestHeaders['Origin'] = 'http://localhost:5173'; callback({ cancel: false, requestHeaders: details.requestHeaders }); }); - const settings = loadSettings(); if (settings.showMenuBarIcon) { createTray(); } @@ -1937,20 +1751,24 @@ async function appMain() { const menu = Menu.getApplicationMenu(); + const shortcuts = getKeyboardShortcuts(settings); + const appMenu = menu?.items.find((item) => item.label === 'Goose'); if (appMenu?.submenu) { appMenu.submenu.insert(1, new MenuItem({ type: 'separator' })); - appMenu.submenu.insert( - 1, - new MenuItem({ - label: 'Settings', - accelerator: 'CmdOrCtrl+,', - click() { - const focusedWindow = BrowserWindow.getFocusedWindow(); - if (focusedWindow) focusedWindow.webContents.send('set-view', 'settings'); - }, - }) - ); + if (shortcuts.settings) { + appMenu.submenu.insert( + 1, + new MenuItem({ + label: 'Settings', + accelerator: shortcuts.settings, + click() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) focusedWindow.webContents.send('set-view', 'settings'); + }, + }) + ); + } appMenu.submenu.insert(1, new MenuItem({ type: 'separator' })); } @@ -1961,7 +1779,7 @@ async function appMain() { const findSubmenu = Menu.buildFromTemplate([ { label: 'Find…', - accelerator: process.platform === 'darwin' ? 'Command+F' : 'Control+F', + accelerator: shortcuts.find || undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('find-command'); @@ -1969,7 +1787,7 @@ async function appMain() { }, { label: 'Find Next', - accelerator: process.platform === 'darwin' ? 'Command+G' : 'Control+G', + accelerator: shortcuts.findNext || undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('find-next'); @@ -1977,7 +1795,7 @@ async function appMain() { }, { label: 'Find Previous', - accelerator: process.platform === 'darwin' ? 'Shift+Command+G' : 'Shift+Control+G', + accelerator: shortcuts.findPrevious || undefined, click() { const focusedWindow = BrowserWindow.getFocusedWindow(); if (focusedWindow) focusedWindow.webContents.send('find-previous'); @@ -2006,42 +1824,51 @@ async function appMain() { const fileMenu = menu?.items.find((item) => item.label === 'File'); if (fileMenu?.submenu) { - fileMenu.submenu.insert( - 0, - new MenuItem({ - label: 'New Chat', - accelerator: 'CmdOrCtrl+T', - click() { - const focusedWindow = BrowserWindow.getFocusedWindow(); - if (focusedWindow) focusedWindow.webContents.send('new-chat'); - }, - }) - ); + // Use a counter to track the actual insertion index + let menuIndex = 0; - fileMenu.submenu.insert( - 1, - new MenuItem({ - label: 'New Chat Window', - accelerator: process.platform === 'darwin' ? 'Cmd+N' : 'Ctrl+N', - click() { - ipcMain.emit('create-chat-window'); - }, - }) - ); + if (shortcuts.newChat) { + fileMenu.submenu.insert( + menuIndex++, + new MenuItem({ + label: 'New Chat', + accelerator: shortcuts.newChat, + click() { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) focusedWindow.webContents.send('new-chat'); + }, + }) + ); + } - fileMenu.submenu.insert( - 2, - new MenuItem({ - label: 'Open Directory...', - accelerator: 'CmdOrCtrl+O', - click: () => openDirectoryDialog(), - }) - ); + if (shortcuts.newChatWindow) { + fileMenu.submenu.insert( + menuIndex++, + new MenuItem({ + label: 'New Chat Window', + accelerator: shortcuts.newChatWindow, + click() { + ipcMain.emit('create-chat-window'); + }, + }) + ); + } + + if (shortcuts.openDirectory) { + fileMenu.submenu.insert( + menuIndex++, + new MenuItem({ + label: 'Open Directory...', + accelerator: shortcuts.openDirectory, + click: () => openDirectoryDialog(), + }) + ); + } const recentFilesSubmenu = buildRecentFilesMenu(); if (recentFilesSubmenu.length > 0) { fileMenu.submenu.insert( - 3, + menuIndex++, new MenuItem({ label: 'Recent Directories', submenu: recentFilesSubmenu, @@ -2049,27 +1876,31 @@ async function appMain() { ); } - fileMenu.submenu.insert(4, new MenuItem({ type: 'separator' })); + fileMenu.submenu.insert(menuIndex++, new MenuItem({ type: 'separator' })); - fileMenu.submenu.append( - new MenuItem({ - label: 'Focus Goose Window', - accelerator: 'CmdOrCtrl+Alt+G', - click() { - focusWindow(); - }, - }) - ); + if (shortcuts.focusWindow) { + fileMenu.submenu.append( + new MenuItem({ + label: 'Focus Goose Window', + accelerator: shortcuts.focusWindow, + click() { + focusWindow(); + }, + }) + ); + } - fileMenu.submenu.append( - new MenuItem({ - label: 'Quick Launcher', - accelerator: 'CmdOrCtrl+Alt+Shift+G', - click() { - createLauncher(); - }, - }) - ); + if (shortcuts.quickLauncher) { + fileMenu.submenu.append( + new MenuItem({ + label: 'Quick Launcher', + accelerator: shortcuts.quickLauncher, + click() { + createLauncher(); + }, + }) + ); + } } if (menu) { @@ -2090,29 +1921,31 @@ async function appMain() { } if (windowMenu.submenu) { - windowMenu.submenu.append( - new MenuItem({ - label: 'Always on Top', - type: 'checkbox', - accelerator: process.platform === 'darwin' ? 'Cmd+Shift+T' : 'Ctrl+Shift+T', - click(menuItem) { - const focusedWindow = BrowserWindow.getFocusedWindow(); - if (focusedWindow) { - const isAlwaysOnTop = menuItem.checked; + if (shortcuts.alwaysOnTop) { + windowMenu.submenu.append( + new MenuItem({ + label: 'Always on Top', + type: 'checkbox', + accelerator: shortcuts.alwaysOnTop, + click(menuItem) { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (focusedWindow) { + const isAlwaysOnTop = menuItem.checked; - if (process.platform === 'darwin') { - focusedWindow.setAlwaysOnTop(isAlwaysOnTop, 'floating'); - } else { - focusedWindow.setAlwaysOnTop(isAlwaysOnTop); + if (process.platform === 'darwin') { + focusedWindow.setAlwaysOnTop(isAlwaysOnTop, 'floating'); + } else { + focusedWindow.setAlwaysOnTop(isAlwaysOnTop); + } + + console.log( + `[Main] Set always-on-top to ${isAlwaysOnTop} for window ${focusedWindow.id}` + ); } - - console.log( - `[Main] Set always-on-top to ${isAlwaysOnTop} for window ${focusedWindow.id}` - ); - } - }, - }) - ); + }, + }) + ); + } } } @@ -2546,54 +2379,7 @@ app.on('will-quit', async () => { } windowPowerSaveBlockers.clear(); - // Unregister all shortcuts when quitting globalShortcut.unregisterAll(); - - try { - await fs.access(gooseTempDir); // Check if directory exists to avoid error on fs.rm if it doesn't - - // First, check for any symlinks in the directory and refuse to delete them - let hasSymlinks = false; - try { - const files = await fs.readdir(gooseTempDir); - for (const file of files) { - const filePath = path.join(gooseTempDir, file); - const stats = await fs.lstat(filePath); - if (stats.isSymbolicLink()) { - console.warn(`[Main] Found symlink in temp directory: ${filePath}. Skipping deletion.`); - hasSymlinks = true; - // Delete the individual file but leave the symlink - continue; - } - - // Delete regular files individually - if (stats.isFile()) { - await fs.unlink(filePath); - } - } - - // If no symlinks were found, it's safe to remove the directory - if (!hasSymlinks) { - await fs.rm(gooseTempDir, { recursive: true, force: true }); - console.log('[Main] Pasted images temp directory cleaned up successfully.'); - } else { - console.log( - '[Main] Cleaned up files in temp directory but left directory intact due to symlinks.' - ); - } - } catch (err) { - console.error('[Main] Error while cleaning up temp directory contents:', err); - } - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { - console.log('[Main] Temp directory did not exist during "will-quit", no cleanup needed.'); - } else { - console.error( - '[Main] Failed to clean up pasted images temp directory during "will-quit":', - error - ); - } - } }); app.on('window-all-closed', () => { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index bf3b6a43..59cd44af 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -1,6 +1,7 @@ import Electron, { contextBridge, ipcRenderer, webUtils } from 'electron'; import { Recipe } from './recipe'; import { GooseApp } from './api'; +import type { Settings } from './utils/settings'; interface NotificationData { title: string; @@ -43,12 +44,6 @@ interface FileResponse { found: boolean; } -interface SaveDataUrlResponse { - id: string; - filePath?: string; - error?: string; -} - const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); interface UpdaterEvent { @@ -91,8 +86,8 @@ type ElectronAPI = { getMenuBarIconState: () => Promise; setDockIcon: (show: boolean) => Promise; getDockIconState: () => Promise; - getSettings: () => Promise; - saveSettings: (settings: unknown) => Promise; + getSettings: () => Promise; + saveSettings: (settings: Settings) => Promise; getSecretKey: () => Promise; getGoosedHostPort: () => Promise; setWakelock: (enable: boolean) => Promise; @@ -116,10 +111,6 @@ type ElectronAPI = { useSystemTheme: boolean; theme: string; }) => void; - // Functions for image pasting - saveDataUrlToTemp: (dataUrl: string, uniqueId: string) => Promise; - deleteTempFile: (filePath: string) => void; - // Function for opening external URLs securely openExternal: (url: string) => Promise; // Update-related functions getVersion: () => string; @@ -235,12 +226,6 @@ const electronAPI: ElectronAPI = { broadcastThemeChange: (themeData: { mode: string; useSystemTheme: boolean; theme: string }) => { ipcRenderer.send('broadcast-theme-change', themeData); }, - saveDataUrlToTemp: (dataUrl: string, uniqueId: string): Promise => { - return ipcRenderer.invoke('save-data-url-to-temp', dataUrl, uniqueId); - }, - deleteTempFile: (filePath: string): void => { - ipcRenderer.send('delete-temp-file', filePath); - }, openExternal: (url: string): Promise => { return ipcRenderer.invoke('open-external', url); }, diff --git a/ui/desktop/src/test/setup.ts b/ui/desktop/src/test/setup.ts index 4716c7ab..e2df9dd6 100644 --- a/ui/desktop/src/test/setup.ts +++ b/ui/desktop/src/test/setup.ts @@ -2,6 +2,24 @@ import '@testing-library/jest-dom'; import { vi, afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; +// Mock Electron modules before any imports +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((name: string) => { + if (name === 'userData') return '/tmp/test-user-data'; + if (name === 'temp') return '/tmp'; + if (name === 'home') return '/tmp/home'; + return '/tmp'; + }), + }, + ipcRenderer: { + invoke: vi.fn(), + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, +})); + // This is the standard setup to ensure that React Testing Library's // automatic cleanup runs after each test. afterEach(() => { @@ -23,3 +41,37 @@ Object.assign(navigator, { writeText: vi.fn(() => Promise.resolve()), }, }); + +// 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)), + showMessageBox: vi.fn(() => Promise.resolve({ response: 0 })), + }, +}); diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index 3eaf5e97..99f4971c 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -1,71 +1,67 @@ -import { app } from 'electron'; -import fs from 'fs'; -import path from 'path'; - -export interface EnvToggles { - GOOSE_SERVER__MEMORY: boolean; - GOOSE_SERVER__COMPUTER_CONTROLLER: boolean; -} - export interface ExternalGoosedConfig { enabled: boolean; url: string; secret: string; } +export interface KeyboardShortcuts { + focusWindow: string | null; + quickLauncher: string | null; + newChat: string | null; + newChatWindow: string | null; + openDirectory: string | null; + settings: string | null; + find: string | null; + findNext: string | null; + findPrevious: string | null; + alwaysOnTop: string | null; +} + +export type DefaultKeyboardShortcuts = { + [K in keyof KeyboardShortcuts]: string; +}; + export interface Settings { - envToggles: EnvToggles; showMenuBarIcon: boolean; showDockIcon: boolean; enableWakelock: boolean; spellcheckEnabled: boolean; externalGoosed?: ExternalGoosedConfig; + globalShortcut?: string | null; + keyboardShortcuts?: KeyboardShortcuts; } -const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json'); - -const defaultSettings: Settings = { - envToggles: { - GOOSE_SERVER__MEMORY: false, - GOOSE_SERVER__COMPUTER_CONTROLLER: false, - }, - showMenuBarIcon: true, - showDockIcon: true, - enableWakelock: false, - spellcheckEnabled: true, +export const defaultKeyboardShortcuts: DefaultKeyboardShortcuts = { + 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', }; -// Settings management -export function loadSettings(): Settings { - try { - if (fs.existsSync(SETTINGS_FILE)) { - const data = fs.readFileSync(SETTINGS_FILE, 'utf8'); - return JSON.parse(data); +export function getKeyboardShortcuts(settings: Settings): KeyboardShortcuts { + if (!settings.keyboardShortcuts && settings.globalShortcut !== undefined) { + const focusShortcut = settings.globalShortcut; + let launcherShortcut: string | null = null; + + if (focusShortcut) { + if (focusShortcut.includes('Shift')) { + launcherShortcut = focusShortcut; + } else { + launcherShortcut = focusShortcut.replace(/\+([Gg])$/, '+Shift+$1'); + } } - } catch (error) { - console.error('Error loading settings:', error); - } - return defaultSettings; -} -export function saveSettings(settings: Settings): void { - try { - fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); - } catch (error) { - console.error('Error saving settings:', error); - } -} - -export function updateEnvironmentVariables(envToggles: EnvToggles): void { - if (envToggles.GOOSE_SERVER__MEMORY) { - process.env.GOOSE_SERVER__MEMORY = 'true'; - } else { - delete process.env.GOOSE_SERVER__MEMORY; - } - - if (envToggles.GOOSE_SERVER__COMPUTER_CONTROLLER) { - process.env.GOOSE_SERVER__COMPUTER_CONTROLLER = 'true'; - } else { - delete process.env.GOOSE_SERVER__COMPUTER_CONTROLLER; + return { + ...defaultKeyboardShortcuts, + focusWindow: focusShortcut, + quickLauncher: launcherShortcut, + }; } + return settings.keyboardShortcuts || defaultKeyboardShortcuts; }