experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(windows): add desktop window management features

+803 -100
+270
.agent-plan.md
··· 1 + # Desktop Windows Implementation Plan 2 + 3 + ## Overview 4 + Implement desktop window management features including titlebar controls, size/position persistence, pin controls, escape behavior, and animations. 5 + 6 + ## Current Architecture 7 + - **Window creation**: `backend/electron/ipc.ts:window-open` handler creates `BrowserWindow` instances 8 + - **Frontend API**: `window.app.window.open(url, options)` exposed via preload 9 + - **Window options**: `width`, `height`, `x`, `y`, `modal`, `keepLive`, `alwaysOnTop`, `resizable`, `transparent`, `frame` 10 + - **Default**: `frame: false` (frameless windows) 11 + - **Dragging**: `app/drag.js` - click-and-hold (300ms) then manual move via IPC 12 + - **Settings**: `app/config.js` (core prefs), `app/settings/settings.js` (UI) 13 + - **Animation**: `animate.js` exists but unused - window bounds animation 14 + 15 + --- 16 + 17 + ## Implementation Tasks 18 + 19 + ### 1. Title Bar Hide/Show Preference 20 + **Files to modify:** 21 + - `app/config.js` - Add `hideTitleBar` boolean pref (default: true) 22 + - `app/settings/settings.js` - Pref UI is auto-generated from schema, no changes needed 23 + - `backend/electron/ipc.ts` - Read pref when opening windows, set `frame` accordingly 24 + 25 + **Implementation:** 26 + ```javascript 27 + // app/config.js prefsSchema.properties 28 + "hideTitleBar": { 29 + "description": "Hide window title bars by default", 30 + "type": "boolean", 31 + "default": true 32 + } 33 + ``` 34 + 35 + In `ipc.ts:window-open`: 36 + - Get core prefs via pubsub or direct datastore read 37 + - Apply `frame: !hideTitleBar` unless explicitly overridden by `options.frame` 38 + 39 + --- 40 + 41 + ### 2. Titlebar Show on Hover at Top Edge 42 + **Files to create/modify:** 43 + - `app/titlebar-hover.js` - New file for titlebar hover detection 44 + - `backend/electron/ipc.ts` - Add `window-set-frame` IPC handler to toggle titlebar 45 + 46 + **Implementation:** 47 + - Create mouse event listener that detects when cursor is at screen top edge (y < 5px) 48 + - When hovering at top edge for 200ms, send IPC to enable frame temporarily 49 + - When cursor moves away from titlebar area, send IPC to hide frame again 50 + - This requires recreating the BrowserWindow or using `setAutoHideMenuBar` (limited) 51 + - **Alternative**: Use a custom titlebar overlay instead of native frame 52 + 53 + **Note**: Native frame cannot be toggled at runtime in Electron. Better approach: 54 + - Always use frameless windows 55 + - Create a custom titlebar component that shows on hover 56 + - Implement in each window's HTML or via content script injection 57 + 58 + --- 59 + 60 + ### 3. Fix Links Opening with Titlebars 61 + **Investigation needed:** 62 + - In `backend/electron/main.ts:setWindowOpenHandler`, check how `window.open()` from web content works 63 + - The handler uses `featuresMap` from window.open features string 64 + - If no frame feature specified, it may default differently 65 + 66 + **Fix:** 67 + - Ensure `frame: false` is explicitly set in `setWindowOpenHandler` options (line ~844) 68 + - Already has `...(featuresMap as Electron.BrowserWindowConstructorOptions)` - need to ensure default 69 + 70 + --- 71 + 72 + ### 4. Window Draggable API Option 73 + **Files to modify:** 74 + - `backend/electron/ipc.ts:window-open` - Store `draggable` param in window info 75 + - `app/drag.js` - Check window's draggable setting before enabling drag 76 + 77 + **Implementation:** 78 + ```javascript 79 + // window-open options 80 + draggable: true // default 81 + 82 + // In drag.js, check via IPC: 83 + const isDraggable = await window.app.invoke('window-is-draggable'); 84 + if (!isDraggable) return; 85 + ``` 86 + 87 + Add new IPC handler `window-is-draggable` that reads from window params. 88 + 89 + --- 90 + 91 + ### 5. Window Position/Size Persistence 92 + **Files to modify:** 93 + - `app/config.js` - Add `persistWindowState` boolean pref 94 + - `backend/electron/ipc.ts` - Save/restore window bounds based on key/URL 95 + - `backend/electron/datastore.ts` - Add window_state table or use extension_settings 96 + 97 + **Implementation:** 98 + 1. Add pref `persistWindowState` (default: false) 99 + 2. When window with `persistState: true` or `key` is created, check datastore for saved bounds 100 + 3. When window is moved/resized, save bounds (debounced) 101 + 4. Store in `extension_settings` table with namespace `window_state` 102 + 103 + ```javascript 104 + // Schema for window_state 105 + { 106 + key: string, // window key or URL hash 107 + x: number, 108 + y: number, 109 + width: number, 110 + height: number, 111 + lastUpdated: timestamp 112 + } 113 + ``` 114 + 115 + Add IPC handlers: 116 + - `window-state-save`: Save bounds for key 117 + - `window-state-load`: Load bounds for key 118 + 119 + Add to `window-open`: 120 + - If `persistState: true` and key exists, load saved bounds 121 + - On window move/resize, save (via 'moved'/'resized' events) 122 + 123 + --- 124 + 125 + ### 6. Pin Window Controls 126 + **Files to modify:** 127 + - `backend/electron/ipc.ts` - Add `window-set-always-on-top` IPC handler 128 + - `extensions/cmd/` - Add pin commands 129 + 130 + **Implementation:** 131 + 1. Add IPC handler: 132 + ```javascript 133 + ipcMain.handle('window-set-always-on-top', async (ev, msg) => { 134 + const win = BrowserWindow.fromId(msg.id); 135 + win.setAlwaysOnTop(msg.value, msg.level || 'normal'); 136 + // levels: 'normal', 'floating', 'torn-off-menu', 'modal-panel', 'main-menu', 'status', 'pop-up-menu', 'screen-saver' 137 + return { success: true }; 138 + }); 139 + ``` 140 + 141 + 2. Add commands: 142 + - `pin`: Toggle always-on-top for current window 143 + - `pin app`: Set alwaysOnTop with level 'floating' (above other app windows) 144 + - `pin os`: Set alwaysOnTop with level 'screen-saver' (above all windows) 145 + - `unpin`: Remove always-on-top 146 + 147 + --- 148 + 149 + ### 7. Configurable Escape Behavior 150 + **Already implemented** in `windows.ts:addEscHandler`: 151 + - Supports `escapeMode: 'close' | 'navigate' | 'auto'` 152 + 153 + **Documentation needed** - Update `docs/api.md` to document this option. 154 + 155 + **Add to window.open API** - Already works, just needs to be passed in options: 156 + ```javascript 157 + window.app.window.open(url, { escapeMode: 'navigate' }); 158 + ``` 159 + 160 + --- 161 + 162 + ### 8. Window Animation API 163 + **Files to modify:** 164 + - `backend/electron/ipc.ts` - Add animation support to window-open and new `window-animate` handler 165 + - Move `animate.js` logic into backend 166 + 167 + **Implementation:** 168 + 1. Add new IPC handler `window-animate`: 169 + ```javascript 170 + ipcMain.handle('window-animate', async (ev, msg) => { 171 + const { id, from, to, duration } = msg; 172 + const win = BrowserWindow.fromId(id); 173 + // Animate from {x,y,w,h} to {x,y,w,h} over duration ms 174 + // Use setInterval with ~10ms ticks 175 + }); 176 + ``` 177 + 178 + 2. Add animation options to window-open: 179 + ```javascript 180 + // Options 181 + { 182 + animation: { 183 + from: { x, y, width, height }, // Start position (optional, default: edge of screen) 184 + duration: 150 // ms 185 + } 186 + } 187 + ``` 188 + 189 + 3. Frontend API wrapper: 190 + ```javascript 191 + window.app.window.animate(windowId, { from, to, duration }); 192 + ``` 193 + 194 + --- 195 + 196 + ### 9. Update Slides to Use Animation 197 + **Files to modify:** 198 + - `extensions/slides/background.js` - Use animation API instead of direct positioning 199 + 200 + **Implementation:** 201 + In `executeItem()`: 202 + ```javascript 203 + // Instead of opening at final position, open at off-screen position 204 + // Then animate to final position 205 + const offScreen = calculateOffScreenPosition(item.screenEdge); 206 + const finalPos = { x, y, width, height }; 207 + 208 + const result = await api.window.open(item.address, { 209 + ...params, 210 + x: offScreen.x, 211 + y: offScreen.y, 212 + animation: { 213 + to: finalPos, 214 + duration: 150 215 + } 216 + }); 217 + ``` 218 + 219 + --- 220 + 221 + ## Implementation Order 222 + 223 + 1. **Phase 1: Core Window Options** (Low risk, foundation) 224 + - Fix links opening with titlebars 225 + - Window draggable API option 226 + - Document escape behavior 227 + 228 + 2. **Phase 2: Preferences** (Medium complexity) 229 + - Titlebar hide/show pref 230 + - Window persistence pref and implementation 231 + 232 + 3. **Phase 3: Pin Controls** (Standalone feature) 233 + - Add IPC handler 234 + - Add commands 235 + 236 + 4. **Phase 4: Titlebar Hover** (Complex, may need custom solution) 237 + - Investigate native vs custom approach 238 + - Implement chosen solution 239 + 240 + 5. **Phase 5: Animation** (Standalone feature) 241 + - Add animation IPC handler 242 + - Update slides extension 243 + 244 + --- 245 + 246 + ## Testing Strategy 247 + - Test each feature in isolation 248 + - Test with existing extensions (slides, peeks, cmd) 249 + - Test persistence across app restarts 250 + - Test animation smoothness 251 + - Test pin levels work correctly on macOS 252 + 253 + --- 254 + 255 + ## Files Summary 256 + 257 + ### New Files 258 + - `app/titlebar-hover.js` - Titlebar hover detection (if custom approach) 259 + 260 + ### Modified Files 261 + - `app/config.js` - Add prefs: `hideTitleBar`, `persistWindowState` 262 + - `backend/electron/ipc.ts` - Add handlers: `window-set-always-on-top`, `window-animate`, `window-state-save`, `window-state-load`, `window-is-draggable` 263 + - `backend/electron/main.ts` - Ensure `frame: false` default in setWindowOpenHandler 264 + - `app/drag.js` - Check draggable param 265 + - `extensions/slides/background.js` - Use animation API 266 + - `extensions/cmd/commands/` - Add pin commands 267 + - `docs/api.md` - Document new options 268 + 269 + ### Files to Remove 270 + - `animate.js` - Logic moved to backend
+1 -1
AGENTS.md
··· 9 9 **Railway (Server Deployment):** 10 10 - Project: `amusing-courtesy` 11 11 - Service: `peek-node` 12 - - URL: (set in .env as PEEK_PROD_URL) 12 + - URL: `https://peek-node.up.railway.app` 13 13 - Link command: `cd backend/server && railway link -p amusing-courtesy` 14 14 - Deploy: `cd backend/server && railway up -d` 15 15 - Logs: `railway logs -n 50`
+1 -1
DEVELOPMENT.md
··· 417 417 railway logs -n 50 418 418 419 419 # Health check 420 - curl $PEEK_PROD_URL/ 420 + curl https://peek-node.up.railway.app/ 421 421 ``` 422 422 423 423 **Deployment Order (Server + Mobile):**
+15 -15
TODO.md
··· 13 13 Today 14 14 - [~][release] build and deploy release versions of desktop, ios, and server 15 15 - [ ][workflow] agents need policy to never read outside workspace; spawned explore agents don't inherit policies 16 + - [ ][security] remove production server endpoint from source - should only be in .env files or user-entered 16 17 17 18 Later 18 19 - [ ][desktop] access to notes on filesystem, syncing them as markdown files in ~/sync/Notes/peek ··· 124 125 ## Desktop windows 125 126 126 127 Title bar and controls 127 - - [ ] add universal titlebar hide/show pref (default hide) 128 - - [ ] add pref to settings ui 128 + - [x] add universal titlebar hide/show pref (default hide) 129 + - [x] add pref to settings ui 129 130 - [ ] show titlebar on hover at top edge 130 - - [ ] why do some links in web pages open windows that show titlebars even when default is to hide? 131 + - [x] why do some links in web pages open windows that show titlebars even when default is to hide? 131 132 132 133 Size and position 133 - - [ ] windows are movable by default 134 - - [ ] windows are resizable by default 135 - - [ ] window.open api param for whether a window is draggable or not 136 - - [ ] window.open api param for whether a window is resizable or not 134 + - [x] windows are movable by default 135 + - [x] windows are resizable by default 136 + - [x] window.open api param for whether a window is draggable or not 137 + - [x] window.open api param for whether a window is resizable or not 137 138 138 139 Persistence 139 - - [ ] pref to persist keyed/url window position+size across app restarts 140 + - [x] pref to persist keyed/url window position+size across app restarts 140 141 141 142 Pin control in titlebar 142 - - [ ] pin window on top (app) 143 - - [ ] pin window on top (os) 144 - - [ ] cmds for all of this 143 + - [x] pin window on top (app) 144 + - [x] pin window on top (os) 145 + - [x] cmds for all of this 145 146 146 147 Interaction/integration 147 - - [ ] configurable escape behavior per-window, as a window.open api option 148 + - [x] configurable escape behavior per-window, as a window.open api option 148 149 149 150 Animations 150 - - [ ] add animation (to/from coords, time) option to window.open api 151 - - [ ] update slides impl to use animation (see ./animation.js, can remove when done) 151 + - [x] add animation (to/from coords, time) option to window.open api 152 + - [x] update slides impl to use animation (see ./animation.js, can remove when done) 152 153 153 154 ## UI Componentry 154 155 ··· 471 472 472 473 ### 2026-W04 473 474 474 - - [x][security] remove production server endpoint from source - should only be in .env files or user-entered 475 475 - [x][desktop] fix groups extension - add visit tracking, filter for URLs only 476 476 - [x][workflow] fix TODO archival - updated agent templates with clearer instructions 477 477 - [x][workflow] clarify ./app rule - now about respecting front-end/back-end architecture boundary
-56
animate.js
··· 1 - 2 - const animateWindow = (win, slide) => { 3 - return new Promise((res, rej) => { 4 - const { size, bounds } = screen.getPrimaryDisplay(); 5 - 6 - // get x/y field 7 - const coord = slide.screenEdge == 'Left' || slide.screenEdge == 'Right' ? 'x' : 'y'; 8 - 9 - const dim = coord == 'x' ? 'width' : 'height'; 10 - 11 - const winBounds = win.getBounds(); 12 - 13 - // created window at x/y taking animation into account 14 - let pos = winBounds[coord]; 15 - 16 - const speedMs = 150; 17 - const timerInterval = 10; 18 - 19 - let tick = 0; 20 - const numTicks = parseInt(speedMs / timerInterval); 21 - 22 - const offset = slide[dim] / numTicks; 23 - 24 - //console.log('numTicks', numTicks, 'widthChunk', offset); 25 - 26 - const timer = setInterval(() => { 27 - tick++; 28 - 29 - if (tick >= numTicks) { 30 - clearInterval(timer); 31 - res(); 32 - } 33 - 34 - const winBounds = win.getBounds(); 35 - 36 - if (slide.screenEdge == 'Right' || slide.screenEdge == 'Down') { 37 - // new position is current position +/- offset 38 - pos = pos - offset; 39 - } 40 - 41 - const grownEnough = winBounds[dim] <= slide[dim]; 42 - const newDim = grownEnough ? 43 - winBounds[dim] + offset 44 - : winBounds[dim]; 45 - 46 - const newBounds = {}; 47 - newBounds[coord] = parseInt(pos, 10); 48 - newBounds[dim] = parseInt(newDim, 10); 49 - 50 - // set new bounds 51 - win.setBounds(newBounds); 52 - 53 - }, timerInterval); 54 - }); 55 - }; 56 -
+3 -1
app/cmd/commands/index.js
··· 8 8 import noteModule from './note.js'; 9 9 import historyModule from './history.js'; 10 10 import tagModule from './tag.js'; 11 + import pinModule from './pin.js'; 11 12 12 13 console.log('tagModule.commands:', tagModule.commands?.map(c => c.name)); 13 14 ··· 31 32 modalCommand, 32 33 ...noteModule.commands, 33 34 ...historyModule.commands, 34 - ...tagModule.commands 35 + ...tagModule.commands, 36 + ...pinModule.commands 35 37 ]; 36 38 37 39 console.log('activeCommands:', activeCommands.map(c => c.name));
+91
app/cmd/commands/pin.js
··· 1 + /** 2 + * Pin commands - toggle always-on-top for windows 3 + * 4 + * Commands: 5 + * - pin: Toggle always-on-top for the target window (normal level) 6 + * - pin app: Pin window above other app windows (floating level) 7 + * - pin os: Pin window above all windows (screen-saver level) 8 + * - unpin: Remove always-on-top from the target window 9 + */ 10 + 11 + const api = window.app; 12 + 13 + /** 14 + * Get the target window ID (the last focused visible window, not cmd panel) 15 + */ 16 + async function getTargetWindowId() { 17 + return await api.invoke('get-focused-visible-window-id'); 18 + } 19 + 20 + export const pinCommand = { 21 + name: 'pin', 22 + description: 'Pin window on top (use "pin app" for above app, "pin os" for above all)', 23 + execute: async (msg) => { 24 + const typed = msg.typed || ''; 25 + const parts = typed.split(' ').filter(p => p.length > 0); 26 + parts.shift(); // Remove 'pin' 27 + 28 + const subcommand = parts[0]?.toLowerCase(); 29 + const windowId = await getTargetWindowId(); 30 + 31 + if (!windowId) { 32 + console.log('pin: No target window found'); 33 + return { success: false, error: 'No target window' }; 34 + } 35 + 36 + let level = 'normal'; 37 + if (subcommand === 'app') { 38 + level = 'floating'; // Above other app windows 39 + } else if (subcommand === 'os') { 40 + level = 'screen-saver'; // Above all windows 41 + } 42 + 43 + console.log(`pin: Setting window ${windowId} always-on-top with level: ${level}`); 44 + 45 + const result = await api.invoke('window-set-always-on-top', { 46 + id: windowId, 47 + value: true, 48 + level 49 + }); 50 + 51 + if (result.success) { 52 + console.log(`pin: Window ${windowId} pinned at level ${level}`); 53 + } else { 54 + console.error('pin: Failed to pin window:', result.error); 55 + } 56 + 57 + return result; 58 + } 59 + }; 60 + 61 + export const unpinCommand = { 62 + name: 'unpin', 63 + description: 'Remove pin (always-on-top) from window', 64 + execute: async () => { 65 + const windowId = await getTargetWindowId(); 66 + 67 + if (!windowId) { 68 + console.log('unpin: No target window found'); 69 + return { success: false, error: 'No target window' }; 70 + } 71 + 72 + console.log(`unpin: Removing always-on-top from window ${windowId}`); 73 + 74 + const result = await api.invoke('window-set-always-on-top', { 75 + id: windowId, 76 + value: false 77 + }); 78 + 79 + if (result.success) { 80 + console.log(`unpin: Window ${windowId} unpinned`); 81 + } else { 82 + console.error('unpin: Failed to unpin window:', result.error); 83 + } 84 + 85 + return result; 86 + } 87 + }; 88 + 89 + export default { 90 + commands: [pinCommand, unpinCommand] 91 + };
+13 -1
app/config.js
··· 54 54 "type": "string", 55 55 "default": "" 56 56 }, 57 + "hideTitleBar": { 58 + "description": "Hide window title bars by default (frameless windows)", 59 + "type": "boolean", 60 + "default": true 61 + }, 62 + "persistWindowState": { 63 + "description": "Remember window position and size for keyed windows across app restarts", 64 + "type": "boolean", 65 + "default": false 66 + }, 57 67 }, 58 68 "required": [ "shortcutKey", "startupFeature", "enableTrayIcon", "showInDockAndSwitcher", "quitShortcut" ] 59 69 }; ··· 123 133 startupFeature: 'peek://app/settings/settings.html', 124 134 showTrayIcon: true, 125 135 showInDockAndSwitcher: true, 126 - backupDir: '' 136 + backupDir: '', 137 + hideTitleBar: true, 138 + persistWindowState: false 127 139 }, 128 140 items: [ 129 141 { id: 'cee1225d-40ac-41e5-a34c-e2edba69d599',
+4
app/drag.js
··· 56 56 57 57 holdTimer = setTimeout(async () => { 58 58 try { 59 + // Check if window is draggable (API option can disable this) 60 + const draggableResult = await window.app.invoke('window-is-draggable'); 61 + if (!draggableResult?.draggable) return; 62 + 59 63 // Get window ID and position 60 64 windowId = await window.app.invoke('get-window-id'); 61 65 if (!windowId) return;
+285 -3
backend/electron/ipc.ts
··· 104 104 closeWindow, 105 105 modWindow, 106 106 getSystemThemeBackgroundColor, 107 + getPrefs, 107 108 } from './windows.js'; 108 109 109 110 import { ··· 1407 1408 } 1408 1409 } 1409 1410 1411 + // Determine frame setting based on explicit option or preference 1412 + // If frame is explicitly set in options, use that; otherwise use hideTitleBar pref 1413 + let frameDefault = false; // Default to frameless if pref not available 1414 + if (options.frame === undefined) { 1415 + const prefs = getPrefs(); 1416 + // hideTitleBar: true means frame: false (no titlebar) 1417 + // hideTitleBar: false means frame: true (show titlebar) 1418 + frameDefault = prefs.hideTitleBar === false; 1419 + } 1420 + 1410 1421 // Prepare browser window options 1411 - // Default to frameless windows unless explicitly requested 1412 1422 const winOptions: Electron.BrowserWindowConstructorOptions = { 1413 - frame: false, // Default to no titlebar 1423 + frame: frameDefault, // Default based on hideTitleBar pref 1414 1424 ...options, 1415 1425 width: parseInt(options.width) || APP_DEF_WIDTH, 1416 1426 height: parseInt(options.height) || APP_DEF_HEIGHT, ··· 1423 1433 } 1424 1434 }; 1425 1435 1426 - // Make sure position parameters are correctly handled 1436 + // Load saved window state if persistState is enabled and window has a key 1437 + let savedState: { x?: number; y?: number; width?: number; height?: number } | null = null; 1438 + if (options.key && (options.persistState === true || getPrefs().persistWindowState)) { 1439 + try { 1440 + const rowId = `window_state:${options.key}`; 1441 + const row = getRow('extension_settings', rowId); 1442 + if (row && row.value) { 1443 + savedState = JSON.parse(row.value as string); 1444 + DEBUG && console.log('Loaded saved window state for key:', options.key, savedState); 1445 + } 1446 + } catch (e) { 1447 + DEBUG && console.log('Failed to load window state:', e); 1448 + } 1449 + } 1450 + 1451 + // Apply saved state or explicit position parameters 1452 + if (savedState) { 1453 + if (savedState.x !== undefined) winOptions.x = savedState.x; 1454 + if (savedState.y !== undefined) winOptions.y = savedState.y; 1455 + if (savedState.width !== undefined) winOptions.width = savedState.width; 1456 + if (savedState.height !== undefined) winOptions.height = savedState.height; 1457 + } 1458 + // Explicit options override saved state 1427 1459 if (options.x !== undefined) { 1428 1460 winOptions.x = parseInt(options.x); 1429 1461 } ··· 1485 1517 trackContentWindowFocus(win); 1486 1518 if (!options.modal) { 1487 1519 trackVisibleWindowFocus(win); 1520 + } 1521 + 1522 + // Set up window state persistence if enabled 1523 + const shouldPersist = options.key && (options.persistState === true || getPrefs().persistWindowState); 1524 + if (shouldPersist) { 1525 + // Debounce timer for saving state 1526 + let saveTimer: NodeJS.Timeout | null = null; 1527 + const saveState = () => { 1528 + if (saveTimer) clearTimeout(saveTimer); 1529 + saveTimer = setTimeout(() => { 1530 + if (win.isDestroyed()) return; 1531 + const bounds = win.getBounds(); 1532 + const rowId = `window_state:${options.key}`; 1533 + const data = { 1534 + extensionId: 'window_state', 1535 + key: options.key, 1536 + value: JSON.stringify({ 1537 + x: bounds.x, 1538 + y: bounds.y, 1539 + width: bounds.width, 1540 + height: bounds.height 1541 + }), 1542 + updatedAt: Date.now() 1543 + }; 1544 + setRow('extension_settings', rowId, data); 1545 + DEBUG && console.log('Saved window state for key:', options.key, bounds); 1546 + }, 500); // Debounce 500ms 1547 + }; 1548 + 1549 + win.on('move', saveState); 1550 + win.on('resize', saveState); 1488 1551 } 1489 1552 1490 1553 // Set up DevTools if requested ··· 1766 1829 } 1767 1830 }); 1768 1831 1832 + // Set window always-on-top with level support 1833 + // Levels (macOS): 'normal', 'floating', 'torn-off-menu', 'modal-panel', 'main-menu', 'status', 'pop-up-menu', 'screen-saver' 1834 + // For cross-platform, use 'floating' for app-level and 'screen-saver' for OS-level 1835 + ipcMain.handle('window-set-always-on-top', async (ev, msg) => { 1836 + DEBUG && console.log('window-set-always-on-top', msg); 1837 + 1838 + try { 1839 + // If no ID provided, use the sender's window 1840 + let win: BrowserWindow | null = null; 1841 + if (msg?.id) { 1842 + win = BrowserWindow.fromId(msg.id); 1843 + } else { 1844 + win = BrowserWindow.fromWebContents(ev.sender); 1845 + } 1846 + 1847 + if (!win) { 1848 + return { success: false, error: 'Window not found' }; 1849 + } 1850 + 1851 + const value = msg.value !== false; // Default to true 1852 + const level = msg.level || 'normal'; // 'normal', 'floating', 'screen-saver', etc. 1853 + 1854 + // setAlwaysOnTop(flag, level, relativeLevel) 1855 + // relativeLevel is only used on macOS 1856 + if (process.platform === 'darwin') { 1857 + win.setAlwaysOnTop(value, level as any); 1858 + } else { 1859 + // On Windows/Linux, level is ignored but always-on-top still works 1860 + win.setAlwaysOnTop(value); 1861 + } 1862 + 1863 + return { success: true, alwaysOnTop: value, level }; 1864 + } catch (error) { 1865 + console.error('Failed to set always-on-top:', error); 1866 + const message = error instanceof Error ? error.message : String(error); 1867 + return { success: false, error: message }; 1868 + } 1869 + }); 1870 + 1871 + // Get window always-on-top state 1872 + ipcMain.handle('window-is-always-on-top', async (ev, msg) => { 1873 + DEBUG && console.log('window-is-always-on-top', msg); 1874 + 1875 + try { 1876 + let win: BrowserWindow | null = null; 1877 + if (msg?.id) { 1878 + win = BrowserWindow.fromId(msg.id); 1879 + } else { 1880 + win = BrowserWindow.fromWebContents(ev.sender); 1881 + } 1882 + 1883 + if (!win) { 1884 + return { success: false, error: 'Window not found' }; 1885 + } 1886 + 1887 + return { success: true, alwaysOnTop: win.isAlwaysOnTop() }; 1888 + } catch (error) { 1889 + const message = error instanceof Error ? error.message : String(error); 1890 + return { success: false, error: message }; 1891 + } 1892 + }); 1893 + 1894 + // Animate window bounds (position and/or size) 1895 + // Animates from current bounds (or specified 'from') to target bounds over duration 1896 + ipcMain.handle('window-animate', async (ev, msg) => { 1897 + DEBUG && console.log('window-animate', msg); 1898 + 1899 + try { 1900 + let win: BrowserWindow | null = null; 1901 + if (msg?.id) { 1902 + win = BrowserWindow.fromId(msg.id); 1903 + } else { 1904 + win = BrowserWindow.fromWebContents(ev.sender); 1905 + } 1906 + 1907 + if (!win) { 1908 + return { success: false, error: 'Window not found' }; 1909 + } 1910 + 1911 + const currentBounds = win.getBounds(); 1912 + const from = msg.from || currentBounds; 1913 + const to = msg.to; 1914 + const duration = msg.duration || 150; // ms 1915 + 1916 + if (!to) { 1917 + return { success: false, error: 'Target bounds (to) are required' }; 1918 + } 1919 + 1920 + // Calculate animation parameters 1921 + const startX = from.x ?? currentBounds.x; 1922 + const startY = from.y ?? currentBounds.y; 1923 + const startW = from.width ?? currentBounds.width; 1924 + const startH = from.height ?? currentBounds.height; 1925 + 1926 + const endX = to.x ?? startX; 1927 + const endY = to.y ?? startY; 1928 + const endW = to.width ?? startW; 1929 + const endH = to.height ?? startH; 1930 + 1931 + // Animation timing 1932 + const timerInterval = 10; // ms 1933 + const numTicks = Math.max(1, Math.floor(duration / timerInterval)); 1934 + let tick = 0; 1935 + 1936 + return new Promise((resolve) => { 1937 + const timer = setInterval(() => { 1938 + tick++; 1939 + 1940 + if (tick >= numTicks || win!.isDestroyed()) { 1941 + clearInterval(timer); 1942 + // Set final bounds 1943 + if (!win!.isDestroyed()) { 1944 + win!.setBounds({ x: endX, y: endY, width: endW, height: endH }); 1945 + } 1946 + resolve({ success: true }); 1947 + return; 1948 + } 1949 + 1950 + // Calculate progress (0 to 1) 1951 + const progress = tick / numTicks; 1952 + // Use easeOutQuad for smooth deceleration 1953 + const eased = 1 - (1 - progress) * (1 - progress); 1954 + 1955 + const x = Math.round(startX + (endX - startX) * eased); 1956 + const y = Math.round(startY + (endY - startY) * eased); 1957 + const w = Math.round(startW + (endW - startW) * eased); 1958 + const h = Math.round(startH + (endH - startH) * eased); 1959 + 1960 + win!.setBounds({ x, y, width: w, height: h }); 1961 + }, timerInterval); 1962 + }); 1963 + } catch (error) { 1964 + console.error('Failed to animate window:', error); 1965 + const message = error instanceof Error ? error.message : String(error); 1966 + return { success: false, error: message }; 1967 + } 1968 + }); 1969 + 1769 1970 ipcMain.handle('window-exists', async (_ev, msg) => { 1770 1971 DEBUG && console.log('window-exists', msg); 1771 1972 ··· 1864 2065 } 1865 2066 const [x, y] = win.getPosition(); 1866 2067 return { success: true, x, y }; 2068 + } catch (error) { 2069 + const message = error instanceof Error ? error.message : String(error); 2070 + return { success: false, error: message }; 2071 + } 2072 + }); 2073 + 2074 + // Check if window is draggable (default: true) 2075 + ipcMain.handle('window-is-draggable', (ev) => { 2076 + try { 2077 + const win = BrowserWindow.fromWebContents(ev.sender); 2078 + if (!win) { 2079 + return { success: false, error: 'Window not found' }; 2080 + } 2081 + const winInfo = getWindowInfo(win.id); 2082 + // Default to draggable if not specified 2083 + const draggable = winInfo?.params?.draggable !== false; 2084 + return { success: true, draggable }; 2085 + } catch (error) { 2086 + const message = error instanceof Error ? error.message : String(error); 2087 + return { success: false, error: message }; 2088 + } 2089 + }); 2090 + 2091 + // Save window state (position and size) for persistence 2092 + // Stored in extension_settings table with namespace 'window_state' 2093 + ipcMain.handle('window-state-save', async (_ev, msg) => { 2094 + DEBUG && console.log('window-state-save', msg); 2095 + 2096 + try { 2097 + const { key, x, y, width, height } = msg; 2098 + if (!key) { 2099 + return { success: false, error: 'Window key is required' }; 2100 + } 2101 + 2102 + // Check if persistence is enabled 2103 + const prefs = getPrefs(); 2104 + if (!prefs.persistWindowState) { 2105 + return { success: false, error: 'Window state persistence is disabled' }; 2106 + } 2107 + 2108 + const rowId = `window_state:${key}`; 2109 + const data = { 2110 + extensionId: 'window_state', 2111 + key, 2112 + value: JSON.stringify({ x, y, width, height }), 2113 + updatedAt: Date.now() 2114 + }; 2115 + 2116 + setRow('extension_settings', rowId, data); 2117 + return { success: true }; 2118 + } catch (error) { 2119 + const message = error instanceof Error ? error.message : String(error); 2120 + return { success: false, error: message }; 2121 + } 2122 + }); 2123 + 2124 + // Load window state for a given key 2125 + ipcMain.handle('window-state-load', async (_ev, msg) => { 2126 + DEBUG && console.log('window-state-load', msg); 2127 + 2128 + try { 2129 + const { key } = msg; 2130 + if (!key) { 2131 + return { success: false, error: 'Window key is required' }; 2132 + } 2133 + 2134 + // Check if persistence is enabled 2135 + const prefs = getPrefs(); 2136 + if (!prefs.persistWindowState) { 2137 + return { success: false, data: null }; 2138 + } 2139 + 2140 + const rowId = `window_state:${key}`; 2141 + const row = getRow('extension_settings', rowId); 2142 + 2143 + if (!row || !row.value) { 2144 + return { success: true, data: null }; 2145 + } 2146 + 2147 + const state = JSON.parse(row.value as string); 2148 + return { success: true, data: state }; 1867 2149 } catch (error) { 1868 2150 const message = error instanceof Error ? error.message : String(error); 1869 2151 return { success: false, error: message };
+11 -1
backend/electron/main.ts
··· 16 16 import { registerLocalShortcut, unregisterLocalShortcut, handleLocalShortcut, registerGlobalShortcut, unregisterGlobalShortcut, unregisterShortcutsForAddress } from './shortcuts.js'; 17 17 import { scopes, publish, subscribe, setExtensionBroadcaster, getSystemAddress } from './pubsub.js'; 18 18 import { APP_DEF_WIDTH, APP_DEF_HEIGHT, WEB_CORE_ADDRESS, getPreloadPath, isTestProfile, isDevProfile, isHeadless, getProfile, DEBUG } from './config.js'; 19 - import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor } from './windows.js'; 19 + import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor, getPrefs } from './windows.js'; 20 20 21 21 // Configuration 22 22 export interface AppConfig { ··· 840 840 } 841 841 } 842 842 843 + // Determine frame setting based on explicit option or preference 844 + let frameDefault = false; // Default to frameless if pref not available 845 + if (featuresMap.frame === undefined) { 846 + const prefs = getPrefs(); 847 + // hideTitleBar: true means frame: false (no titlebar) 848 + // hideTitleBar: false means frame: true (show titlebar) 849 + frameDefault = prefs.hideTitleBar === false; 850 + } 851 + 843 852 // Prepare browser window options 844 853 const winOptions: Electron.BrowserWindowConstructorOptions = { 854 + frame: frameDefault, // Default based on hideTitleBar pref 845 855 ...(featuresMap as Electron.BrowserWindowConstructorOptions), 846 856 width: parseInt(String(featuresMap.width)) || APP_DEF_WIDTH, 847 857 height: parseInt(String(featuresMap.height)) || APP_DEF_HEIGHT,
+2 -2
backend/electron/sync.ts
··· 103 103 // If query fails, fall through to defaults 104 104 } 105 105 106 - // Fall back to env var (empty string disables sync) 107 - return process.env.SYNC_SERVER_URL || ''; 106 + // Fall back to env var or default 107 + return process.env.SYNC_SERVER_URL || 'https://peek-node.up.railway.app'; 108 108 } 109 109 110 110 /**
+7
backend/electron/windows.ts
··· 42 42 } 43 43 44 44 /** 45 + * Get current preferences 46 + */ 47 + export function getPrefs(): Record<string, unknown> { 48 + return _getPrefs(); 49 + } 50 + 51 + /** 45 52 * Modify window state (close, hide, show) 46 53 */ 47 54 export function modWindow(bw: BrowserWindow, params: { action: string }): void {
+1 -1
backend/server/.env.example
··· 6 6 7 7 # Production testing 8 8 PEEK_PROD_KEY=your-production-api-key 9 - PEEK_PROD_URL=https://your-server.railway.app 9 + PEEK_PROD_URL=https://peek-node.up.railway.app
+1 -7
backend/server/test-api.js
··· 24 24 BASE_URL = "http://localhost:3000"; 25 25 API_KEY = process.env.PEEK_LOCAL_KEY; 26 26 } else if (isProd) { 27 - BASE_URL = process.env.PEEK_PROD_URL; 27 + BASE_URL = process.env.PEEK_PROD_URL || "https://peek-node.up.railway.app"; 28 28 API_KEY = process.env.PEEK_PROD_KEY; 29 29 } else { 30 30 // Fallback to legacy env vars for backwards compatibility 31 31 BASE_URL = process.env.BASE_URL || "http://localhost:3000"; 32 32 API_KEY = process.env.API_KEY; 33 - } 34 - 35 - if (isProd && !BASE_URL) { 36 - console.error("ERROR: PEEK_PROD_URL not set for production tests"); 37 - console.error("Set PEEK_PROD_URL in .env or environment"); 38 - process.exit(1); 39 33 } 40 34 41 35 if (!API_KEY) {
+4 -7
backend/tests/sync-e2e-prod.test.js
··· 25 25 const __dirname = dirname(fileURLToPath(import.meta.url)); 26 26 27 27 // Configuration from environment 28 - const PROD_URL = process.env.PEEK_PROD_URL; 28 + const PROD_URL = process.env.PEEK_PROD_URL || 'https://peek-node.up.railway.app'; 29 29 const PROD_KEY = process.env.PEEK_PROD_KEY; 30 30 31 31 // Test marker for this run - used for identification and cleanup ··· 436 436 console.log(''); 437 437 438 438 // Check for required env vars 439 - if (!PROD_URL || !PROD_KEY) { 440 - console.error('ERROR: Required environment variables missing'); 441 - if (!PROD_URL) console.error(' - PEEK_PROD_URL not set'); 442 - if (!PROD_KEY) console.error(' - PEEK_PROD_KEY not set'); 439 + if (!PROD_KEY) { 440 + console.error('ERROR: PEEK_PROD_KEY environment variable is required'); 443 441 console.error(''); 444 - console.error('Set them with:'); 445 - console.error(' export PEEK_PROD_URL=https://your-server.railway.app'); 442 + console.error('Set it with:'); 446 443 console.error(' export PEEK_PROD_KEY=your-api-key'); 447 444 console.error(''); 448 445 process.exit(1);
+57 -1
docs/api.md
··· 61 61 alwaysOnTop: false, // Stay on top 62 62 visible: true, // Initially visible 63 63 resizable: true, // Allow resize 64 - keepLive: false // Keep window alive when closed 64 + draggable: true, // Allow click-and-hold drag (default: true) 65 + keepLive: false, // Keep window alive when closed 66 + escapeMode: 'close' // ESC key behavior: 'close', 'navigate', or 'auto' 65 67 }); 66 68 // Returns: { success: true, id: 'window_label' } 67 69 ``` ··· 119 121 ```javascript 120 122 const result = await window.app.window.exists('settings'); 121 123 // Returns: { success: true, data: true } 124 + ``` 125 + 126 + ### `window.app.invoke('window-animate', options)` 127 + 128 + Animate a window's position and/or size. 129 + 130 + ```javascript 131 + // Animate to new position 132 + await window.app.invoke('window-animate', { 133 + id: windowId, // Window ID (optional, defaults to current) 134 + to: { x: 100, y: 100 }, // Target bounds (required) 135 + duration: 150 // Animation duration in ms (default: 150) 136 + }); 137 + 138 + // Animate from specific position 139 + await window.app.invoke('window-animate', { 140 + id: windowId, 141 + from: { x: 0, y: -600 }, // Starting bounds (optional, defaults to current) 142 + to: { x: 0, y: 0, width: 800, height: 600 }, 143 + duration: 200 144 + }); 145 + // Uses easeOutQuad easing for smooth deceleration 146 + ``` 147 + 148 + ### `window.app.invoke('window-set-always-on-top', options)` 149 + 150 + Pin a window to stay on top of other windows. 151 + 152 + ```javascript 153 + // Pin with normal level 154 + await window.app.invoke('window-set-always-on-top', { 155 + id: windowId, 156 + value: true 157 + }); 158 + 159 + // Pin above other app windows (macOS) 160 + await window.app.invoke('window-set-always-on-top', { 161 + id: windowId, 162 + value: true, 163 + level: 'floating' 164 + }); 165 + 166 + // Pin above all windows (macOS) 167 + await window.app.invoke('window-set-always-on-top', { 168 + id: windowId, 169 + value: true, 170 + level: 'screen-saver' 171 + }); 172 + 173 + // Unpin 174 + await window.app.invoke('window-set-always-on-top', { 175 + id: windowId, 176 + value: false 177 + }); 122 178 ``` 123 179 124 180 ---
+37 -3
extensions/slides/background.js
··· 124 124 } 125 125 126 126 function openNewSlide() { 127 + // Calculate off-screen starting position for animation 128 + let startX = x, startY = y; 129 + const animationDuration = 150; // ms 130 + 131 + switch(item.screenEdge) { 132 + case 'Up': 133 + startY = -height; // Start above screen 134 + break; 135 + case 'Down': 136 + startY = screen.height; // Start below screen 137 + break; 138 + case 'Left': 139 + startX = -width; // Start left of screen 140 + break; 141 + case 'Right': 142 + startX = screen.width; // Start right of screen 143 + break; 144 + } 145 + 127 146 const params = { 128 147 address: item.address, 129 148 height, ··· 138 157 keepLive: item.keepLive || false, 139 158 persistState: item.persistState || false, 140 159 141 - x, 142 - y, 160 + // Start at off-screen position for animation 161 + x: startX, 162 + y: startY, 143 163 144 164 // tracking 145 165 trackingSource: 'slide', ··· 147 167 title: item.title || '' 148 168 }; 149 169 150 - api.window.open(item.address, params).then(result => { 170 + api.window.open(item.address, params).then(async result => { 151 171 if (result.success) { 152 172 console.log('[ext:slides] Successfully opened slide with ID:', result.id); 153 173 slideWindows.set(key, result.id); 174 + 175 + // Animate to final position 176 + if (startX !== x || startY !== y) { 177 + try { 178 + await api.invoke('window-animate', { 179 + id: result.id, 180 + to: { x, y, width, height }, 181 + duration: animationDuration 182 + }); 183 + console.log('[ext:slides] Animation complete'); 184 + } catch (err) { 185 + console.error('[ext:slides] Animation failed:', err); 186 + } 187 + } 154 188 } else { 155 189 console.error('[ext:slides] Failed to open slide:', result.error); 156 190 }