Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
50 changes: 28 additions & 22 deletions src/main/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
moveWindowByArrow,
moveWindowToCorner,
resizeWindowByArrow,
restoreWindow,
toggleOpacity,
toggleStealth,
WindowPosition,
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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';
}
};

Expand Down Expand Up @@ -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`);
Expand Down
28 changes: 7 additions & 21 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
Expand Down
Loading