diff --git a/CLAUDE.md b/CLAUDE.md index cc2356eb..5515b277 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -734,7 +734,7 @@ The Dock half has a failure mode of its own: **macOS drops a Dock call made with 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). +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. The `allowScreenCapture` setting (Configuration, off by default) lifts it outside stealth mode, and so does `--disable-content-protection` at launch (dev/testing only). Stealth always protects, whatever either says: a window made capturable for a demo must not stay capturable on the screen share stealth exists for. `applyContentProtection()` runs inside `applySurfaceVisibility()` so the setting applies live and every stealth toggle re-evaluates it, and it skips no-op calls like the z-order does. The app's own action-suggestion screenshot goes through `withContentProtection()`, which protects the window for the capture and waits `CAPTURE_PROTECTION_SETTLE_MS` when it had to switch: the setting is about other apps seeing the window, and that screenshot exists to show the model what is behind it - with the window in it, it can cover the question and feed the model its own earlier answers. `test/content-protection.test.mjs` pins it. Background throttling is disabled globally (via `app.commandLine` switches and `backgroundThrottling: false` in `webPreferences`) so audio keeps running when the window is occluded. diff --git a/README.md b/README.md index 41036cc3..6bc9883a 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, unless you turn on "Hide taskbar icon during live interviews" in Configuration. +The app keeps itself off the surfaces a screen share exposes. There is no desktop shortcut, and the window is hidden from screen capture by default. To show it in screen shares, recordings and screenshots, turn on "Allow screen capture" in Configuration; stealth mode hides it again whatever that setting says, and also removes the Windows taskbar button and the macOS Dock icon and Cmd+Tab entry. Outside stealth mode the app otherwise 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/src/main/consts.ts b/src/main/consts.ts index 49c639bd..bd82755d 100644 --- a/src/main/consts.ts +++ b/src/main/consts.ts @@ -85,6 +85,11 @@ export const ACTION_TIMEOUT_MS = 30_000; // 30 seconds // the failure mode of over-shrinking is silent - a confident answer about a blurry image. export const CAPTURE_MAX_EDGE_PX = 1920; +// How long to wait after re-protecting a capturable window before the app takes its own +// screenshot. setContentProtection lands on the next desktop composition, not when it returns, +// so a capture started at once can still contain the window it was meant to exclude. +export const CAPTURE_PROTECTION_SETTLE_MS = 100; + // How long a suggestion request may spend *reaching* the backend - DNS, TLS, the upload, and the // response headers coming back. Shared by all three suggestion paths, because none of it depends // on what was asked for. diff --git a/src/main/index.ts b/src/main/index.ts index 0acf2db5..9033251d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -159,9 +159,7 @@ async function createWindow() { win.setAutoHideMenuBar(true); } - // Pass --disable-content-protection on the CLI to allow screen recording (dev/testing only) - win.setContentProtection(!process.argv.includes('--disable-content-protection')); - + // Content protection is applied by setWindowReference, from the allowScreenCapture setting. setWindowReference(win); setZoomWindowReference(win); diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index 54d6cd3f..6e18d352 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -1,7 +1,10 @@ import { ipcMain } from 'electron'; import { appStateService } from '../services/app-state.service.js'; -import { setHideTaskbarWhileRunning } from '../services/window-control.service.js'; +import { + setAllowScreenCapture, + setHideTaskbarWhileRunning, +} from '../services/window-control.service.js'; import { configStore, RuntimeConfig } from '../store/config.store.js'; export function registerConfigHandlers(): void { @@ -30,6 +33,10 @@ export function registerConfigHandlers(): void { setHideTaskbarWhileRunning(config.hideTaskbarWhileRunning); } + if (updates.allowScreenCapture !== undefined) { + setAllowScreenCapture(config.allowScreenCapture); + } + return config; } catch (error) { console.error('Failed to update config:', error); diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index d4999a31..52d76e4e 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -29,6 +29,7 @@ import { UuidUtil } from '../utils/uuid.js'; import { actionLockService, ActionType } from './action-lock.service.js'; import { appStateService } from './app-state.service.js'; import { pushNotificationService } from './push-notification.service.js'; +import { withContentProtection } from './window-control.service.js'; export class ActionSuggestionService { private llmApi: LLMApi = new LLMApi(); @@ -378,18 +379,20 @@ export class ActionSuggestionService { // desktopCapturer is Electron's built-in screen-capture API. // A timeout guards against indefinite hangs on restricted or virtual display adapters. - const sources = await Promise.race([ - desktopCapturer.getSources({ - types: ['screen'], - thumbnailSize: { width: captureWidth, height: captureHeight }, - }), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Screenshot timed out after ${ACTION_TIMEOUT_MS}ms`)), - ACTION_TIMEOUT_MS - ) - ), - ]); + const sources = await withContentProtection(() => + Promise.race([ + desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: captureWidth, height: captureHeight }, + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Screenshot timed out after ${ACTION_TIMEOUT_MS}ms`)), + ACTION_TIMEOUT_MS + ) + ), + ]) + ); if (!sources || sources.length === 0) { throw new Error('No screen sources captured from any display'); diff --git a/src/main/services/window-control.service.ts b/src/main/services/window-control.service.ts index 924bd385..c137cd2a 100644 --- a/src/main/services/window-control.service.ts +++ b/src/main/services/window-control.service.ts @@ -1,6 +1,12 @@ import { app, BrowserWindow, dialog, screen } from 'electron'; -import { MIN_HEIGHT, MIN_WIDTH, OPACITY_LEVELS, RAISE_INTERVAL_MS } from '../consts.js'; +import { + CAPTURE_PROTECTION_SETTLE_MS, + 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'; @@ -26,6 +32,11 @@ let _stealth = configStore.getStealth(); // Cached so the window events that run applySurfaceVisibility do not hit the disk each time. let _hideTaskbarWhileRunning = readHideTaskbarSetting(); +let _allowScreenCapture = readAllowScreenCaptureSetting(); + +// Dev/testing override, kept from before the setting existed: launching with it allows capture +// outside stealth whatever the setting says. +const captureForcedByFlag = process.argv.includes('--disable-content-protection'); // 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. @@ -35,6 +46,13 @@ let raiseTimer: NodeJS.Timeout | null = null; // makes the shell re-add the taskbar button, so it is only touched on a real change. let minimizableApplied: boolean | null = null; +// Last content-protection state we asked for, so window events do not re-issue it. +let contentProtectionApplied: boolean | null = null; + +// The app's own screenshots in flight. Each one protects the window for its duration, whatever +// the setting says, so a capturable window does not end up in the image sent to the model. +let ownCaptures = 0; + // 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; @@ -53,6 +71,15 @@ function readHideTaskbarSetting(): boolean { } } +function readAllowScreenCaptureSetting(): boolean { + try { + return configStore.getConfig().allowScreenCapture === true; + } catch (e) { + console.warn('Failed to read allowScreenCapture:', e); + return false; + } +} + // helper: return the display the window mostly occupies (fallback to primary) function getCurrentDisplay(): Electron.Display { if (win && !win.isDestroyed()) { @@ -116,6 +143,7 @@ export function setWindowReference(window: BrowserWindow): void { // read the first real call as a no-op and leave the new window unpinned. alwaysOnTopApplied = null; minimizableApplied = null; + contentProtectionApplied = null; stopRaiseTimer(); // The shell re-registers the taskbar button whenever the window is re-shown or re-shaped, and @@ -171,6 +199,53 @@ export function canMinimize(): boolean { return !_stealth && !_hideTaskbarWhileRunning; } +/** + * Whether the window is hidden from screen capture. Stealth always protects: it is the explicit + * request to be invisible, and a capture setting chosen for an ordinary window must not undo it. + */ +function shouldProtectContent(): boolean { + return _stealth || ownCaptures > 0 || !(_allowScreenCapture || captureForcedByFlag); +} + +/** + * Run the app's own screen capture with the window protected. The user allowing capture is about + * other apps seeing the window; the action-suggestion screenshot exists to show the model what is + * *behind* it, and with the window in the image it can cover the question and feed the model its + * own earlier answers. + */ +export async function withContentProtection(capture: () => Promise): Promise { + ownCaptures++; + try { + const before = contentProtectionApplied; + applyContentProtection(); + if (before === false && contentProtectionApplied === true) { + await new Promise((resolve) => setTimeout(resolve, CAPTURE_PROTECTION_SETTLE_MS)); + } + return await capture(); + } finally { + ownCaptures--; + applyContentProtection(); + } +} + +/** Apply a changed `allowScreenCapture` setting to the live window. */ +export function setAllowScreenCapture(value: boolean): void { + _allowScreenCapture = value; + applySurfaceVisibility(); +} + +function applyContentProtection(): void { + if (!win || win.isDestroyed()) return; + const enabled = shouldProtectContent(); + if (contentProtectionApplied === enabled) return; + try { + if (typeof win.setContentProtection === 'function') win.setContentProtection(enabled); + contentProtectionApplied = enabled; + } catch (e) { + console.warn('setContentProtection failed:', e); + } +} + /** Apply a changed `hideTaskbarWhileRunning` setting to the live window. */ export function setHideTaskbarWhileRunning(value: boolean): void { _hideTaskbarWhileRunning = value; @@ -233,6 +308,9 @@ function applySurfaceVisibility(): void { const hidden = shouldHideSurfaces(); if (win && !win.isDestroyed()) { + // Ahead of setSkipTaskbar like the other window mutations, in case the shell re-registers. + applyContentProtection(); + // Style change, so before setSkipTaskbar for the same reason as the z-order below. applyMinimizable(); diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index d65b4e08..b1b89cfe 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -49,6 +49,10 @@ export interface RuntimeConfig { // 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; + + // Let screen capture see the main window. Off by default: content protection is on unless the + // user opts out here, and stealth mode protects the window whatever this says. + allowScreenCapture: boolean; } // Default runtime configuration @@ -91,6 +95,7 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { // where composing unaided is the point. mockLiveHintsEnabled: true, hideTaskbarWhileRunning: false, + allowScreenCapture: false, }; // interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now @@ -313,6 +318,7 @@ export const configStore = new ConfigStore(); migration.mockLiveHintsEnabled = typeof legacy === 'boolean' ? legacy : true; } if (raw?.hideTaskbarWhileRunning === undefined) migration.hideTaskbarWhileRunning = false; + if (raw?.allowScreenCapture === undefined) migration.allowScreenCapture = false; // perform migration only if there are values to set if (Object.keys(migration).length > 0) { configStore.updateConfig(migration); diff --git a/src/renderer/components/custom/settings/allow-screen-capture-field.tsx b/src/renderer/components/custom/settings/allow-screen-capture-field.tsx new file mode 100644 index 00000000..6442ba2a --- /dev/null +++ b/src/renderer/components/custom/settings/allow-screen-capture-field.tsx @@ -0,0 +1,22 @@ +import { Checkbox } from '@/components/ui/checkbox'; +import { useAllowScreenCapture } from '@/hooks/use-allow-screen-capture'; +import { useT } from '@/i18n'; + +export function AllowScreenCaptureField() { + const t = useT(); + const { enabled, setEnabled } = useAllowScreenCapture(); + + return ( + + ); +} diff --git a/src/renderer/hooks/use-allow-screen-capture.ts b/src/renderer/hooks/use-allow-screen-capture.ts new file mode 100644 index 00000000..914a21c8 --- /dev/null +++ b/src/renderer/hooks/use-allow-screen-capture.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; +import { toast } from 'sonner'; + +import { currentTranslation } from '@/i18n'; + +import { useConfigStore } from './use-config-store'; + +/** Whether the main window may appear in screen capture outside stealth, plus a persisting setter. */ +export function useAllowScreenCapture() { + const { config } = useConfigStore(); + + const setEnabled = useCallback((enabled: boolean) => { + const { updateConfig } = useConfigStore.getState(); + updateConfig({ allowScreenCapture: enabled }).catch((e) => { + console.error('Failed to save screen capture setting', e); + toast.error(currentTranslation().settingsToasts.saveAllowScreenCaptureFailed); + }); + }, []); + + return { enabled: config?.allowScreenCapture === true, setEnabled }; +} diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts index b3987fb5..f77a6c43 100644 --- a/src/renderer/i18n/locales/en.ts +++ b/src/renderer/i18n/locales/en.ts @@ -353,6 +353,11 @@ export const en = { description: 'Off by default. When on, the app leaves the taskbar (and the Dock on macOS) and stays on top of other windows while a live interview is running. The window cannot be minimized while this is on. If the window gets lost, launch the app again to bring it back.', }, + allowScreenCaptureField: { + label: 'Allow screen capture', + description: + "Off by default. When on, the app window appears in screen shares, recordings and other apps' screenshots. The app's own screenshots and stealth mode always leave it out, whatever this is set to.", + }, mockHintsField: { label: 'Show hints in mock interviews', description: @@ -1120,6 +1125,7 @@ export const en = { saveTranscriptPanelFailed: 'Failed to save transcription panel setting', saveMockHintsFailed: 'Failed to save live suggestions setting', saveHideTaskbarFailed: 'Could not save the taskbar setting. Please try again.', + saveAllowScreenCaptureFailed: 'Could not save the screen capture setting. Please try again.', saveUiLanguageFailed: 'Failed to save the app language', }, diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts index 34de2a58..43664983 100644 --- a/src/renderer/i18n/locales/ru.ts +++ b/src/renderer/i18n/locales/ru.ts @@ -365,6 +365,11 @@ export const ru: Translation = { description: 'По умолчанию выключено. Когда включено, приложение убирается с панели задач (и из Dock на macOS) и остаётся поверх других окон, пока идёт живое собеседование. Пока настройка включена, окно нельзя свернуть. Если окно потерялось, запустите приложение ещё раз - оно вернётся.', }, + allowScreenCaptureField: { + label: 'Разрешить захват экрана', + description: + 'По умолчанию выключено. Когда включено, окно приложения видно при демонстрации экрана, в записях и на снимках экрана из других приложений. Собственные снимки приложения и скрытый режим не показывают его никогда, независимо от этой настройки.', + }, mockHintsField: { label: 'Показывать подсказки в пробных собеседованиях', description: @@ -1127,6 +1132,8 @@ export const ru: Translation = { saveTranscriptPanelFailed: 'Не удалось сохранить настройку панели расшифровки', saveMockHintsFailed: 'Не удалось сохранить настройку живых подсказок', saveHideTaskbarFailed: 'Не удалось сохранить настройку панели задач. Попробуйте ещё раз.', + saveAllowScreenCaptureFailed: + 'Не удалось сохранить настройку захвата экрана. Попробуйте ещё раз.', saveUiLanguageFailed: 'Не удалось сохранить язык приложения', }, diff --git a/src/renderer/pages/configuration/index.tsx b/src/renderer/pages/configuration/index.tsx index 7ce9a04e..535f0161 100644 --- a/src/renderer/pages/configuration/index.tsx +++ b/src/renderer/pages/configuration/index.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { HotkeyCheatsheetDialog } from '@/components/custom/hotkey-cheatsheet'; import PageHeader from '@/components/custom/page-header'; +import { AllowScreenCaptureField } from '@/components/custom/settings/allow-screen-capture-field'; import { HideTaskbarField } from '@/components/custom/settings/hide-taskbar-field'; import { LanguageField } from '@/components/custom/settings/language-field'; import { MicrophoneField } from '@/components/custom/settings/microphone-field'; @@ -52,6 +53,7 @@ export default function ConfigurationPage() { + {/* The wizard is not a one-time thing the user is stuck having skipped. Reachable here rather than only on a first launch, which is also what lets it be skippable without diff --git a/src/renderer/types/config.ts b/src/renderer/types/config.ts index 523107c4..2b8c3493 100644 --- a/src/renderer/types/config.ts +++ b/src/renderer/types/config.ts @@ -41,4 +41,7 @@ export interface Config { // Hide the taskbar/Dock icon, pin on top and drop minimize while a live interview runs. hideTaskbarWhileRunning: boolean; + + // Let screen capture see the main window. Stealth mode hides it regardless. + allowScreenCapture: boolean; } diff --git a/test/config-store.test.mjs b/test/config-store.test.mjs index 5776581d..17d1eda2 100644 --- a/test/config-store.test.mjs +++ b/test/config-store.test.mjs @@ -132,6 +132,12 @@ export async function run(userDataDir) { store.configStore.getConfig().hideTaskbarWhileRunning === false ); + // Content protection is the default; capture is an opt-out the user has to choose. + check( + 'allowScreenCapture defaults to off', + store.configStore.getConfig().allowScreenCapture === false + ); + // `lastSessionMode` backed the control bar's split Start button, which no longer exists. // Seeded above, so this pins the scrub rather than merely that RuntimeConfig stopped declaring // it - every read and write in the store spreads the raw stored object through, and nothing diff --git a/test/content-protection.test.mjs b/test/content-protection.test.mjs new file mode 100644 index 00000000..84754b6e --- /dev/null +++ b/test/content-protection.test.mjs @@ -0,0 +1,74 @@ +/** + * Content protection follows the `allowScreenCapture` setting, and stealth overrides it. The + * override is the half a later change could quietly drop: a window the user made capturable for + * a demo would then stay capturable in stealth mode, on the screen share stealth exists for. + */ +import { createChecker, loadMainAs } from './helpers.mjs'; + +export async function run() { + const { check, failures } = createChecker('content-protection'); + + // A fresh instance, so the stealth state other tests leave behind does not leak in. + const windowControl = await loadMainAs('win32', 'services/window-control.service.js'); + + const protection = []; + windowControl.setWindowReference({ + isDestroyed: () => false, + on: () => {}, + setContentProtection: (enabled) => protection.push(enabled), + setAlwaysOnTop: () => {}, + setSkipTaskbar: () => {}, + setMinimizable: () => {}, + setVisibleOnAllWorkspaces: () => {}, + setIgnoreMouseEvents: () => {}, + setFocusable: () => {}, + setOpacity: () => {}, + isMinimized: () => false, + restore: () => {}, + webContents: { send: () => {} }, + }); + + check('the window is protected by default', protection.at(-1) === true); + + windowControl.setAllowScreenCapture(true); + check('allowing capture lifts the protection', protection.at(-1) === false); + + const calls = protection.length; + windowControl.setAllowScreenCapture(true); + check('an unchanged setting is not re-applied', protection.length === calls); + + windowControl.enableStealth(); + check('stealth protects a capturable window', protection.at(-1) === true); + + windowControl.disableStealth(); + check('leaving stealth returns to the setting', protection.at(-1) === false); + + // The app's own screenshot is of what is behind the window, so it protects the window for the + // capture even when the user allowed other apps to see it, and settles before capturing. + protection.length = 0; + let duringCapture; + const result = await windowControl.withContentProtection(async () => { + duringCapture = protection.at(-1); + return 'shot'; + }); + check('an own capture protects a capturable window', duringCapture === true); + check('an own capture returns what the capture returned', result === 'shot'); + check('an own capture restores the setting afterwards', protection.at(-1) === false); + + protection.length = 0; + await windowControl + .withContentProtection(async () => { + throw new Error('capture failed'); + }) + .catch(() => {}); + check('a failed own capture still restores the setting', protection.at(-1) === false); + + windowControl.setAllowScreenCapture(false); + check('turning the setting off protects again', protection.at(-1) === true); + + protection.length = 0; + await windowControl.withContentProtection(async () => {}); + check('an own capture on a protected window changes nothing', protection.length === 0); + + return failures; +} diff --git a/test/run.mjs b/test/run.mjs index 31ab77fd..bebe5a7b 100644 --- a/test/run.mjs +++ b/test/run.mjs @@ -35,6 +35,7 @@ for (const module of [ './stealth-surface.test.mjs', './stealth-toggle.test.mjs', './stealth-dock.test.mjs', + './content-protection.test.mjs', // After the stealth tests: it drives the shared appStateService singleton, and the dock test // reads the same running state through its own copy of window-control. './running-surface.test.mjs',