Skip to content
Merged
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@ The outer container keeps `overflow-auto` as a safety valve for large zoom facto

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 are driven by `applySurfaceVisibility()` in [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts) - `setSkipTaskbar(hidden)` 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.

`hidden` comes from `shouldHideSurfaces()`, which is `_stealth || isAssistantRunning()`. **The two inputs are independent, not nested.** A running assistant is when a screen share is most likely live, so it hides the same surfaces stealth does; leaving stealth mid-session must therefore *not* hand the taskbar button back. The macOS traffic lights are the deliberate exception - they follow `_stealth` alone, because a merely running window is still focusable and interactive and needs its close and minimise buttons. `test/running-surface.test.mjs` pins all of it.
`hidden` comes from `shouldHideSurfaces()`, which is `_stealth || (_hideTaskbarWhileRunning && isAssistantRunning())`. `hideTaskbarWhileRunning` is an opt-in config setting, off by default: with it off a running assistant leaves the icon, z-order and minimize alone. **The two inputs are independent, not nested.** With the setting on, leaving stealth mid-session must *not* hand the taskbar button back. While hidden the window is also re-raised with `moveTop()` every `RAISE_INTERVAL_MS`, and `canMinimize()` (false in stealth and whenever the setting is on) drives `setMinimizable`, the `window:minimize` handler and the titlebar's minimize button. The macOS traffic lights are the deliberate exception - they follow `_stealth` alone, because a merely running window is still focusable and interactive and needs its close and minimise buttons. `test/running-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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ 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, 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.
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, unless you turn on "Hide taskbar icon during live interviews" in Configuration.

**Lost the window?** Launch Power Interview again. It does not start a second copy - the running app brings its window back and focuses it.

Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Nothing in it is a trap: Skip is on every step, every setting has a working defa

### Session Window Behaviour

While the assistant is running - or while stealth mode is on - the window is pinned above other windows (`screen-saver` level, and visible over a fullscreen call on macOS) and drops its taskbar button and Dock icon. The two conditions are independent: switching stealth off mid-session leaves both in place until the session actually stops. macOS traffic lights stay visible outside stealth, since the window is still interactive. Service: [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts).
While stealth mode is on - or while the assistant is running with the opt-in "Hide taskbar icon during live interviews" setting enabled - the window is pinned above other windows (`screen-saver` level, visible over a fullscreen call on macOS), re-raised every 2 seconds, cannot be minimized, and drops its taskbar button and Dock icon. With the setting off (the default) a running assistant leaves the window alone. The two conditions are independent: switching stealth off mid-session with the setting on leaves everything in place until the session stops. macOS traffic lights stay visible outside stealth, since the window is still interactive. Service: [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts).

### Interview Config Sync

Expand Down
3 changes: 3 additions & 0 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ export const SUGGESTION_STALL_MS = 15_000;
// above the longest legitimate action suggestion.
export const ACTION_LOCK_MAX_HOLD_MS = 180_000;

// How often a hidden-surface window is moved back to the top of the z-order.
export const RAISE_INTERVAL_MS = 2000;

// Stealth mode opacity levels (cycles on each toggle; default = second highest)
export const OPACITY_LEVELS = [0.2, 0.5, 0.73, 0.9] as const;
export const OPACITY_DEFAULT = OPACITY_LEVELS[OPACITY_LEVELS.length - 2]; // 0.73
Expand Down
5 changes: 5 additions & 0 deletions src/main/ipc/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron';

import { appStateService } from '../services/app-state.service.js';
import { setHideTaskbarWhileRunning } from '../services/window-control.service.js';
import { configStore, RuntimeConfig } from '../store/config.store.js';

export function registerConfigHandlers(): void {
Expand All @@ -25,6 +26,10 @@ export function registerConfigHandlers(): void {
// changing, which is enough: the re-seed is a no-op once a real interview has started.
if (updates.uiLanguage !== undefined) appStateService.refreshPlaceholderLanguage();

if (updates.hideTaskbarWhileRunning !== undefined) {
setHideTaskbarWhileRunning(config.hideTaskbarWhileRunning);
}

return config;
} catch (error) {
console.error('Failed to update config:', error);
Expand Down
5 changes: 3 additions & 2 deletions src/main/ipc/tools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { dialog, ipcMain } from 'electron';
import { ipcMain } from 'electron';
import fs from 'fs/promises';

import { toolsService } from '../services/tools.service.js';
import { showSaveDialog } from '../services/window-control.service.js';
import { ExportFormat } from '../types/export.js';
import { uiStrings } from '../utils/ui-strings.js';

Expand All @@ -22,7 +23,7 @@ export function registerToolsHandlers(): void {
'tools:save-image',
async (_event, { filename, data }: { filename: string; data: number[] }) => {
const strings = uiStrings();
const { canceled, filePath } = await dialog.showSaveDialog({
const { canceled, filePath } = await showSaveDialog({
title: strings.saveImageTitle,
defaultPath: filename,
filters: [{ name: strings.pngFilter, extensions: ['png'] }],
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function registerWindowHandlers(): void {

ipcMain.on('window:minimize', () => {
const win = window();
if (win && !win.isDestroyed()) win.minimize();
if (win && !win.isDestroyed() && windowControls.canMinimize()) win.minimize();
});

ipcMain.on('window:maximize', () => {
Expand Down
6 changes: 3 additions & 3 deletions src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,9 @@ export class AppStateService {
this.notifyRenderer();
}

// The taskbar button, the Dock icon and always-on-top follow the running state as well as
// stealth, and this is the only place a run starts or ends. Done after the state is written,
// since window-control reads it back. Never allowed to fail the update itself.
// The taskbar button, the Dock icon and always-on-top follow the running state (when the
// hideTaskbarWhileRunning setting is on) as well as stealth, and this is the only place a
// run starts or ends. Done after the state is written, since window-control reads it back. Never allowed to fail the update itself.
if (runningChanged) {
try {
refreshWindowSurfaces();
Expand Down
6 changes: 3 additions & 3 deletions src/main/services/tools.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { convertMarkdownToDocx } from '@mohtasham/md-to-docx';
import { dialog } from 'electron';
import fs from 'fs/promises';

import { LLMApi } from '../api/llm.js';
Expand All @@ -14,6 +13,7 @@ import { mockInterviewService } from './mock-interview.service.js';
import { actionSuggestionService } from './suggestion-action.service.js';
import { liveSuggestionService } from './suggestion-live.service.js';
import { transcriptService } from './transcript.service.js';
import { showSaveDialog } from './window-control.service.js';

/**
* The Word style sheet both exports are rendered with.
Expand Down Expand Up @@ -81,7 +81,7 @@ class ToolsService {
// The document inside follows the interview language; this window is being read by the
// person at the keyboard, so it follows the chrome language.
const strings = uiStrings();
const { canceled, filePath } = await dialog.showSaveDialog({
const { canceled, filePath } = await showSaveDialog({
title: strings.saveTranscriptTitle,
defaultPath: generateExportFilename(format),
filters: isMarkdown
Expand Down Expand Up @@ -125,7 +125,7 @@ class ToolsService {
const isMarkdown = format === 'md';

const strings = uiStrings();
const { canceled, filePath } = await dialog.showSaveDialog({
const { canceled, filePath } = await showSaveDialog({
title: strings.saveMockReportTitle,
defaultPath: generateExportFilename(format, 'mock-interview'),
filters: isMarkdown
Expand Down
106 changes: 100 additions & 6 deletions src/main/services/window-control.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, BrowserWindow, screen } from 'electron';
import { app, BrowserWindow, dialog, screen } from 'electron';

import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS } from '../consts.js';
import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS, RAISE_INTERVAL_MS } from '../consts.js';
import { configStore } from '../store/config.store.js';
import { RunningState } from '../types/app-state.js';
import { isMockInterviewSessionActive } from '../types/mock-interview.js';
Expand All @@ -24,6 +24,17 @@ const ALWAYS_ON_TOP_LEVEL = 'screen-saver' as const;
let win: BrowserWindow | null = null;
let _stealth = configStore.getStealth();

// Cached so the window events that run applySurfaceVisibility do not hit the disk each time.
let _hideTaskbarWhileRunning = readHideTaskbarSetting();

// While surfaces are hidden the window is re-raised on this interval, since something else
// (a call app, a fullscreen share) can still end up above it.
let raiseTimer: NodeJS.Timeout | null = null;

// Last minimizable state we asked for. Changing it alters the window style, which on Windows
// makes the shell re-add the taskbar button, so it is only touched on a real change.
let minimizableApplied: boolean | null = null;

// Last z-order state we asked for. `null` means nothing has been applied to this window yet,
// which is what makes the first call after a window is registered always go through.
let alwaysOnTopApplied: boolean | null = null;
Expand All @@ -33,6 +44,15 @@ let dockVisible: boolean | null = null;
let lastDockCallAt = 0;
let dockRecheckTimer: NodeJS.Timeout | null = null;

function readHideTaskbarSetting(): boolean {
try {
return configStore.getConfig().hideTaskbarWhileRunning === true;
} catch (e) {
console.warn('Failed to read hideTaskbarWhileRunning:', e);
return false;
}
}

// helper: return the display the window mostly occupies (fallback to primary)
function getCurrentDisplay(): Electron.Display {
if (win && !win.isDestroyed()) {
Expand Down Expand Up @@ -75,6 +95,17 @@ interface WindowBounds {
height?: number;
}

/**
* A save dialog owned by the main window. Unowned, it opens as an ordinary window and sits under
* the main window whenever that is pinned on top, out of reach until the pin is released.
*/
export function showSaveDialog(
options: Electron.SaveDialogOptions
): Promise<Electron.SaveDialogReturnValue> {
if (win && !win.isDestroyed()) return dialog.showSaveDialog(win, options);
return dialog.showSaveDialog(options);
}

/**
* Set the window reference for all control functions
*/
Expand All @@ -84,6 +115,8 @@ export function setWindowReference(window: BrowserWindow): void {
// The tracked z-order belongs to the previous window, not this one. Carrying it over would
// read the first real call as a no-op and leave the new window unpinned.
alwaysOnTopApplied = null;
minimizableApplied = null;
stopRaiseTimer();

// 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.
Expand All @@ -93,6 +126,7 @@ export function setWindowReference(window: BrowserWindow): void {
window.on('restore', applySurfaceVisibility);
window.on('maximize', applySurfaceVisibility);
window.on('unmaximize', applySurfaceVisibility);
window.on('closed', stopRaiseTimer);
}

applySurfaceVisibility();
Expand Down Expand Up @@ -120,12 +154,67 @@ function isAssistantRunning(): boolean {
* above the call.
*
* Two independent inputs, either of which is enough. Stealth is the explicit request for it.
* A running assistant is the implicit one: that is precisely when a screen share is likely to be
* live, and when the suggestions are useless if the call window covers them. The two are not
* nested - leaving stealth mid-session must not hand the taskbar button back.
* A running assistant is the implicit one, but only when the user opted in with
* `hideTaskbarWhileRunning`: otherwise the window is an ordinary one, icon and z-order included.
* The two are not nested - leaving stealth mid-session with the setting on must not hand the
* taskbar button back.
*/
function shouldHideSurfaces(): boolean {
return _stealth || isAssistantRunning();
return _stealth || (_hideTaskbarWhileRunning && isAssistantRunning());
}

/**
* Minimize is unavailable whenever the setting is on (even at idle, so there is no gap around a
* session) and in stealth: with the taskbar button gone there is nothing to restore from.
*/
export function canMinimize(): boolean {
return !_stealth && !_hideTaskbarWhileRunning;
}

/** Apply a changed `hideTaskbarWhileRunning` setting to the live window. */
export function setHideTaskbarWhileRunning(value: boolean): void {
_hideTaskbarWhileRunning = value;
refreshWindowSurfaces();
}

function applyMinimizable(): void {
if (!win || win.isDestroyed()) return;
const allowed = canMinimize();
if (minimizableApplied === allowed) return;
try {
win.setMinimizable(allowed);
minimizableApplied = allowed;
// Only the setting, not stealth: restore() activates the window, which must not pull focus
// out of a call. A window minimized before stealth is brought back by restoreWindow().
if (!allowed && !_stealth && win.isMinimized()) win.restore();
} catch (e) {
console.warn('setMinimizable failed:', e);
}
}

function stopRaiseTimer(): void {
if (!raiseTimer) return;
clearInterval(raiseTimer);
raiseTimer = null;
}

function syncRaiseTimer(hidden: boolean): void {
if (!hidden) {
stopRaiseTimer();
return;
}
if (raiseTimer) return;
raiseTimer = setInterval(() => {
if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return;
try {
// moveTop does not take focus and does not re-register the taskbar button, unlike
// re-issuing setAlwaysOnTop.
win.moveTop();
} catch (e) {
console.warn('moveTop failed:', e);
}
}, RAISE_INTERVAL_MS);
raiseTimer.unref?.();
}

/**
Expand All @@ -144,6 +233,9 @@ function applySurfaceVisibility(): void {
const hidden = shouldHideSurfaces();

if (win && !win.isDestroyed()) {
// Style change, so before setSkipTaskbar for the same reason as the z-order below.
applyMinimizable();

// Z-order first. Changing it re-registers the window with the shell and hands the taskbar
// button back, so setSkipTaskbar below has to be the one that runs last of the two.
applyAlwaysOnTop(hidden);
Expand All @@ -154,6 +246,8 @@ function applySurfaceVisibility(): void {
console.warn('setSkipTaskbar failed:', e);
}

syncRaiseTimer(hidden);

// `titleBarStyle: 'hidden'` draws the traffic lights as native chrome, independent of
// setSkipTaskbar/the Dock icon - they stay on screen in stealth mode unless hidden here too.
//
Expand Down
6 changes: 6 additions & 0 deletions src/main/store/config.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export interface RuntimeConfig {
// mock interview: also generate what the live assistant would have suggested for each
// question. On by default - see the note on the default value below.
mockLiveHintsEnabled: boolean;

// Hide the taskbar/Dock icon, pin on top and drop minimize while a live interview runs.
// Off by default: the window is a normal window unless stealth mode is on.
hideTaskbarWhileRunning: boolean;
}

// Default runtime configuration
Expand Down Expand Up @@ -86,6 +90,7 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
// the only answer they are ever offered - and the session bar still turns it off for the run
// where composing unaided is the point.
mockLiveHintsEnabled: true,
hideTaskbarWhileRunning: false,
};

// interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now
Expand Down Expand Up @@ -307,6 +312,7 @@ export const configStore = new ConfigStore();
?.mockLiveSuggestionsEnabled;
migration.mockLiveHintsEnabled = typeof legacy === 'boolean' ? legacy : true;
}
if (raw?.hideTaskbarWhileRunning === undefined) migration.hideTaskbarWhileRunning = false;
// perform migration only if there are values to set
if (Object.keys(migration).length > 0) {
configStore.updateConfig(migration);
Expand Down
7 changes: 6 additions & 1 deletion src/main/window-close-guard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { app, BrowserWindow, ipcMain } from 'electron';

import { appStateService } from './services/app-state.service.js';
import { getWindowReference } from './services/window-control.service.js';
import { getWindowReference, restoreWindow } from './services/window-control.service.js';

/**
* Hold the window open long enough to ask whether the interview should be saved.
Expand Down Expand Up @@ -50,6 +50,11 @@ export function installCloseGuard(win: BrowserWindow): void {
if (wc.isDestroyed() || wc.isCrashed()) return;

event.preventDefault();

// The prompt renders inside this window. A close from the Dock or taskbar can arrive while it
// is minimized or hidden (Cmd+H), and the prompt would then be invisible and the quit look
// ignored. Also on a repeat close, since the window may have been minimized under the prompt.
if (win.isMinimized() || !win.isVisible()) restoreWindow();
if (prompting) return;

prompting = true;
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/components/custom/settings/hide-taskbar-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Checkbox } from '@/components/ui/checkbox';
import { useHideTaskbar } from '@/hooks/use-hide-taskbar';
import { useT } from '@/i18n';

export function HideTaskbarField() {
const t = useT();
const { enabled, setEnabled } = useHideTaskbar();

return (
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">{t.hideTaskbarField.label}</p>
<p className="text-xs text-muted-foreground">{t.hideTaskbarField.description}</p>
</div>
<Checkbox
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
aria-label={t.hideTaskbarField.label}
/>
</label>
);
}
Loading
Loading