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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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, 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.

Expand Down
5 changes: 5 additions & 0 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 1 addition & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
9 changes: 8 additions & 1 deletion src/main/ipc/config.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 15 additions & 12 deletions src/main/services/suggestion-action.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<never>((_, 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<never>((_, 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');
Expand Down
80 changes: 79 additions & 1 deletion src/main/services/window-control.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand All @@ -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;
Expand All @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<T>(capture: () => Promise<T>): Promise<T> {
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;
Expand Down Expand Up @@ -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();

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 @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">{t.allowScreenCaptureField.label}</p>
<p className="text-xs text-muted-foreground">{t.allowScreenCaptureField.description}</p>
</div>
<Checkbox
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
aria-label={t.allowScreenCaptureField.label}
/>
</label>
);
}
21 changes: 21 additions & 0 deletions src/renderer/hooks/use-allow-screen-capture.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
6 changes: 6 additions & 0 deletions src/renderer/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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',
},

Expand Down
7 changes: 7 additions & 0 deletions src/renderer/i18n/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,11 @@ export const ru: Translation = {
description:
'По умолчанию выключено. Когда включено, приложение убирается с панели задач (и из Dock на macOS) и остаётся поверх других окон, пока идёт живое собеседование. Пока настройка включена, окно нельзя свернуть. Если окно потерялось, запустите приложение ещё раз - оно вернётся.',
},
allowScreenCaptureField: {
label: 'Разрешить захват экрана',
description:
'По умолчанию выключено. Когда включено, окно приложения видно при демонстрации экрана, в записях и на снимках экрана из других приложений. Собственные снимки приложения и скрытый режим не показывают его никогда, независимо от этой настройки.',
},
mockHintsField: {
label: 'Показывать подсказки в пробных собеседованиях',
description:
Expand Down Expand Up @@ -1127,6 +1132,8 @@ export const ru: Translation = {
saveTranscriptPanelFailed: 'Не удалось сохранить настройку панели расшифровки',
saveMockHintsFailed: 'Не удалось сохранить настройку живых подсказок',
saveHideTaskbarFailed: 'Не удалось сохранить настройку панели задач. Попробуйте ещё раз.',
saveAllowScreenCaptureFailed:
'Не удалось сохранить настройку захвата экрана. Попробуйте ещё раз.',
saveUiLanguageFailed: 'Не удалось сохранить язык приложения',
},

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/pages/configuration/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +53,7 @@ export default function ConfigurationPage() {
<ZoomField />
<TranscriptPanelField />
<HideTaskbarField />
<AllowScreenCaptureField />

{/* 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
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions test/config-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading