Keyboard short cuts (#6711)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-01-27 14:51:22 -05:00
committed by GitHub
parent 5827ea4743
commit b5700461cc
8 changed files with 932 additions and 536 deletions
@@ -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({
<FileText className="h-4 w-4" />
Prompts
</TabsTrigger>
<TabsTrigger
value="keyboard"
className="flex gap-2"
data-testid="settings-keyboard-tab"
>
<Keyboard className="h-4 w-4" />
Keyboard
</TabsTrigger>
<TabsTrigger value="app" className="flex gap-2" data-testid="settings-app-tab">
<Monitor className="h-4 w-4" />
App
@@ -169,6 +179,13 @@ export default function SettingsView({
<PromptsSettingsSection />
</TabsContent>
<TabsContent
value="keyboard"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<KeyboardShortcutsSection />
</TabsContent>
<TabsContent
value="app"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
@@ -3,16 +3,7 @@ import { Switch } from '../../ui/switch';
import { Input } from '../../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import { AlertCircle } from 'lucide-react';
interface ExternalGoosedConfig {
enabled: boolean;
url: string;
secret: string;
}
interface Settings {
externalGoosed?: Partial<ExternalGoosedConfig>;
}
import { ExternalGoosedConfig } from '../../../utils/settings';
const DEFAULT_CONFIG: ExternalGoosedConfig = {
enabled: false,
@@ -20,11 +11,12 @@ const DEFAULT_CONFIG: ExternalGoosedConfig = {
secret: '',
};
function parseConfig(partial: Partial<ExternalGoosedConfig> | 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<void> => {
setIsSaving(true);
try {
const currentSettings = ((await window.electron.getSettings()) as Settings) || {};
const currentSettings = await window.electron.getSettings();
await window.electron.saveSettings({
...currentSettings,
externalGoosed: newConfig,
@@ -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<keyof KeyboardShortcuts>([
'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<KeyboardShortcuts | null>(null);
const [editingKey, setEditingKey] = useState<keyof KeyboardShortcuts | null>(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<string, ShortcutConfig[]>
);
if (!shortcuts) {
return <div>Loading...</div>;
}
return (
<div className="space-y-4 pr-4 pb-8 mt-1">
{showRestartNotice && (
<Card className="rounded-lg border-yellow-600/50 bg-yellow-600/10">
<CardContent className="pt-4 px-4 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="text-text-default text-sm font-medium mb-1">Restart Required</h3>
<p className="text-xs text-text-muted">
Changes to application shortcuts (like New Chat, Settings, etc.) require
restarting Goose to take effect. Global shortcuts (Focus Window, Quick Launcher)
work immediately.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setShowRestartNotice(false)}
className="text-xs shrink-0"
>
Dismiss
</Button>
</div>
</CardContent>
</Card>
)}
{Object.entries(groupedShortcuts).map(([category, configs]) => (
<Card key={category} className="rounded-lg">
<CardHeader className="pb-0">
<CardTitle>{categoryLabels[category as keyof typeof categoryLabels]}</CardTitle>
<CardDescription>
{categoryDescriptions[category as keyof typeof categoryDescriptions]}
</CardDescription>
</CardHeader>
<CardContent className="pt-4 space-y-4 px-4">
{configs.map((config) => {
const shortcut = shortcuts[config.key];
const isEditing = editingKey === config.key;
return (
<div key={config.key} className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-text-default text-xs">{config.label}</h3>
<p className="text-xs text-text-muted max-w-md mt-[2px]">
{config.description}
</p>
</div>
<div className="flex items-center gap-2">
{!isEditing ? (
<>
{shortcut ? (
<span className="text-xs font-mono px-2 py-1 bg-background-muted rounded min-w-[120px] text-center">
{formatShortcut(shortcut)}
</span>
) : (
<span className="text-xs text-text-muted min-w-[120px] text-center">
Disabled
</span>
)}
<Button
variant="secondary"
size="sm"
onClick={() => handleEdit(config.key)}
className="text-xs"
>
Change
</Button>
<Switch
checked={shortcut !== null}
onCheckedChange={(checked) => handleToggle(config.key, checked)}
variant="mono"
/>
</>
) : (
<ShortcutRecorder
value={shortcut || ''}
onSave={handleSave}
onCancel={handleCancel}
allShortcuts={shortcuts}
currentKey={config.key}
/>
)}
</div>
</div>
);
})}
</CardContent>
</Card>
))}
<Card className="rounded-lg">
<CardContent className="pt-4 px-4 pb-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-text-default text-sm font-medium">Reset to Defaults</h3>
<p className="text-xs text-text-muted max-w-md mt-[2px]">
Restore all keyboard shortcuts to their original configuration
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleResetToDefaults}
className="text-xs"
>
Reset All Shortcuts
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -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<string | null>(null);
const inputRef = useRef<HTMLDivElement>(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<string, string> = {
' ': '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 (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div
ref={inputRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={handleStartRecording}
className={`
text-xs font-mono px-3 py-2 rounded border
${
recording
? 'bg-background-default border-border-focus ring-1 ring-border-focus'
: conflict
? 'bg-background-muted border-yellow-600/50'
: 'bg-background-muted border-border-default cursor-pointer hover:border-border-focus'
}
focus:outline-none focus:ring-1 focus:ring-border-focus
w-64 text-center
`}
>
{recording ? (
<span className="text-text-muted animate-pulse">Press shortcut...</span>
) : displayShortcut ? (
<span className={conflict ? 'text-yellow-600' : 'text-text-default'}>
{displayShortcut}
</span>
) : capturedShortcut ? (
<span className={conflict ? 'text-yellow-600' : 'text-text-default'}>
{formatShortcut(capturedShortcut)}
</span>
) : (
<span className="text-text-muted">Click to record...</span>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={handleSave}
disabled={!capturedShortcut}
className="text-xs"
>
Save
</Button>
<Button variant="secondary" size="sm" onClick={handleCancel} className="text-xs">
Cancel
</Button>
</div>
{conflict && (
<div className="text-xs text-yellow-600 flex items-center gap-1">
<span></span>
<span>
This shortcut is already used by <strong>{getShortcutLabel(conflict)}</strong>. Saving
will reassign it to this action.
</span>
</div>
)}
</div>
);
}
+235 -449
View File
@@ -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<string> {
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<typeof loadSettings>): 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<string, string> // 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', () => {
+3 -18
View File
@@ -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<boolean>;
setDockIcon: (show: boolean) => Promise<boolean>;
getDockIconState: () => Promise<boolean>;
getSettings: () => Promise<unknown | null>;
saveSettings: (settings: unknown) => Promise<boolean>;
getSettings: () => Promise<Settings>;
saveSettings: (settings: Settings) => Promise<boolean>;
getSecretKey: () => Promise<string>;
getGoosedHostPort: () => Promise<string | null>;
setWakelock: (enable: boolean) => Promise<boolean>;
@@ -116,10 +111,6 @@ type ElectronAPI = {
useSystemTheme: boolean;
theme: string;
}) => void;
// Functions for image pasting
saveDataUrlToTemp: (dataUrl: string, uniqueId: string) => Promise<SaveDataUrlResponse>;
deleteTempFile: (filePath: string) => void;
// Function for opening external URLs securely
openExternal: (url: string) => Promise<void>;
// 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<SaveDataUrlResponse> => {
return ipcRenderer.invoke('save-data-url-to-temp', dataUrl, uniqueId);
},
deleteTempFile: (filePath: string): void => {
ipcRenderer.send('delete-temp-file', filePath);
},
openExternal: (url: string): Promise<void> => {
return ipcRenderer.invoke('open-external', url);
},
+52
View File
@@ -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 })),
},
});
+47 -51
View File
@@ -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;
}