···277277278278// ***** Caches *****
279279280280-// keyed on source address
280280+// keyed on shortcut string, value is source address (for global shortcuts)
281281const shortcuts = new Map();
282282283283+// keyed on shortcut string, value is { source, callback, replyTopic }
284284+// Local shortcuts only work when app has focus
285285+const localShortcuts = new Map();
286286+283287// app global prefs configurable by user
284288// populated during app init
285289let _prefs = {};
···290294class WindowManager {
291295 constructor() {
292296 this.windows = new Map();
293293-297297+294298 // Track window close events to clean up
295299 app.on('browser-window-created', (_, window) => {
296300 window.on('closed', () => {
297301 const windowId = window.id;
298302 const windowData = this.getWindow(windowId);
299299-303303+300304 // Notify subscribers that window was closed
301305 if (windowData) {
302306 pubsub.publish(windowData.source, scopes.GLOBAL, 'window:closed', {
···304308 source: windowData.source
305309 });
306310 }
307307-311311+308312 // Remove from window manager
309313 this.removeWindow(windowId);
314314+ });
315315+316316+ // Handle local shortcuts on all windows via before-input-event
317317+ window.webContents.on('before-input-event', (event, input) => {
318318+ if (handleLocalShortcut(input)) {
319319+ event.preventDefault();
320320+ }
310321 });
311322 });
312323 }
···652663 initTray();
653664 }
654665655655- // update quit shortcut if changed
666666+ // update quit shortcut if changed (local shortcut - only works when app has focus)
656667 const newQuitShortcut = msg.prefs.quitShortcut || strings.defaults.quitShortcut;
657668 if (newQuitShortcut !== _quitShortcut) {
658669 if (_quitShortcut) {
659670 console.log('unregistering old quit shortcut:', _quitShortcut);
660660- globalShortcut.unregister(_quitShortcut);
671671+ unregisterLocalShortcut(_quitShortcut);
661672 }
662673 console.log('registering new quit shortcut:', newQuitShortcut);
663663- registerShortcut(newQuitShortcut, onQuit);
674674+ registerLocalShortcut(newQuitShortcut, onQuit);
664675 _quitShortcut = newQuitShortcut;
665676 }
666677 });
···804815 };
805816 });
806817807807- // Register default quit shortcut - will be updated when prefs arrive
818818+ // Register default quit shortcut (local - only works when app has focus)
819819+ // Will be updated when prefs arrive
808820 _quitShortcut = strings.defaults.quitShortcut;
809809- registerShortcut(_quitShortcut, onQuit);
821821+ registerLocalShortcut(_quitShortcut, onQuit);
810822811823 // Mark app as ready and process any URLs that arrived during startup
812824 _appReady = true;
···862874});
863875864876ipcMain.on(strings.msgs.registerShortcut, (ev, msg) => {
865865- console.log('ipc register shortcut', msg);
877877+ const isGlobal = msg.global === true;
878878+ console.log('ipc register shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)');
866879867867- // record source of shortcut
868868- shortcuts.set(msg.shortcut, msg.source);
880880+ const callback = () => {
881881+ console.log('on(registershortcut): shortcut executed', msg.shortcut, msg.replyTopic);
882882+ ev.reply(msg.replyTopic, { foo: 'bar' });
883883+ };
869884870870- registerShortcut(msg.shortcut, () => {
871871- console.log('on(registershortcut): shortcut executed', msg.shortcut, msg.replyTopic)
872872- ev.reply(msg.replyTopic, { foo: 'bar' });
873873- });
885885+ if (isGlobal) {
886886+ // Global shortcut (works even when app doesn't have focus)
887887+ shortcuts.set(msg.shortcut, msg.source);
888888+ registerShortcut(msg.shortcut, callback);
889889+ } else {
890890+ // Local shortcut (only works when app has focus)
891891+ const parsed = parseShortcut(msg.shortcut);
892892+ localShortcuts.set(msg.shortcut, { source: msg.source, parsed, callback });
893893+ }
874894});
875895876896ipcMain.on(strings.msgs.unregisterShortcut, (ev, msg) => {
877877- console.log('ipc unregister shortcut', msg);
897897+ const isGlobal = msg.global === true;
898898+ console.log('ipc unregister shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)');
878899879879- unregisterShortcut(msg.shortcut, res => {
880880- console.log('ipc unregister shortcut callback result:', res);
881881- });
900900+ if (isGlobal) {
901901+ unregisterShortcut(msg.shortcut, res => {
902902+ console.log('ipc unregister global shortcut callback result:', res);
903903+ });
904904+ } else {
905905+ unregisterLocalShortcut(msg.shortcut);
906906+ }
882907});
883908884909ipcMain.on(strings.msgs.closeWindow, (ev, msg) => {
···18921917const unregisterShortcutsForAddress = (aAddress) => {
18931918 for (const [shortcut, address] of shortcuts) {
18941919 if (address == aAddress) {
18951895- console.log('unregistering', shortcut, 'for', address);
19201920+ console.log('unregistering global shortcut', shortcut, 'for', address);
18961921 unregisterShortcut(shortcut);
18971922 }
18981923 }
19241924+ // Also unregister local shortcuts for this address
19251925+ for (const [shortcut, data] of localShortcuts) {
19261926+ if (data.source === aAddress) {
19271927+ console.log('unregistering local shortcut', shortcut, 'for', aAddress);
19281928+ localShortcuts.delete(shortcut);
19291929+ }
19301930+ }
19311931+};
19321932+19331933+// Map key names to physical key codes (for before-input-event matching)
19341934+// Electron's input.code follows the USB HID spec
19351935+const keyToCode = {
19361936+ // Letters
19371937+ 'a': 'KeyA', 'b': 'KeyB', 'c': 'KeyC', 'd': 'KeyD', 'e': 'KeyE',
19381938+ 'f': 'KeyF', 'g': 'KeyG', 'h': 'KeyH', 'i': 'KeyI', 'j': 'KeyJ',
19391939+ 'k': 'KeyK', 'l': 'KeyL', 'm': 'KeyM', 'n': 'KeyN', 'o': 'KeyO',
19401940+ 'p': 'KeyP', 'q': 'KeyQ', 'r': 'KeyR', 's': 'KeyS', 't': 'KeyT',
19411941+ 'u': 'KeyU', 'v': 'KeyV', 'w': 'KeyW', 'x': 'KeyX', 'y': 'KeyY',
19421942+ 'z': 'KeyZ',
19431943+ // Numbers
19441944+ '0': 'Digit0', '1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4',
19451945+ '5': 'Digit5', '6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9',
19461946+ // Punctuation
19471947+ ',': 'Comma', '.': 'Period', '/': 'Slash', ';': 'Semicolon', "'": 'Quote',
19481948+ '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '`': 'Backquote',
19491949+ '-': 'Minus', '=': 'Equal',
19501950+ // Special keys
19511951+ 'enter': 'Enter', 'return': 'Enter',
19521952+ 'tab': 'Tab',
19531953+ 'space': 'Space', ' ': 'Space',
19541954+ 'backspace': 'Backspace',
19551955+ 'delete': 'Delete',
19561956+ 'escape': 'Escape', 'esc': 'Escape',
19571957+ 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', 'right': 'ArrowRight',
19581958+ 'arrowup': 'ArrowUp', 'arrowdown': 'ArrowDown', 'arrowleft': 'ArrowLeft', 'arrowright': 'ArrowRight',
19591959+ 'home': 'Home', 'end': 'End',
19601960+ 'pageup': 'PageUp', 'pagedown': 'PageDown',
19611961+ // Function keys
19621962+ 'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5', 'f6': 'F6',
19631963+ 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10', 'f11': 'F11', 'f12': 'F12',
19641964+};
19651965+19661966+// Parse shortcut string to match Electron's input event format
19671967+// e.g., 'Alt+Q' -> { alt: true, code: 'KeyQ' }
19681968+// e.g., 'CommandOrControl+Shift+P' -> { meta: true, shift: true, code: 'KeyP' } (on Mac)
19691969+const parseShortcut = (shortcut) => {
19701970+ const parts = shortcut.toLowerCase().split('+');
19711971+ const result = {
19721972+ ctrl: false,
19731973+ alt: false,
19741974+ shift: false,
19751975+ meta: false,
19761976+ code: ''
19771977+ };
19781978+19791979+ for (const part of parts) {
19801980+ const p = part.trim();
19811981+ if (p === 'ctrl' || p === 'control') {
19821982+ result.ctrl = true;
19831983+ } else if (p === 'alt' || p === 'option') {
19841984+ result.alt = true;
19851985+ } else if (p === 'shift') {
19861986+ result.shift = true;
19871987+ } else if (p === 'meta' || p === 'cmd' || p === 'command' || p === 'super') {
19881988+ result.meta = true;
19891989+ } else if (p === 'commandorcontrol' || p === 'cmdorctrl') {
19901990+ // On Mac, use meta (Cmd), on others use ctrl
19911991+ if (process.platform === 'darwin') {
19921992+ result.meta = true;
19931993+ } else {
19941994+ result.ctrl = true;
19951995+ }
19961996+ } else {
19971997+ // This is the key itself - convert to code
19981998+ result.code = keyToCode[p] || p;
19991999+ }
20002000+ }
20012001+20022002+ return result;
20032003+};
20042004+20052005+// Check if an input event matches a parsed shortcut
20062006+const inputMatchesShortcut = (input, parsed) => {
20072007+ // Check modifiers
20082008+ if (input.alt !== parsed.alt) return false;
20092009+ if (input.shift !== parsed.shift) return false;
20102010+ if (input.meta !== parsed.meta) return false;
20112011+ if (input.control !== parsed.ctrl) return false;
20122012+20132013+ // Check physical key code (case-insensitive comparison)
20142014+ return input.code.toLowerCase() === parsed.code.toLowerCase();
20152015+};
20162016+20172017+// Register a local (app-only) shortcut
20182018+const registerLocalShortcut = (shortcut, callback) => {
20192019+ console.log('registerLocalShortcut', shortcut);
20202020+20212021+ if (localShortcuts.has(shortcut)) {
20222022+ console.log('local shortcut already registered, replacing:', shortcut);
20232023+ }
20242024+20252025+ const parsed = parseShortcut(shortcut);
20262026+ localShortcuts.set(shortcut, { parsed, callback });
20272027+};
20282028+20292029+// Unregister a local shortcut
20302030+const unregisterLocalShortcut = (shortcut) => {
20312031+ console.log('unregisterLocalShortcut', shortcut);
20322032+20332033+ if (!localShortcuts.has(shortcut)) {
20342034+ console.error('local shortcut not registered:', shortcut);
20352035+ return;
20362036+ }
20372037+20382038+ localShortcuts.delete(shortcut);
20392039+};
20402040+20412041+// Handle local shortcuts from any focused window
20422042+// Called from before-input-event handler
20432043+const handleLocalShortcut = (input) => {
20442044+ // Only handle keyDown events
20452045+ if (input.type !== 'keyDown') return false;
20462046+20472047+ for (const [shortcut, data] of localShortcuts) {
20482048+ if (inputMatchesShortcut(input, data.parsed)) {
20492049+ data.callback();
20502050+ return true;
20512051+ }
20522052+ }
20532053+ return false;
18992054};
1900205519012056// Ask renderer to handle escape, returns Promise<{ handled: boolean }>
notes/.extensibility.md.swp
This is a binary file and will not be displayed.
+193
notes/shortcuts-api.md
···11+# Peek Shortcuts API
22+33+The shortcuts API allows features and extensions to register keyboard shortcuts that trigger callbacks.
44+55+## Overview
66+77+Peek supports two types of shortcuts:
88+99+- **Global shortcuts**: Work system-wide, even when the app doesn't have focus. Useful for invoking the app from other applications.
1010+- **Local shortcuts**: Only work when Peek has focus. Safer for actions that shouldn't be triggered accidentally from other apps.
1111+1212+By default, shortcuts are **local** (app-only). Pass `{ global: true }` to register a global shortcut.
1313+1414+## API Reference
1515+1616+### `api.shortcuts.register(shortcut, callback, options)`
1717+1818+Register a keyboard shortcut.
1919+2020+**Parameters:**
2121+2222+| Name | Type | Required | Description |
2323+|------|------|----------|-------------|
2424+| `shortcut` | string | Yes | Key combination (e.g., `'Alt+1'`, `'CommandOrControl+Shift+P'`) |
2525+| `callback` | function | Yes | Function to call when shortcut is triggered |
2626+| `options` | object | No | Configuration options |
2727+| `options.global` | boolean | No | If `true`, shortcut works system-wide. Default: `false` |
2828+2929+**Example:**
3030+3131+```javascript
3232+// Local shortcut (only works when app has focus)
3333+api.shortcuts.register('Alt+Q', () => {
3434+ console.log('Quit shortcut pressed');
3535+ api.quit();
3636+});
3737+3838+// Global shortcut (works even when app doesn't have focus)
3939+api.shortcuts.register('Alt+1', () => {
4040+ console.log('Opening peek 1');
4141+ openPeek(1);
4242+}, { global: true });
4343+```
4444+4545+### `api.shortcuts.unregister(shortcut, options)`
4646+4747+Unregister a previously registered shortcut.
4848+4949+**Parameters:**
5050+5151+| Name | Type | Required | Description |
5252+|------|------|----------|-------------|
5353+| `shortcut` | string | Yes | The shortcut to unregister |
5454+| `options` | object | No | Configuration options |
5555+| `options.global` | boolean | No | Must match the registration. Default: `false` |
5656+5757+**Example:**
5858+5959+```javascript
6060+// Unregister a local shortcut
6161+api.shortcuts.unregister('Alt+Q');
6262+6363+// Unregister a global shortcut
6464+api.shortcuts.unregister('Alt+1', { global: true });
6565+```
6666+6767+## Shortcut String Format
6868+6969+Shortcuts use Electron's accelerator format. Common modifiers:
7070+7171+| Modifier | Mac | Windows/Linux |
7272+|----------|-----|---------------|
7373+| `CommandOrControl` | Cmd | Ctrl |
7474+| `Command` / `Cmd` | Cmd | N/A |
7575+| `Control` / `Ctrl` | Ctrl | Ctrl |
7676+| `Alt` / `Option` | Option | Alt |
7777+| `Shift` | Shift | Shift |
7878+| `Meta` / `Super` | Cmd | Win |
7979+8080+**Examples:**
8181+8282+- `Alt+1` - Option/Alt + 1
8383+- `CommandOrControl+Shift+P` - Cmd+Shift+P (Mac) or Ctrl+Shift+P (Windows/Linux)
8484+- `Option+Up` - Option + Arrow Up
8585+- `Ctrl+Q` - Control + Q
8686+8787+## When to Use Global vs Local
8888+8989+### Use Global Shortcuts For:
9090+9191+- **Invoking the app** (e.g., opening peeks, slides, command palette)
9292+- **Quick access features** that users expect to work from any context
9393+- **Features explicitly configured** as system-wide by the user
9494+9595+### Use Local Shortcuts For:
9696+9797+- **Destructive actions** like quit - prevents accidental triggers from other apps
9898+- **Feature-internal navigation** that only makes sense when the app is active
9999+- **Context-sensitive actions** that depend on app state
100100+101101+## Implementation Details
102102+103103+### Global Shortcuts
104104+105105+Uses Electron's `globalShortcut` module. These shortcuts:
106106+107107+- Work even when the app doesn't have focus
108108+- Are registered at the OS level
109109+- May conflict with shortcuts from other applications
110110+- Are unregistered when the app quits
111111+112112+### Local Shortcuts
113113+114114+Uses Electron's `before-input-event` on each BrowserWindow. These shortcuts:
115115+116116+- Only work when a Peek window has focus
117117+- Are handled before the webpage receives the input
118118+- Cannot conflict with other applications
119119+- Are more secure for sensitive operations
120120+- Use physical key codes (not characters) for matching, so Option+, works correctly on Mac even though it produces '≤'
121121+122122+### Internal Architecture
123123+124124+```
125125+Renderer Process (preload.js)
126126+ │
127127+ │ api.shortcuts.register(shortcut, cb, { global: true/false })
128128+ │
129129+ ▼
130130+IPC: 'registershortcut' { shortcut, replyTopic, global }
131131+ │
132132+ ▼
133133+Main Process (index.js)
134134+ │
135135+ ├─ global=true ──▶ globalShortcut.register()
136136+ │ (stored in `shortcuts` Map)
137137+ │
138138+ └─ global=false ──▶ localShortcuts Map
139139+ (handled via before-input-event)
140140+```
141141+142142+## Feature Examples
143143+144144+### Peeks (Global)
145145+146146+Peeks use global shortcuts so users can quickly invoke them from any app:
147147+148148+```javascript
149149+api.shortcuts.register(`Option+${keyNum}`, () => {
150150+ openPeekWindow(item);
151151+}, { global: true });
152152+```
153153+154154+### Quit (Local)
155155+156156+The quit shortcut is local to prevent accidentally quitting the app:
157157+158158+```javascript
159159+api.shortcuts.register('Option+Q', () => {
160160+ app.quit();
161161+}); // No global flag - defaults to local
162162+```
163163+164164+## Best Practices
165165+166166+1. **Default to local** - Only use global shortcuts when necessary
167167+2. **Match register/unregister** - Pass the same `global` option to both calls
168168+3. **Clean up on uninit** - Always unregister shortcuts when a feature unloads
169169+4. **Track registered shortcuts** - Keep a list for cleanup:
170170+171171+```javascript
172172+let registeredShortcuts = [];
173173+174174+const init = () => {
175175+ const shortcut = 'Alt+1';
176176+ api.shortcuts.register(shortcut, callback, { global: true });
177177+ registeredShortcuts.push(shortcut);
178178+};
179179+180180+const uninit = () => {
181181+ registeredShortcuts.forEach(shortcut => {
182182+ api.shortcuts.unregister(shortcut, { global: true });
183183+ });
184184+ registeredShortcuts = [];
185185+};
186186+```
187187+188188+## Future Considerations
189189+190190+- **UI for viewing shortcuts**: Settings panel showing all registered shortcuts
191191+- **Conflict detection**: Warn when a shortcut is already registered
192192+- **User customization**: Allow users to rebind shortcuts
193193+- **Shortcut categories**: Group shortcuts by feature for better organization