diff --git a/CLAUDE.md b/CLAUDE.md index b6b1946..1bcf70e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,13 +75,17 @@ Hash-based router (required for Electron `file://` protocol). Routes: `/` (index The main window reference is passed to `windowControlService` and `zoomService` after creation. Window bounds persist to Electron Store on `close` and are restored on next launch with minimum-size clamping (`MIN_WIDTH` / `MIN_HEIGHT` from [src/main/consts.ts](src/main/consts.ts)). -The app keeps itself off the surfaces a screen share exposes: `skipTaskbar: true` on the window, no desktop shortcut from the NSIS installer (and `build/installer.nsh` deletes one left by an older install), and on macOS an accessory activation policy - `LSUIElement` in the packaged Info.plist, `app.setActivationPolicy('accessory')` for dev runs - so there is no Dock icon and no Cmd+Tab entry. +The app keeps itself off the surfaces a screen share exposes, but only where it has to. There is never a desktop shortcut (the NSIS installer creates none, and `build/installer.nsh` deletes one left by an older install). The taskbar button and the macOS Dock icon belong to stealth mode: `applySurfaceVisibility()` in [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts) drives both from the current stealth flag - `setSkipTaskbar(_stealth)` plus, on macOS, `app.setActivationPolicy('accessory')` + `app.dock.hide()` going in and `'regular'` + `app.dock.show()` coming out. There is deliberately no `LSUIElement` in the packaged Info.plist: it would pin the app to accessory from launch and there would be no Dock icon to give back. -Two consequences follow from having no taskbar button and no Dock icon. A minimized window can only be brought back by relaunching the app (the single instance lock routes to `restoreWindow()`) or with `Ctrl+Shift+F8` (`Ctrl+Opt+F8` on macOS) - F8 because these shortcuts are system-wide and `Ctrl+Shift+R` is hard-reload in every browser. And `window-all-closed` quits on every platform including macOS, because a windowless process would otherwise sit there holding the global hotkeys unreachable. `test/stealth-surface.test.mjs` pins all of it. +Two consequences. A window minimized *in stealth mode* has no button to click, so it can only be brought back by relaunching the app - the single instance lock routes to `restoreWindow()`. And `window-all-closed` quits on every platform including macOS, because a windowless process in stealth mode would otherwise sit there holding the global hotkeys unreachable. `test/stealth-surface.test.mjs` pins all of it. Always-on-top belongs to stealth mode only (`'screen-saver'` level), and is dropped again on the way out. -Hiding the taskbar button is *registration* state (`ITaskbarList::DeleteTab` on Windows), not a window style, so the `skipTaskbar` constructor option does not survive `setFocusable` or z-order changes - the button reappears after a stealth toggle. `hideFromTaskbar()` re-asserts it, and anything that reshapes or re-shows the window must call it. `test/stealth-toggle.test.mjs` pins that. +Hiding the taskbar button is *registration* state (`ITaskbarList::DeleteTab` on Windows), not a window style, so it does not survive `setFocusable` or z-order changes - the button reappears after a stealth toggle. `applySurfaceVisibility()` re-asserts the right state, is wired to the window's `show`/`restore`/`maximize`/`unmaximize` events, and must be called after anything that reshapes or re-shows the window - and after `_stealth` is updated, since it reads it. `test/stealth-toggle.test.mjs` pins that. + +The Dock half has a failure mode of its own: **macOS drops a Dock call made within one second of the previous one**, silently. Toggling stealth twice quickly would otherwise leave the icon on screen for the rest of the session. `applyDockVisibility()` therefore skips no-op calls (so window events do not spend the one-second budget), and when a call does land inside the window it schedules a re-assert `DOCK_RATE_LIMIT_MS` later that re-reads `_stealth`. The activation policy carries no such limit and is applied immediately, so the icon still goes away at once in the swallowed case. `test/stealth-dock.test.mjs` pins this; it loads a second copy of the service through `loadMainAs('darwin', ...)`, since these branches are dead code on the Linux runner CI uses. + +Whether the *shell* actually acts on `setSkipTaskbar` is not something a unit test can reach, and it fails without an error. `test/manual/taskbar-probe.mjs` drives the real service in a real Electron process and reads the taskbar back through UI Automation - Windows only, run by hand (`pnpm exec electron test/manual/taskbar-probe.mjs`), deliberately not in `test/run.mjs`. Stealth mode hides the window from screen capture via `setContentProtection`. The main process emits `stealth-changed`; the preload script toggles a `stealth` CSS class on `document.body`. Content protection is on by default; pass `--disable-content-protection` at launch to disable it (dev/testing only). diff --git a/README.md b/README.md index af5a6a0..a487fe6 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,9 @@ Stay on top of the conversation with live ASR: ### Staying Out of Sight -The app keeps itself off the surfaces a screen share exposes. There is no desktop shortcut, no taskbar button on Windows, and no Dock icon or Cmd+Tab entry on macOS. Stealth mode additionally hides the window from screen capture. +The app keeps itself off the surfaces a screen share exposes. There is no desktop shortcut, and stealth mode hides the window from screen capture while also removing the Windows taskbar button and the macOS Dock icon and Cmd+Tab entry. Outside stealth mode the app behaves like any other window, taskbar button and Dock icon included. -**Lost the window?** Launch Power Interview again. It does not start a second copy - the running app brings its window back and focuses it. `Ctrl+Shift+F8` (`Ctrl+Opt+F8` on macOS) does the same without leaving the keyboard. +**Lost the window?** Launch Power Interview again. It does not start a second copy - the running app brings its window back and focuses it. ## Platform Support diff --git a/package.json b/package.json index 3963037..c0f5e14 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,6 @@ "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.inherit.plist", "extendInfo": { - "LSUIElement": true, "NSMicrophoneUsageDescription": "Power Interview AI needs microphone access to transcribe your voice during interviews.", "NSScreenRecordingUsageDescription": "Power Interview AI needs screen recording access to capture system audio during interviews." } diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index 4610cfc..8159298 100644 --- a/src/main/hotkeys.ts +++ b/src/main/hotkeys.ts @@ -6,7 +6,6 @@ import { moveWindowByArrow, moveWindowToCorner, resizeWindowByArrow, - restoreWindow, toggleOpacity, toggleStealth, WindowPosition, @@ -40,14 +39,10 @@ export function registerGlobalHotkeys(): void { // Opacity toggle: cycle opacity when in stealth mode globalShortcut.register(`${BASE}+N`, () => toggleOpacity()); - // Restore the window. The window is kept off the taskbar, so this is the only way back after - // minimizing that does not put the app's name on a shared screen. F8 rather than R: these are - // system-wide, and Ctrl+Shift+R is hard-reload in every browser. - globalShortcut.register(`${BASE}+F8`, () => restoreWindow()); - - // Toggle the transcription dock. F7 rather than T for the same reason as above, and it keeps - // the dock reachable in stealth mode, where the control panel carrying the button is hidden. - globalShortcut.register(`${BASE}+F7`, () => { + // Toggle the transcription dock. F8 rather than T because these shortcuts are system-wide, and + // it keeps the dock reachable in stealth mode, where the control panel carrying the button is + // hidden. + globalShortcut.register(`${BASE}+F8`, () => { const w = BrowserWindow.getAllWindows()[0]; if (w && !w.isDestroyed()) w.webContents.send('hotkey:toggle-transcript'); }); @@ -81,16 +76,26 @@ export function registerGlobalHotkeys(): void { // 1 2 3 const numToCorner = (n: number): WindowPosition => { switch (String(n)) { - case '1': return 'bottom-left'; - case '2': return 'bottom-center'; - case '3': return 'bottom-right'; - case '4': return 'middle-left'; - case '5': return 'center'; - case '6': return 'middle-right'; - case '7': return 'top-left'; - case '8': return 'top-center'; - case '9': return 'top-right'; - default: return 'center'; + case '1': + return 'bottom-left'; + case '2': + return 'bottom-center'; + case '3': + return 'bottom-right'; + case '4': + return 'middle-left'; + case '5': + return 'center'; + case '6': + return 'middle-right'; + case '7': + return 'top-left'; + case '8': + return 'top-center'; + case '9': + return 'top-right'; + default: + return 'center'; } }; @@ -168,11 +173,12 @@ export function registerGlobalHotkeys(): void { console.log(` ${mod}+Q : Stop assistant`); console.log(` ${mod}+M : Toggle stealth mode`); console.log(` ${mod}+N : Toggle opacity (stealth only)`); - console.log(` ${mod}+F7 : Toggle transcription dock`); - console.log(` ${mod}+F8 : Restore window (no taskbar button exists)`); + console.log(` ${mod}+F8 : Toggle transcription dock`); console.log(` ${mod}+1-9 : Place window (numpad layout)`); console.log(' Ctrl+Alt+Shift+Arrow : Move window'); - console.log(isMac ? ' Ctrl+Opt+Cmd+Arrow : Resize window' : ' Ctrl+Win+Shift+Arrow : Resize window'); + console.log( + isMac ? ' Ctrl+Opt+Cmd+Arrow : Resize window' : ' Ctrl+Win+Shift+Arrow : Resize window' + ); console.log(` ${mod}+J / K / L : Scroll live suggestions`); console.log(` ${mod}+U / I / O : Scroll action suggestions`); console.log(` ${mod}+F9/F10/F11/F12 : Capture / Clear / Trigger / Capture+Trigger`); diff --git a/src/main/index.ts b/src/main/index.ts index ed1bc1b..8dad991 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,10 +20,7 @@ import { registerPermissionHandlers } from './ipc/permissions.js'; import { registerActionSuggestionHandlers } from './ipc/suggestion-action.js'; import { registerLiveSuggestionHandlers } from './ipc/suggestion-live.js'; import { registerToolsHandlers } from './ipc/tools.js'; -import { - initializeAudioLoopback, - registerTranscriptHandlers, -} from './ipc/transcript.js'; +import { initializeAudioLoopback, registerTranscriptHandlers } from './ipc/transcript.js'; import { registerWindowHandlers } from './ipc/window.js'; import { autoUpdaterService } from './services/auto-updater.service.js'; import { healthCheckService } from './services/health-check.service.js'; @@ -102,9 +99,9 @@ async function createWindow() { title: 'Power Interview AI', ...savedBounds, titleBarStyle: 'hidden', - // A labelled taskbar button defeats stealth mode the moment a screen is shared. Not sticky: - // see hideFromTaskbar() in window-control.service, which re-asserts it. - skipTaskbar: true, + // No skipTaskbar here: the taskbar button and the Dock icon follow stealth mode instead, and + // the app starts in normal mode. See applySurfaceVisibility() in window-control.service. + // // Center traffic lights vertically in the h-9 (36px) titlebar. // Default y=7 puts button centers at 13px; (36-12)/2=12 is exact center. trafficLightPosition: { x: 7, y: 12 }, @@ -191,17 +188,6 @@ async function createWindow() { // APP LIFECYCLE // ------------------------------------------------------------- app.whenReady().then(async () => { - // macOS counterpart of skipTaskbar: an accessory app has no Dock icon and does not appear in - // Cmd+Tab. LSUIElement in the packaged Info.plist does the same thing from launch (no icon - // flash); this call is what makes dev runs behave the same, since dev uses Electron's plist. - if (process.platform === 'darwin') { - try { - app.setActivationPolicy('accessory'); - } catch (err) { - console.warn('Failed to set accessory activation policy:', err); - } - } - // Register all IPC handlers registerConfigHandlers(); registerAppStateHandlers(); @@ -255,9 +241,9 @@ app.on('will-quit', async () => { unregisterHotkeys(); }); -// The usual macOS "stay alive with no windows" behavior assumed a Dock icon to click. As an -// accessory app there is none, so a windowless process would just sit there holding the global -// hotkeys with no way to reach it. Closing the window means quitting, on every platform. +// The usual macOS "stay alive with no windows" behavior assumes a Dock icon to click. In stealth +// mode there is none, so a windowless process would just sit there holding the global hotkeys +// with no way to reach it. Closing the window means quitting, on every platform. app.on('window-all-closed', () => { app.quit(); }); diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index 8c378f7..485d53c 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -1,14 +1,25 @@ -import { BrowserWindow, screen } from 'electron'; +import { app, BrowserWindow, screen } from 'electron'; import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS } from '../consts.js'; import { configStore } from '../store/config.store.js'; import { appStateService } from './app-state.service.js'; import { pushNotificationService } from './push-notification.service.js'; +const isMac = process.platform === 'darwin'; + +// macOS ignores a Dock call made within one second of the previous one, so a quick stealth +// toggle can silently leave the icon in the wrong state. 1100ms is the documented workaround. +const DOCK_RATE_LIMIT_MS = 1100; + // Global reference to the main window let win: BrowserWindow | null = null; let _stealth = configStore.getStealth(); +// Last Dock state we asked macOS for, and when. `null` means nothing has been applied yet. +let dockVisible: boolean | null = null; +let lastDockCallAt = 0; +let dockRecheckTimer: NodeJS.Timeout | null = null; + // helper: return the display the window mostly occupies (fallback to primary) function getCurrentDisplay(): Electron.Display { if (win && !win.isDestroyed()) { @@ -57,15 +68,17 @@ interface WindowBounds { export function setWindowReference(window: BrowserWindow): void { win = window; - // The shell re-adds the taskbar button whenever the window is re-shown or re-shaped, and most - // of those paths are not ours to intercept - Alt+Tab restoring a minimized window, for one. + // The shell re-registers the taskbar button whenever the window is re-shown or re-shaped, and + // most of those paths are not ours to intercept - Alt+Tab restoring a minimized window, for one. // Re-assert on the events instead of at every call site. if (typeof window.on === 'function') { - window.on('show', hideFromTaskbar); - window.on('restore', hideFromTaskbar); - window.on('maximize', hideFromTaskbar); - window.on('unmaximize', hideFromTaskbar); + window.on('show', applySurfaceVisibility); + window.on('restore', applySurfaceVisibility); + window.on('maximize', applySurfaceVisibility); + window.on('unmaximize', applySurfaceVisibility); } + + applySurfaceVisibility(); } /** @@ -76,28 +89,83 @@ export function getWindowReference(): BrowserWindow | null { } /** - * Re-assert that the window stays out of the taskbar. + * Put the taskbar button and the macOS Dock icon in step with stealth mode: present in normal + * mode, gone in stealth, where a labelled button or Dock icon is the first thing a shared screen + * gives the app away with. * - * The constructor option is not sticky: hiding the button is registration state + * This cannot be set once and left alone. Hiding the taskbar button is registration state * (`ITaskbarList::DeleteTab` on Windows), not a window style, and the shell re-adds the button * when the window's styles change underneath it - which is exactly what toggling stealth does * through `setFocusable` and the z-order level. Anything that reshapes or re-shows the window * has to call this afterwards. */ -function hideFromTaskbar(): void { - if (!win || win.isDestroyed()) return; +function applySurfaceVisibility(): void { + if (win && !win.isDestroyed()) { + try { + win.setSkipTaskbar(_stealth); + } catch (e) { + console.warn('setSkipTaskbar failed:', e); + } + } + + if (isMac) applyDockVisibility(); +} + +/** + * macOS counterpart of the taskbar button. An accessory app has no Dock icon and no Cmd+Tab + * entry, a regular one has both. The activation policy is what actually moves the app between + * the two - `dock.show()` alone does not lift an accessory app back out - so both are set. + * + * `dock.hide()` is rate limited: macOS drops a Dock call made within a second of the previous + * one. Toggling stealth twice in quick succession therefore leaves the icon on screen with no + * error of any kind, so a swallowed call schedules a re-assert once the window has passed. + * The activation policy carries no such limit, which is why it is applied on the spot rather + * than deferred with the Dock call - the icon goes away immediately even in the swallowed case. + */ +function applyDockVisibility(): void { + const wantVisible = !_stealth; + + // Window events (show, restore, maximize) land here too. Skipping the no-op keeps them from + // spending the one-second budget that a real stealth toggle needs. + if (dockVisible === wantVisible) return; + + const swallowed = dockVisible !== null && Date.now() - lastDockCallAt < DOCK_RATE_LIMIT_MS; try { - win.setSkipTaskbar(true); + if (wantVisible) { + app.setActivationPolicy('regular'); + void app.dock?.show(); + } else { + app.setActivationPolicy('accessory'); + app.dock?.hide(); + } } catch (e) { - console.warn('setSkipTaskbar failed:', e); + console.warn('Failed to update Dock visibility:', e); } + + dockVisible = wantVisible; + lastDockCallAt = Date.now(); + + if (dockRecheckTimer) { + clearTimeout(dockRecheckTimer); + dockRecheckTimer = null; + } + if (!swallowed) return; + + dockRecheckTimer = setTimeout(() => { + dockRecheckTimer = null; + // Forget what we asked for so the re-assert is not skipped as a no-op, then apply whatever + // stealth is by now - the user may have toggled again while this was pending. + dockVisible = null; + applyDockVisibility(); + }, DOCK_RATE_LIMIT_MS); + dockRecheckTimer.unref?.(); } /** * Restore and raise the window. - * There is no taskbar button and no Dock icon to click, so a minimized window is - * only reachable through this. + * In stealth mode there is no taskbar button and no Dock icon to click, so a minimized window is + * only reachable through this - relaunching the app routes here through the single instance lock. */ export function restoreWindow(): void { if (!win || win.isDestroyed()) return; @@ -109,16 +177,16 @@ export function restoreWindow(): void { // app on macOS. Pulling focus out of the call the user is in is the one thing it must not do. if (_stealth) { if (!win.isVisible()) win.showInactive(); - hideFromTaskbar(); + applySurfaceVisibility(); return; } - // `show()` also raises and activates, which an accessory app on macOS needs - `focus()` - // alone does not bring it forward there. + // `show()` also raises and activates - `focus()` alone does not always bring the window + // forward on macOS. win.show(); win.focus(); // Restoring from minimized re-registers the window with the shell. - hideFromTaskbar(); + applySurfaceVisibility(); } catch (err) { console.warn('⚠️ restoreWindow failed:', err); } @@ -323,10 +391,12 @@ export function enableStealth(): void { // Make the window semi-transparent using last-used opacity level win.setOpacity(configStore.getOpacityLevel()); - // Same reason as on the way out: setFocusable(false) can hand the button back. - hideFromTaskbar(); - _stealth = true; + + // After every other window mutation, and after _stealth is set - this reads it. setFocusable + // and the z-order change both hand the taskbar button back. + applySurfaceVisibility(); + try { configStore.setStealth(_stealth); } catch (e) { @@ -366,8 +436,8 @@ export function disableStealth(): void { win.setOpacity(1.0); // Last, after every other window mutation: setFocusable, the z-order change and dropping - // the layered style all hand the taskbar button back. - hideFromTaskbar(); + // the layered style all reshuffle the taskbar registration. + applySurfaceVisibility(); try { configStore.setStealth(_stealth); diff --git a/src/renderer/components/custom/documentation-dialog.tsx b/src/renderer/components/custom/documentation-dialog.tsx index 19b94d6..98dd5bb 100644 --- a/src/renderer/components/custom/documentation-dialog.tsx +++ b/src/renderer/components/custom/documentation-dialog.tsx @@ -65,10 +65,10 @@ export default function DocumentationDialog({ open, onOpenChange }: Documentatio

Lost the window?

- {APP_NAME} stays out of the taskbar and the macOS Dock so it is not visible when you - share your screen, which also means a minimized window has no button to click. Just - launch {APP_NAME} again: it does not start a second copy, it brings this window back.{' '} - {HOTKEYS[Hotkey.RestoreWin].combo} does the same without leaving the keyboard. + In stealth mode {APP_NAME} leaves the taskbar and the macOS Dock so it is not visible + when you share your screen, which also means a minimized window has no button to click. + Just launch {APP_NAME} again: it does not start a second copy, it brings this window + back. Outside stealth mode the usual taskbar button and Dock icon are there.

Hotkeys

diff --git a/src/renderer/lib/hotkeys.ts b/src/renderer/lib/hotkeys.ts index d4789bc..77da781 100644 --- a/src/renderer/lib/hotkeys.ts +++ b/src/renderer/lib/hotkeys.ts @@ -6,7 +6,6 @@ export enum Hotkey { Opacity = 'Opacity', ToggleTranscript = 'ToggleTranscript', PlaceWin = 'PlaceWin', - RestoreWin = 'RestoreWin', MoveWin = 'MoveWin', ResizeWin = 'ResizeWin', ZoomInOutReset = 'ZoomInOutReset', @@ -35,13 +34,7 @@ export const HOTKEY_GROUPS: HotkeyGroup[] = [ }, { label: 'Window Management', - keys: [ - Hotkey.PlaceWin, - Hotkey.RestoreWin, - Hotkey.MoveWin, - Hotkey.ResizeWin, - Hotkey.ZoomInOutReset, - ], + keys: [Hotkey.PlaceWin, Hotkey.MoveWin, Hotkey.ResizeWin, Hotkey.ZoomInOutReset], }, { label: 'Scroll Panels', @@ -91,7 +84,7 @@ export const HOTKEYS: Record = { description: 'Toggle window opacity in stealth mode', }, [Hotkey.ToggleTranscript]: { - combo: `${BASE}F7`, + combo: `${BASE}F8`, title: 'Toggle Transcription', description: 'Show or hide the transcription dock - works in stealth mode too', }, @@ -100,11 +93,6 @@ export const HOTKEYS: Record = { title: 'Place Window', description: 'Place window in a specific corner, side, or center', }, - [Hotkey.RestoreWin]: { - combo: `${BASE}F8`, - title: 'Restore Window', - description: 'Bring the window back after minimizing - it is kept off the taskbar', - }, [Hotkey.MoveWin]: { combo: `${MOVE}[↑↓←→]`, title: 'Move Window', diff --git a/test/app-state.test.mjs b/test/app-state.test.mjs index e89c053..5cc0650 100644 --- a/test/app-state.test.mjs +++ b/test/app-state.test.mjs @@ -24,6 +24,7 @@ export async function run() { const sent = []; windowControl.setWindowReference({ isDestroyed: () => false, + setSkipTaskbar: () => {}, webContents: { send: (channel, payload) => sent.push({ channel, payload }) }, }); diff --git a/test/helpers.mjs b/test/helpers.mjs index aa792f1..9851280 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -28,12 +28,21 @@ export function stubElectron() { format: 'module', shortCircuit: true, source: ` +// Records every Dock/activation-policy call in order so the macOS surface can be asserted on +// any platform. See test/stealth-dock.test.mjs. +const dockCalls = []; const app = { getPath: () => ${JSON.stringify(userData)}, getName: () => 'pia-test', getVersion: () => '0.0.0', on: () => {}, whenReady: () => Promise.resolve(), + setActivationPolicy: (policy) => dockCalls.push(policy), + dock: { + show: async () => { dockCalls.push('dock.show'); }, + hide: () => { dockCalls.push('dock.hide'); }, + }, + dockCalls, }; const ipcMain = { on: () => {}, handle: () => {} }; const screen = { getPrimaryDisplay: () => ({ workAreaSize: { width: 1920, height: 1080 } }), getAllDisplays: () => [] }; @@ -52,6 +61,25 @@ export function loadMain(relativePath) { return import(pathToFileURL(path.join(DIST, relativePath)).href); } +/** + * Import a fresh copy of a compiled module while `process.platform` reports `platform`. + * + * The macOS-only branches read `process.platform` once at module scope, so they are dead code on + * the Linux runner CI uses - which is exactly where the Dock behaviour would go unnoticed. The + * ESM cache is keyed by URL, so a query string yields a second, separately initialised instance; + * its own relative imports carry no query and keep sharing the already-loaded singletons. + */ +export async function loadMainAs(platform, relativePath) { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + const url = `${pathToFileURL(path.join(DIST, relativePath)).href}?platform=${platform}`; + return await import(url); + } finally { + Object.defineProperty(process, 'platform', original); + } +} + export function createChecker(name) { const failures = []; console.log(`\n# ${name}`); diff --git a/test/manual/taskbar-buttons.ps1 b/test/manual/taskbar-buttons.ps1 new file mode 100644 index 0000000..fa1483a --- /dev/null +++ b/test/manual/taskbar-buttons.ps1 @@ -0,0 +1,18 @@ +# Dump the names of every taskbar button via UI Automation. Used by taskbar-probe.mjs: the +# taskbar reports " - N running windows", which is the only reliable read of whether a +# window is registered, since skipTaskbar is shell registration and not a window style. +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes + +$root = [System.Windows.Automation.AutomationElement]::RootElement +$cond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ClassNameProperty, 'Shell_TrayWnd') +$tray = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond) +if ($null -eq $tray) { Write-Output 'NO_TRAY'; exit 1 } + +$btnCond = New-Object System.Windows.Automation.PropertyCondition( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button) + +foreach ($b in $tray.FindAll([System.Windows.Automation.TreeScope]::Descendants, $btnCond)) { + Write-Output $b.Current.Name +} diff --git a/test/manual/taskbar-probe.mjs b/test/manual/taskbar-probe.mjs new file mode 100644 index 0000000..3641ef2 --- /dev/null +++ b/test/manual/taskbar-probe.mjs @@ -0,0 +1,111 @@ +/** + * Manual, Windows-only end-to-end check of the taskbar button across stealth toggles. + * + * `test/stealth-toggle.test.mjs` can only prove we call `setSkipTaskbar` with the right argument. + * Whether the shell acts on it is the part that actually broke before, and it fails silently - + * no exception, no log. This drives the real compiled service in a real Electron process and + * reads the taskbar back through UI Automation. + * + * Not in `test/run.mjs`: it needs a Windows desktop session, and CI runs headless Linux. + * + * cd client + * pnpm electron:build-main + * pnpm exec electron test/manual/taskbar-probe.mjs + * + * If `electron --version` prints a Node version rather than an Electron one, `ELECTRON_RUN_AS_NODE` + * is set in your shell; clear it first. + */ +import { app, BrowserWindow } from 'electron'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '..', '..'); +const SERVICE = new URL( + `file:///${path + .join(ROOT, 'electron-dist', 'services', 'window-control.service.js') + .replace(/\\/g, '/')}` +).href; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * Windows groups every Electron app under a single taskbar button (they share an + * AppUserModelID), so a second Electron app running on the machine would hide our button behind + * its own. Count the windows the button reports rather than the buttons themselves, and measure + * against a baseline taken before our window exists. + */ +function electronWindowsOnTaskbar() { + const out = execFileSync( + 'powershell', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', path.join(HERE, 'taskbar-buttons.ps1')], + { encoding: 'utf8' } + ); + let total = 0; + for (const m of out.matchAll(/Electron - (\d+) running window/g)) total += Number(m[1]); + return total; +} + +let baseline = 0; +const results = []; + +function probe(label, expectPresent) { + const n = electronWindowsOnTaskbar(); + const present = n > baseline; + const ok = present === expectPresent; + results.push(ok); + console.log( + ` ${ok ? 'ok ' : 'FAIL'} ${label}: taskbar button ${present ? 'present' : 'absent'}` + + ` (expected ${expectPresent ? 'present' : 'absent'}, electron windows ${n}/${baseline})` + ); +} + +app.commandLine.appendSwitch('force-device-scale-factor', '1'); + +app.whenReady().then(async () => { + if (process.platform !== 'win32') { + console.log('taskbar-probe: Windows only, skipping.'); + app.exit(0); + return; + } + + console.log('\n# taskbar-probe'); + baseline = electronWindowsOnTaskbar(); + + const { setWindowReference, enableStealth, disableStealth } = await import(SERVICE); + + const win = new BrowserWindow({ x: 40, y: 40, width: 460, height: 260, title: 'PROBE WINDOW' }); + win.loadURL('data:text/html,PROBE'); + win.show(); + + setWindowReference(win); + await sleep(1500); + probe('the window starts on the taskbar', true); + + // One pass proves little - the reported bug only appeared after switching modes. + for (let cycle = 1; cycle <= 3; cycle++) { + enableStealth(); + await sleep(1200); + probe(`cycle ${cycle}: stealth takes the button away`, false); + + disableStealth(); + await sleep(1200); + probe(`cycle ${cycle}: leaving stealth gives it back`, true); + } + + // The sequence a user produces by mashing the stealth hotkey. + enableStealth(); + disableStealth(); + enableStealth(); + await sleep(2500); + probe('rapid toggles settling in stealth: absent', false); + + disableStealth(); + await sleep(2500); + probe('rapid toggles settling in normal mode: present', true); + + const passed = results.filter(Boolean).length; + console.log(`\n${passed}/${results.length} checks passed`); + app.exit(passed === results.length ? 0 : 1); +}); diff --git a/test/run.mjs b/test/run.mjs index f97a56c..39252f7 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -19,6 +19,7 @@ for (const module of [ './account.test.mjs', './stealth-surface.test.mjs', './stealth-toggle.test.mjs', + './stealth-dock.test.mjs', './tools-export.test.mjs', ]) { const { run } = await import(module); diff --git a/test/stealth-dock.test.mjs b/test/stealth-dock.test.mjs new file mode 100644 index 0000000..1c6c9b7 --- /dev/null +++ b/test/stealth-dock.test.mjs @@ -0,0 +1,111 @@ +/** + * The macOS Dock icon is the counterpart of the Windows taskbar button: present in normal mode, + * gone in stealth. It has a failure mode the taskbar button does not - macOS drops a Dock call + * made within a second of the previous one, silently, so toggling stealth a few times in quick + * succession could leave the icon sitting on screen during a shared call with nothing logged. + * + * These branches read `process.platform` at module scope, so they are dead code on the Linux + * runner CI uses. `loadMainAs('darwin', ...)` loads a second instance that believes it is on + * macOS, and the electron stub records every Dock and activation-policy call in order. + */ +import { createChecker, loadMainAs } from './helpers.mjs'; + +/** Last state the Dock was actually asked for, ignoring the activation-policy entries. */ +function dockState(calls) { + for (let i = calls.length - 1; i >= 0; i--) { + if (calls[i] === 'dock.show') return 'visible'; + if (calls[i] === 'dock.hide') return 'hidden'; + } + return 'untouched'; +} + +/** Both halves have to agree - the policy is what moves the app, the Dock call clears the icon. */ +function surfaceIs(calls, expected) { + const policy = calls.filter((c) => c === 'regular' || c === 'accessory').at(-1); + return expected === 'visible' + ? dockState(calls) === 'visible' && policy === 'regular' + : dockState(calls) === 'hidden' && policy === 'accessory'; +} + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function run() { + const { check, failures } = createChecker('stealth-dock'); + + const { app } = await import('electron'); + const windowControl = await loadMainAs('darwin', 'services/window-control.service.js'); + + const calls = app.dockCalls; + const skipTaskbar = []; + const handlers = {}; + windowControl.setWindowReference({ + isDestroyed: () => false, + on: (event, handler) => { + handlers[event] = handler; + }, + setAlwaysOnTop: () => {}, + setSkipTaskbar: (skip) => skipTaskbar.push(skip), + setVisibleOnAllWorkspaces: () => {}, + setIgnoreMouseEvents: () => {}, + setFocusable: () => {}, + setOpacity: () => {}, + isMinimized: () => false, + isVisible: () => true, + show: () => {}, + showInactive: () => {}, + focus: () => {}, + restore: () => {}, + webContents: { send: () => {} }, + }); + + check('the app starts with a Dock icon', surfaceIs(calls, 'visible')); + + // Five cycles back to back, all inside the one-second window, so every one of them hits the + // rate limit. This is the sequence a user produces by mashing the stealth hotkey. + for (let i = 0; i < 5; i++) { + windowControl.enableStealth(); + check( + `cycle ${i + 1}: entering stealth asks for the Dock icon to go`, + surfaceIs(calls, 'hidden') + ); + windowControl.disableStealth(); + check( + `cycle ${i + 1}: leaving stealth asks for the Dock icon back`, + surfaceIs(calls, 'visible') + ); + } + + // Settling in stealth is the case that matters: a call macOS swallowed here leaves the icon up. + windowControl.enableStealth(); + check('the last toggle asks for the Dock icon to go', surfaceIs(calls, 'hidden')); + + const beforeRecheck = calls.length; + await wait(1300); + check( + 'the swallowed Dock call is re-asserted once the rate limit passes', + calls.length > beforeRecheck + ); + check('and it settles hidden', surfaceIs(calls, 'hidden')); + + // Window events run through the same path. They must not spend the one-second budget that the + // next real toggle needs, so a no-op has to stay a no-op. + const beforeEvents = calls.length; + skipTaskbar.length = 0; + for (const event of ['show', 'restore', 'maximize', 'unmaximize']) handlers[event]?.(); + check('window events touch the Dock only when the state changed', calls.length === beforeEvents); + check('window events still re-assert the taskbar button', skipTaskbar.at(-1) === true); + + windowControl.disableStealth(); + check('leaving stealth asks for the Dock icon back', surfaceIs(calls, 'visible')); + + await wait(1300); + check('it settles visible', surfaceIs(calls, 'visible')); + + // The re-assert must not re-arm itself, or the app would keep poking the Dock forever. + const afterSettle = calls.length; + await wait(1300); + check('the re-assert stops once the state is stable', calls.length === afterSettle); + check('and the Dock icon is still there', surfaceIs(calls, 'visible')); + + return failures; +} diff --git a/test/stealth-surface.test.mjs b/test/stealth-surface.test.mjs index be1e61c..af9455b 100644 --- a/test/stealth-surface.test.mjs +++ b/test/stealth-surface.test.mjs @@ -1,6 +1,7 @@ /** * Stealth mode hides the window from screen capture, but the surfaces around it - a desktop - * shortcut, a taskbar button, a Dock icon - are what a screen share exposes first. These are + * shortcut, a taskbar button, a Dock icon - are what a screen share exposes first. The taskbar + * button and the Dock icon follow stealth mode; the desktop shortcut is never created. These are * one-line settings that a merge can silently flip back, and none of them show up until someone * builds an installer on the right OS. */ @@ -21,7 +22,9 @@ export async function run() { 'installer still creates a start menu shortcut', pkg.build.nsis.createStartMenuShortcut === true ); - check('packaged mac app is an accessory app', pkg.build.mac.extendInfo.LSUIElement === true); + // LSUIElement would make the packaged app an accessory app from launch, with no Dock icon at + // all. The Dock icon now belongs to normal mode, so the policy is switched at runtime instead. + check('packaged mac app is not pinned to accessory', !pkg.build.mac.extendInfo.LSUIElement); // createDesktopShortcut only covers fresh installs; upgrades have to delete the old icon. const nsh = fs.readFileSync(path.join(ROOT, 'build', 'installer.nsh'), 'utf8'); @@ -33,18 +36,33 @@ export async function run() { // Read the compiled output rather than the source: this is what actually ships. const main = fs.readFileSync(path.join(ROOT, 'electron-dist', 'index.js'), 'utf8'); - check('window is created with skipTaskbar', /skipTaskbar:\s*true/.test(main)); - check('dev runs get the accessory policy too', /setActivationPolicy\('accessory'\)/.test(main)); + check('window is not pinned off the taskbar at creation', !/skipTaskbar:\s*true/.test(main)); - // No taskbar button and no Dock icon: without these, closing or minimizing the window - // leaves a running process with no way back to it. + // Both halves of the macOS switch have to be there: dock.hide() alone does not lift the app + // back out of accessory, and the policy alone leaves a stale icon behind. + const windowControl = fs.readFileSync( + path.join(ROOT, 'electron-dist', 'services', 'window-control.service.js'), + 'utf8' + ); + check('stealth drops the Dock icon', /dock\?\.hide\(\)/.test(windowControl)); + check('stealth makes the app an accessory app', /'accessory'/.test(windowControl)); + check('normal mode brings the Dock icon back', /dock\?\.show\(\)/.test(windowControl)); + check('normal mode makes the app a regular app', /'regular'/.test(windowControl)); + check('the taskbar button follows stealth', /setSkipTaskbar\(_?stealth\)/.test(windowControl)); + + // In stealth mode there is no taskbar button and no Dock icon: without this, closing or + // minimizing the window leaves a running process with no way back to it. const onAllClosed = main.slice(main.indexOf("'window-all-closed'")); check( 'closing the last window quits on every platform', /^[\s\S]{0,120}app\.quit\(\)/.test(onAllClosed) && !/^[\s\S]{0,120}darwin/.test(onAllClosed) ); const hotkeys = fs.readFileSync(path.join(ROOT, 'electron-dist', 'hotkeys.js'), 'utf8'); - check('a restore hotkey is registered', /restoreWindow\(\)/.test(hotkeys)); + + // The restore hotkey is gone: relaunching the app is the recovery path. F8 was freed by that + // removal and now carries the transcript toggle, so the key itself no longer proves anything - + // what has to stay true is that no hotkey reaches restoreWindow. + check('no hotkey restores the window', !/restoreWindow/.test(hotkeys)); // Stealth mode hides the control panel that carries the transcription toggle, so the hotkey // is the only route to it there. It takes both halves to work, and neither fails loudly. diff --git a/test/stealth-toggle.test.mjs b/test/stealth-toggle.test.mjs index 9f70459..faa1dda 100644 --- a/test/stealth-toggle.test.mjs +++ b/test/stealth-toggle.test.mjs @@ -1,8 +1,9 @@ /** - * Hiding the taskbar button is shell registration (`ITaskbarList::DeleteTab` on Windows), not a - * window style, so it does not survive the style changes stealth mode makes - `setFocusable` and - * the z-order level. Reported from a real run: the button came back after switching out of - * stealth. Anything that reshapes or re-shows the window has to re-assert it. + * The taskbar button follows stealth mode: present in normal mode, gone in stealth. It cannot be + * set once and left alone - hiding it is shell registration (`ITaskbarList::DeleteTab` on + * Windows), not a window style, so it does not survive the style changes stealth mode makes + * (`setFocusable` and the z-order level). Reported from a real run: the button came back after + * switching out of stealth. Anything that reshapes or re-shows the window has to re-assert it. */ import { createChecker, loadMain } from './helpers.mjs'; @@ -34,27 +35,58 @@ export async function run() { webContents: { send: () => {} }, }); + // Stealth is reset to off on load, so the app starts with an ordinary taskbar button. + check('the window starts on the taskbar', skipTaskbar.at(-1) === false); + + skipTaskbar.length = 0; windowControl.enableStealth(); check('stealth pins the window above everything', alwaysOnTop.at(-1)?.level === 'screen-saver'); - check('entering stealth keeps the taskbar button hidden', skipTaskbar.at(-1) === true); + check('entering stealth takes the taskbar button away', skipTaskbar.at(-1) === true); + + // Most paths that re-show or re-shape the window are not ours to call - Alt+Tab restoring a + // minimized window, the titlebar maximize button - so the events have to carry the fix. + for (const event of ['show', 'restore', 'maximize', 'unmaximize']) { + skipTaskbar.length = 0; + handlers[event]?.(); + check(`the ${event} event keeps the button hidden in stealth`, skipTaskbar.at(-1) === true); + } + + skipTaskbar.length = 0; + windowControl.restoreWindow(); + check('restoring in stealth keeps the button hidden', skipTaskbar.at(-1) === true); alwaysOnTop.length = 0; skipTaskbar.length = 0; windowControl.disableStealth(); check('leaving stealth drops always-on-top again', alwaysOnTop.at(-1)?.enabled === false); - check('leaving stealth hides the taskbar button again', skipTaskbar.at(-1) === true); - - skipTaskbar.length = 0; - windowControl.restoreWindow(); - check('restoring hides the taskbar button again', skipTaskbar.at(-1) === true); + check('leaving stealth gives the taskbar button back', skipTaskbar.at(-1) === false); - // Most paths that re-show or re-shape the window are not ours to call - Alt+Tab restoring a - // minimized window, the titlebar maximize button - so the events have to carry the fix. for (const event of ['show', 'restore', 'maximize', 'unmaximize']) { skipTaskbar.length = 0; handlers[event]?.(); - check(`the ${event} event hides the taskbar button again`, skipTaskbar.at(-1) === true); + check(`the ${event} event keeps the button in normal mode`, skipTaskbar.at(-1) === false); + } + + skipTaskbar.length = 0; + windowControl.restoreWindow(); + check('restoring in normal mode keeps the button', skipTaskbar.at(-1) === false); + + // The reported bug only showed up after switching modes, so one pass either way proves little. + // Cycle repeatedly and check the button tracks the mode every single time. + let drifted = null; + for (let i = 0; i < 10; i++) { + windowControl.enableStealth(); + if (skipTaskbar.at(-1) !== true) drifted ??= `cycle ${i + 1} entering stealth`; + windowControl.disableStealth(); + if (skipTaskbar.at(-1) !== false) drifted ??= `cycle ${i + 1} leaving stealth`; + // A window event between toggles must not knock it out of step either. + handlers.show?.(); + if (skipTaskbar.at(-1) !== false) drifted ??= `cycle ${i + 1} show event in normal mode`; } + check( + `10 stealth cycles keep the taskbar button in step${drifted ? ` (drifted at ${drifted})` : ''}`, + drifted === null + ); return failures; }