experiments in a post-browser web
10
fork

Configure Feed

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

fix(hud): use showInactive() to prevent Space switching and startup flash

The did-become-active handler called win.show() on HUD windows, which
triggers activateIgnoringOtherApps:YES at the native level. This caused
two issues: (1) opening cmd palette on a non-primary macOS Space would
switch back to the original Space, and (2) HUD windows flashed during
startup as multiple activation events fired.

Using showInactive() restores HUD visibility without activating the app,
which avoids the Space switch and the startup flash.

+292 -1
+1 -1
backend/electron/main.ts
··· 196 196 // Show alwaysOnTop overlay windows (e.g. HUD) when app regains focus 197 197 for (const win of BrowserWindow.getAllWindows()) { 198 198 if (!win.isDestroyed() && win.isAlwaysOnTop() && (win as any).__hudHidden) { 199 - win.show(); 199 + win.showInactive(); 200 200 (win as any).__hudHidden = false; 201 201 } 202 202 }
+291
docs/macos-spaces-analysis.md
··· 1 + # macOS Spaces Switching: Root Cause Analysis 2 + 3 + ## Problem Statement 4 + 5 + When the user is on macOS Desktop Space 2 (or any Space other than where the Peek app was 6 + initially started) and triggers the cmd palette via the Alt+Space global shortcut, macOS switches 7 + them back to the original Space where the app started. 8 + 9 + A previous fix attempted to add `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })` 10 + to modal windows and reused keepLive windows, but this did NOT fix the issue. 11 + 12 + --- 13 + 14 + ## 1. Exact Call Chain from Alt+Space Keypress to Window Display 15 + 16 + ### Step 1: Global Shortcut Registration 17 + 18 + In `/Users/dietrich/misc/mpeek/extensions/cmd/background.js`, the `initShortcut()` function 19 + (line 396) registers a global shortcut via the extension API: 20 + 21 + ```js 22 + api.shortcuts.register(prefs.shortcutKey, () => { 23 + openPanelWindow(prefs); 24 + }, { global: true }); 25 + ``` 26 + 27 + The default shortcut key is `Option+Space` (from `config.js` line 32). 28 + 29 + This calls through to `/Users/dietrich/misc/mpeek/backend/electron/shortcuts.ts` line 174: 30 + 31 + ```ts 32 + const ret = globalShortcut.register(shortcut, () => { 33 + callback(); 34 + }); 35 + ``` 36 + 37 + The callback is a plain function -- no `app.focus()` or `app.show()` is injected here. The 38 + callback simply invokes `openPanelWindow(prefs)`. 39 + 40 + ### Step 2: openPanelWindow calls api.window.open 41 + 42 + In `background.js` line 332-388, `openPanelWindow()` calls: 43 + 44 + ```js 45 + api.window.open(panelAddress, params); 46 + ``` 47 + 48 + With these params: 49 + - `key: panelAddress` (enables keepLive reuse) 50 + - `keepLive: true` 51 + - `modal: true` 52 + - `type: 'panel'` 53 + - `alwaysOnTop: true` 54 + - `transparent: true` 55 + - `frame: false` 56 + - `center: true` 57 + - `role: 'palette'` 58 + 59 + ### Step 3: IPC handler 'window-open' in main process 60 + 61 + In `/Users/dietrich/misc/mpeek/backend/electron/ipc.ts` line 2139, the `window-open` IPC 62 + handler fires. 63 + 64 + **FIRST INVOCATION (cold start -- window doesn't exist yet):** 65 + 66 + The handler reaches line 2305-2310 which sets `type: 'panel'` for modal windows on macOS: 67 + 68 + ```ts 69 + if (options.modal === true) { 70 + if (process.platform === 'darwin') { 71 + winOptions.type = 'panel'; 72 + } 73 + } 74 + ``` 75 + 76 + Then at line 2365, `new BrowserWindow(winOptions)` is called. After creation, at line 2371-2374: 77 + 78 + ```ts 79 + if (options.modal === true && process.platform === 'darwin') { 80 + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); 81 + } 82 + ``` 83 + 84 + The window is created with `show: true` (line 2234, since `options.show !== false` and not headless), so `BrowserWindow` constructor calls native `Show()`. 85 + 86 + **SUBSEQUENT INVOCATIONS (keepLive reuse -- window already exists):** 87 + 88 + The handler reaches line 2172-2198: 89 + 90 + ```ts 91 + const existingWindow = findWindowByKey(msg.source, options.key); 92 + if (existingWindow) { 93 + // ... 94 + if (process.platform === 'darwin' && (options.modal === true || options.alwaysOnTop === true)) { 95 + existingWindow.window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); 96 + } 97 + existingWindow.window.show(); // <--- LINE 2194 98 + existingWindow.window.focus(); // <--- LINE 2195 99 + return { success: true, id: existingWindow.id, reused: true }; 100 + } 101 + ``` 102 + 103 + --- 104 + 105 + ## 2. Every show(), focus(), and app.focus() Call in the Chain 106 + 107 + ### Direct calls in the cmd palette flow: 108 + 109 + | Call | Location | When | 110 + |------|----------|------| 111 + | `new BrowserWindow({ show: true })` | ipc.ts:2365 | First open (cold) | 112 + | `win.setVisibleOnAllWorkspaces(true)` | ipc.ts:2372 | First open (cold) | 113 + | `existingWindow.window.setVisibleOnAllWorkspaces(true)` | ipc.ts:2192 | Reuse (warm) | 114 + | `existingWindow.window.show()` | ipc.ts:2194 | Reuse (warm) | 115 + | `existingWindow.window.focus()` | ipc.ts:2195 | Reuse (warm) | 116 + 117 + ### Indirect calls triggered as a side effect: 118 + 119 + | Call | Location | When | 120 + |------|----------|------| 121 + | `win.show()` for HUD windows | main.ts:199 | `did-become-active` handler shows HUD overlays | 122 + 123 + There are **no** `app.focus()` or `app.show()` calls anywhere in the codebase. 124 + 125 + --- 126 + 127 + ## 3. Which Specific Call Triggers the Space Switch 128 + 129 + ### The Root Cause: `BrowserWindow.show()` and `BrowserWindow.focus()` call native macOS activation 130 + 131 + Looking at Electron's native macOS implementation (`native_window_mac.mm`), the `Show()` and 132 + `Focus()` methods behave differently for panel vs. non-panel windows: 133 + 134 + **For NON-panel windows:** 135 + ```objc 136 + // Show(): 137 + [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; 138 + [window_ makeKeyAndOrderFront:nil]; 139 + 140 + // Focus(): 141 + [[NSApplication sharedApplication] activateIgnoringOtherApps:NO]; 142 + [window_ makeKeyAndOrderFront:nil]; 143 + ``` 144 + 145 + **For panel windows:** 146 + ```objc 147 + // Show() and Focus(): 148 + [window_ makeKeyAndOrderFront:nil]; 149 + // NO activateIgnoringOtherApps call 150 + ``` 151 + 152 + The cmd palette IS created as a panel window (`type: 'panel'`), which means its `show()` and 153 + `focus()` calls should NOT trigger `activateIgnoringOtherApps`. This is why the previous fix 154 + of adding `setVisibleOnAllWorkspaces` did not work -- the problem is upstream of the cmd palette 155 + window itself. 156 + 157 + ### The Actual Trigger: The `did-become-active` Handler 158 + 159 + Here is the critical sequence of events: 160 + 161 + 1. User is on Space 2. Peek app's windows (background, extension host, etc.) are on Space 1. 162 + 163 + 2. User presses Alt+Space. The `globalShortcut` callback fires in the main process. 164 + 165 + 3. The callback calls `openPanelWindow()` which sends an IPC to `window-open`. 166 + 167 + 4. `window-open` calls `existingWindow.window.show()` on the cmd palette (a panel window). 168 + 169 + 5. **On Electron 40.x** (the version used: `"electron": "^40.0.0"`), the panel window's 170 + `show()` does NOT call `activateIgnoringOtherApps`. However, calling `show()` on 171 + ANY BrowserWindow still causes macOS to deliver an `NSApplicationDidBecomeActiveNotification` 172 + if the app was not already the active app, because ordering a window to front (`makeKeyAndOrderFront`) 173 + can trigger app activation at the macOS level. 174 + 175 + 6. The `did-become-active` handler in `main.ts` (line 193-202) fires: 176 + ```ts 177 + (app as any).on('did-become-active', () => { 178 + // ... 179 + for (const win of BrowserWindow.getAllWindows()) { 180 + if (!win.isDestroyed() && win.isAlwaysOnTop() && (win as any).__hudHidden) { 181 + win.show(); // <--- Shows HUD window! 182 + (win as any).__hudHidden = false; 183 + } 184 + } 185 + }); 186 + ``` 187 + 188 + 7. **This shows the HUD window, which is NOT a panel window** (it's created as a normal 189 + BrowserWindow with `alwaysOnTop: true, focusable: false`). Even though it's non-focusable, 190 + calling `show()` on a non-panel BrowserWindow triggers `activateIgnoringOtherApps:YES` 191 + in the native code, which forces macOS to switch to the Space where that window was created. 192 + 193 + ### BUT: Even without the HUD, there's a deeper issue 194 + 195 + Even if the HUD window were not shown, there is a fundamental problem: 196 + 197 + **`setVisibleOnAllWorkspaces(true)` uses `NSWindowCollectionBehaviorCanJoinAllSpaces`**, which 198 + makes the window VISIBLE on all Spaces but does NOT prevent Space switching when the window 199 + is activated. What is actually needed is **`NSWindowCollectionBehaviorMoveToActiveSpace`**, which 200 + tells macOS: "when this window becomes active, move it to the currently active Space instead 201 + of switching the user to the window's original Space." 202 + 203 + Electron's `setVisibleOnAllWorkspaces(true)` does NOT set `NSWindowCollectionBehaviorMoveToActiveSpace`. 204 + This is a known limitation documented in [Electron issue #8734](https://github.com/electron/electron/issues/8734). 205 + 206 + ### Summary of triggers (in order of severity): 207 + 208 + 1. **Primary trigger**: `existingWindow.window.show()` + `existingWindow.window.focus()` on 209 + the keepLive cmd palette window (ipc.ts lines 2194-2195). Even though it's a panel window 210 + and skips `activateIgnoringOtherApps`, `makeKeyAndOrderFront` can still trigger app activation 211 + at the macOS level, which causes Space switching. 212 + 213 + 2. **Secondary trigger**: The `did-become-active` handler calling `win.show()` on the HUD 214 + window (main.ts line 199). This is a non-panel window, so its `show()` calls 215 + `activateIgnoringOtherApps:YES`, which definitively forces a Space switch. 216 + 217 + 3. **Underlying issue**: `setVisibleOnAllWorkspaces` uses `CanJoinAllSpaces` but NOT 218 + `MoveToActiveSpace`. Even with `visibleOnAllWorkspaces: true`, showing/focusing a window 219 + can still cause macOS to switch to its original Space. 220 + 221 + --- 222 + 223 + ## 4. What the Internet Says About This Issue 224 + 225 + ### Electron Issue #8734: window.show() on current desktop 226 + [https://github.com/electron/electron/issues/8734](https://github.com/electron/electron/issues/8734) 227 + 228 + The canonical issue. Users report that `setVisibleOnAllWorkspaces` makes windows visible on 229 + all desktops, but `window.show()` still switches to the desktop where the window was initially 230 + created. The issue was closed as duplicate of #5362 (Add workspace API) which remains open. 231 + 232 + **Key workaround discussed**: Use `NSWindowCollectionBehaviorMoveToActiveSpace` instead of (or 233 + in addition to) `NSWindowCollectionBehaviorCanJoinAllSpaces`. Electron does not expose this 234 + flag through its API. 235 + 236 + ### Electron Issue #29644: focusable:false BrowserWindow still makes macOS try to focus it 237 + [https://github.com/electron/electron/issues/29644](https://github.com/electron/electron/issues/29644) 238 + 239 + Confirms that even `focusable: false` windows cause Space switching on macOS. When the Electron 240 + app gets focus and a window is not focusable, the focus is given to another Electron window, 241 + causing a Space ping-pong effect. 242 + 243 + ### Electron PR #40307: Do not activate app when calling focus on inactive panel window 244 + [https://github.com/electron/electron/pull/40307](https://github.com/electron/electron/pull/40307) 245 + 246 + Fixed in Electron 28+. Panel windows no longer call `activateIgnoringOtherApps` when focused. 247 + This is already in effect for this project (Electron 40). However, this only prevents the 248 + panel's own `focus()` from activating the app -- it does NOT prevent app activation from other 249 + sources (like `makeKeyAndOrderFront` or other non-panel windows being shown). 250 + 251 + ### Electron Issue #36364: setAlwaysOnTop + setVisibleOnAllWorkspaces + visibleOnFullScreen not working 252 + [https://github.com/electron/electron/issues/36364](https://github.com/electron/electron/issues/36364) 253 + 254 + The combination of `setAlwaysOnTop`, `setVisibleOnAllWorkspaces`, and `visibleOnFullScreen` 255 + does not work reliably unless the window is manually focused by the user. 256 + 257 + ### VSCode Issue #90680: VSCode stealing focus / switching macOS spaces 258 + [https://github.com/microsoft/vscode/issues/90680](https://github.com/microsoft/vscode/issues/90680) 259 + 260 + The same problem affects VSCode, confirming this is a systemic Electron limitation on macOS. 261 + 262 + ### nswindow-napi package 263 + [https://www.npmjs.com/package/nswindow-napi](https://www.npmjs.com/package/nswindow-napi) 264 + 265 + A native Node.js addon that provides direct access to NSWindow's `collectionBehavior` property, 266 + enabling setting of `NSWindowCollectionBehaviorMoveToActiveSpace` which Electron does not 267 + expose through its JavaScript API. 268 + 269 + --- 270 + 271 + ## 5. Conclusion: Root Cause 272 + 273 + The root cause is a combination of two factors: 274 + 275 + **Factor A: Electron's `setVisibleOnAllWorkspaces` sets the wrong NSWindow collection behavior flag.** 276 + It sets `NSWindowCollectionBehaviorCanJoinAllSpaces` (window appears on all Spaces) but does NOT 277 + set `NSWindowCollectionBehaviorMoveToActiveSpace` (window moves to the active Space when 278 + activated instead of switching the user). Electron does not expose any API to set 279 + `MoveToActiveSpace`. 280 + 281 + **Factor B: Calling `show()` or `focus()` on ANY BrowserWindow triggers macOS app activation.** 282 + Even panel windows (which skip `activateIgnoringOtherApps`) still call `makeKeyAndOrderFront`, 283 + which can trigger `NSApplicationDidBecomeActiveNotification`. Once the app becomes active, the 284 + `did-become-active` handler in `main.ts` shows HUD overlay windows via `win.show()`, and THOSE 285 + windows are non-panel types whose `show()` calls `activateIgnoringOtherApps:YES`, which 286 + definitively forces a Space switch. 287 + 288 + The previous fix of adding `setVisibleOnAllWorkspaces(true)` was addressing the wrong flag. 289 + The window was already visible on all workspaces -- the problem is that activating it pulls 290 + the user to the wrong Space, and the correct macOS behavior flag (`MoveToActiveSpace`) is not 291 + available through Electron's API.