Keyboard short cuts (#6711)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user