diff --git a/CLAUDE.md b/CLAUDE.md index 681b65eb..cc2356eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index e3dbf97c..fc39b4d0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 8208a830..45a14f1a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 diff --git a/src/main/consts.ts b/src/main/consts.ts index c9a244c9..49c639bd 100644 --- a/src/main/consts.ts +++ b/src/main/consts.ts @@ -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 diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index c1cd906d..54d6cd3f 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -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 { @@ -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); diff --git a/src/main/ipc/tools.ts b/src/main/ipc/tools.ts index c64d92ab..0c3cfed3 100644 --- a/src/main/ipc/tools.ts +++ b/src/main/ipc/tools.ts @@ -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'; @@ -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'] }], diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts index 1fbafda6..8bfaf45b 100644 --- a/src/main/ipc/window.ts +++ b/src/main/ipc/window.ts @@ -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', () => { diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index fabd11ca..6254a890 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -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(); diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index 1796bcd8..8374ef82 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -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'; @@ -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. @@ -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 @@ -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 diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index 07902e73..924bd385 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -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'; @@ -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; @@ -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()) { @@ -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 { + if (win && !win.isDestroyed()) return dialog.showSaveDialog(win, options); + return dialog.showSaveDialog(options); +} + /** * Set the window reference for all control functions */ @@ -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. @@ -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(); @@ -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?.(); } /** @@ -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); @@ -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. // diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index c5741139..d65b4e08 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -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 @@ -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 @@ -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); diff --git a/src/main/window-close-guard.ts b/src/main/window-close-guard.ts index 654931bb..50e26039 100644 --- a/src/main/window-close-guard.ts +++ b/src/main/window-close-guard.ts @@ -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. @@ -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; diff --git a/src/renderer/components/custom/settings/hide-taskbar-field.tsx b/src/renderer/components/custom/settings/hide-taskbar-field.tsx new file mode 100644 index 00000000..53f94b8b --- /dev/null +++ b/src/renderer/components/custom/settings/hide-taskbar-field.tsx @@ -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 ( + + ); +} diff --git a/src/renderer/components/custom/titlebar.tsx b/src/renderer/components/custom/titlebar.tsx index 8b09f099..9a6f13e3 100644 --- a/src/renderer/components/custom/titlebar.tsx +++ b/src/renderer/components/custom/titlebar.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppState } from '@/hooks/use-app-state'; import { useCommandPaletteStore } from '@/hooks/use-command-palette'; +import { useHideTaskbar } from '@/hooks/use-hide-taskbar'; import { useInterviewLock } from '@/hooks/use-interview-lock'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; import { useT } from '@/i18n'; @@ -57,6 +58,9 @@ export default function Titlebar() { // surface is the same one. const { locked } = useInterviewLock(); + // No taskbar icon to restore from, and main refuses minimize in this mode too. + const { enabled: minimizeBlocked } = useHideTaskbar(); + if (isStealth) return null; return ( @@ -121,30 +125,32 @@ export default function Titlebar() { {!isMac && ( <> - - - - - -

{t.titlebar.minimize}

-
-
+ + + + + + +

{t.titlebar.minimize}

+
+ + )}