Skip to content
Closed
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
113 changes: 113 additions & 0 deletions electron/cameraPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, command?: unknown) => unknown>(),
events: new Map<string, () => void>(),
window: {
isDestroyed: vi.fn(() => false),
showInactive: vi.fn(),
hide: vi.fn(),
focus: vi.fn(),
once: vi.fn((event: string, callback: () => void) => mocks.events.set(event, callback)),
on: vi.fn(),
webContents: { send: vi.fn() },
getBounds: vi.fn(() => ({ x: 50, y: 50, width: 368, height: 214 })),
setBounds: vi.fn(),
},
create: vi.fn(),
}));
vi.mock("electron", () => ({
ipcMain: {
handle: (name: string, callback: (event: unknown, command?: unknown) => unknown) =>
mocks.handlers.set(name, callback),
},
screen: {
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
getDisplayMatching: () => ({ workArea: { x: 0, y: 0, width: 1440, height: 900 } }),
},
}));
vi.mock("./windows", () => ({
createCameraPreviewWindow: () => {
mocks.create();
return mocks.window;
},
}));
const originalPlatform = process.platform;
beforeEach(() => {
vi.resetModules();
vi.stubEnv("HEADLESS", "");
vi.clearAllMocks();
mocks.handlers.clear();
mocks.events.clear();
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
});
describe("macOS camera preview", () => {
it("precreates one stable window before enabling the camera, and keeps it across device changes", async () => {
const preview = await import("./cameraPreview");
preview.registerCameraPreview();
expect(preview.getCameraPreviewWindow()).toBe(mocks.window);
preview.syncCameraPreview({ camEnabled: false });
expect(mocks.window.showInactive).not.toHaveBeenCalled();
preview.syncCameraPreview({ camEnabled: true, camDeviceId: "a" });
expect(mocks.window.showInactive).not.toHaveBeenCalled();
mocks.events.get("ready-to-show")?.();
expect(mocks.window.showInactive).toHaveBeenCalled();
preview.syncCameraPreview({ camEnabled: true, camDeviceId: "b" });
expect(mocks.create).toHaveBeenCalledTimes(1);
expect(mocks.window.webContents.send).toHaveBeenLastCalledWith("camera-preview-state", {
enabled: true,
deviceId: "b",
deviceName: undefined,
});
});
it("stops a dismissed preview without changing the recording preferences; explicit Show or a camera toggle reopens it", async () => {
const preview = await import("./cameraPreview");
preview.registerCameraPreview();
const event = { sender: mocks.window.webContents };
preview.syncCameraPreview({ camEnabled: true });
mocks.handlers.get("camera-preview-hide")?.(event);
preview.syncCameraPreview({ camEnabled: true, camDeviceId: "b" });
expect(mocks.handlers.get("camera-preview-state")?.(event)).toEqual({
enabled: false,
deviceId: "b",
deviceName: undefined,
});
preview.showCameraPreview();
expect(mocks.handlers.get("camera-preview-state")?.(event)).toMatchObject({ enabled: true });
mocks.handlers.get("camera-preview-hide")?.(event);
preview.syncCameraPreview({ camEnabled: false });
preview.syncCameraPreview({ camEnabled: true });
expect(mocks.handlers.get("camera-preview-state")?.(event)).toMatchObject({ enabled: true });
});
it("rejects other windows and invalid geometry commands", async () => {
const preview = await import("./cameraPreview");
preview.registerCameraPreview();
preview.syncCameraPreview({ camEnabled: true });
expect(mocks.handlers.get("camera-preview-state")?.({ sender: {} })).toBe(null);
mocks.handlers.get("camera-preview-hide")?.({ sender: {} });
const transform = mocks.handlers.get("camera-preview-transform");
const command = { action: "nudge", direction: "move", dx: 5, dy: 0 };
transform?.({ sender: {} }, command);
transform?.({ sender: mocks.window.webContents }, { ...command, dx: Infinity });
transform?.({ sender: mocks.window.webContents }, { ...command, direction: "invalid" });
expect(mocks.window.setBounds).not.toHaveBeenCalled();
transform?.({ sender: mocks.window.webContents }, command);
expect(mocks.window.setBounds).toHaveBeenCalledWith({ x: 55, y: 50, width: 368, height: 214 });
});
it("does not open a camera window in headless runs", async () => {
vi.stubEnv("HEADLESS", "true");
const preview = await import("./cameraPreview");
preview.registerCameraPreview();
expect(mocks.create).not.toHaveBeenCalled();
});
it.each(["win32", "linux"])("does not start an unprotected preview on %s", async (platform) => {
Object.defineProperty(process, "platform", { value: platform, configurable: true });
const preview = await import("./cameraPreview");
preview.registerCameraPreview();
expect(mocks.create).not.toHaveBeenCalled();
});
});
121 changes: 121 additions & 0 deletions electron/cameraPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { type BrowserWindow, type IpcMainInvokeEvent, ipcMain, screen } from "electron";
import type {
CameraPreviewCommand,
CameraPreviewState,
CameraPreviewTransform,
} from "../src/lib/cameraPreview";
import { cameraPreviewBounds } from "./cameraPreviewBounds";
import { createCameraPreviewWindow } from "./windows";

let preview: BrowserWindow | null = null;
let state: CameraPreviewState = { enabled: false };
let cameraEnabled = false;
let dismissed = false;
let ready = false;
let gesture: {
bounds: Electron.Rectangle;
cursor: Electron.Point;
direction: CameraPreviewTransform;
} | null = null;
const directions = new Set(["move", "nw", "ne", "sw", "se"]);
export function getCameraPreviewWindow() {
return preview;
}

function publish() {
if (!preview || preview.isDestroyed()) return;
preview.webContents.send("camera-preview-state", state);
if (state.enabled && ready) preview.showInactive();
else preview.hide();
}

/** Only a new camera activation or an explicit Show action overrides a manual dismissal. */
export function syncCameraPreview(prefs: {
camEnabled: boolean;
camDeviceId?: string | null;
camDeviceName?: string | null;
}) {
if (!preview || preview.isDestroyed()) return;
if (!prefs.camEnabled || !cameraEnabled) dismissed = false;
cameraEnabled = prefs.camEnabled;
state = {
enabled: cameraEnabled && !dismissed,
deviceId: prefs.camDeviceId ?? undefined,
deviceName: prefs.camDeviceName ?? undefined,
};
publish();
}
export function showCameraPreview(focus = false) {
if (!preview || preview.isDestroyed()) return;
dismissed = false;
state = { ...state, enabled: cameraEnabled };
publish();
if (focus && state.enabled && ready) preview.focus();
}

export function registerCameraPreview() {
if (process.platform !== "darwin" || process.env.HEADLESS === "true") return;
// Precreate before a source can be picked, including while Camera is off.
preview = createCameraPreviewWindow();
preview.once("ready-to-show", () => {
ready = true;
publish();
});
preview.once("closed", () => {
preview = null;
gesture = null;
ready = false;
});
preview.on("blur", () => {
gesture = null;
});
const isPreview = (event: IpcMainInvokeEvent) =>
preview && !preview.isDestroyed() && event.sender === preview.webContents;
ipcMain.handle("camera-preview-state", (event) => (isPreview(event) ? state : null));
ipcMain.handle("camera-preview-hide", (event) => {
if (!isPreview(event)) return;
dismissed = true;
gesture = null;
state = { ...state, enabled: false };
publish();
});
ipcMain.handle("camera-preview-transform", (event, command: CameraPreviewCommand) => {
if (!isPreview(event) || !preview || !state.enabled || !command) return;
if (command.action === "begin" && directions.has(command.direction)) {
gesture = {
bounds: preview.getBounds(),
cursor: screen.getCursorScreenPoint(),
direction: command.direction,
};
} else if (command.action === "end") {
gesture = null;
} else if (command.action === "update" && gesture) {
const cursor = screen.getCursorScreenPoint();
preview.setBounds(
cameraPreviewBounds(
gesture.bounds,
gesture.direction,
{ x: cursor.x - gesture.cursor.x, y: cursor.y - gesture.cursor.y },
screen.getDisplayNearestPoint(cursor).workArea,
),
);
} else if (
command.action === "nudge" &&
directions.has(command.direction) &&
Number.isFinite(command.dx) &&
Number.isFinite(command.dy) &&
Math.abs(command.dx) <= 40 &&
Math.abs(command.dy) <= 40
) {
const bounds = preview.getBounds();
preview.setBounds(
cameraPreviewBounds(
bounds,
command.direction,
{ x: command.dx, y: command.dy },
screen.getDisplayMatching(bounds).workArea,
),
);
}
});
}
30 changes: 30 additions & 0 deletions electron/cameraPreviewBounds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { cameraPreviewBounds } from "./cameraPreviewBounds";

const bounds = { x: 400, y: 300, width: 368, height: 214 };
const area = { x: 0, y: 0, width: 1440, height: 900 };
describe("camera preview geometry", () => {
it("moves across displays with negative origins and clamps into the work area", () => {
expect(
cameraPreviewBounds(bounds, "move", { x: -3000, y: 3000 }, { ...area, x: -1440 }),
).toEqual({ ...bounds, x: -1440, y: 686 });
});
it.each(["nw", "ne", "sw", "se"] as const)("anchors the opposite corner for %s", (direction) => {
const result = cameraPreviewBounds(bounds, direction, { x: 64, y: 32 }, area);
expect((result.width - 16) / (result.height - 16)).toBeCloseTo(16 / 9, 1);
expect(direction.includes("w") ? result.x + result.width : result.x).toBe(
direction.includes("w") ? bounds.x + bounds.width : bounds.x,
);
expect(direction.includes("n") ? result.y + result.height : result.y).toBe(
direction.includes("n") ? bounds.y + bounds.height : bounds.y,
);
});
it("limits both very small and very large resizes", () => {
const small = cameraPreviewBounds(bounds, "se", { x: -10000, y: -10000 }, area);
const large = cameraPreviewBounds(bounds, "se", { x: 10000, y: 10000 }, area);
expect(small.width).toBe(256);
expect(large.width).toBe(736);
expect(large.x + large.width).toBeLessThanOrEqual(area.width);
expect(large.y + large.height).toBeLessThanOrEqual(area.height);
});
});
47 changes: 47 additions & 0 deletions electron/cameraPreviewBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { CameraPreviewTransform } from "../src/lib/cameraPreview";

type Rect = { x: number; y: number; width: number; height: number };
type Point = { x: number; y: number };
export const CAMERA_PREVIEW_GUTTER = 16;
const ASPECT = 16 / 9;

/** Keep the opposite corner anchored and the image (excluding shadow) in 16:9. */
export function cameraPreviewBounds(
bounds: Rect,
direction: CameraPreviewTransform,
delta: Point,
area: Rect,
): Rect {
let { x, y, width, height } = bounds;
if (direction === "move") {
x += delta.x;
y += delta.y;
} else {
const west = direction.includes("w");
const north = direction.includes("n");
const dx = west ? -delta.x : delta.x;
const dy = (north ? -delta.y : delta.y) * ASPECT;
const change = Math.abs(dx) >= Math.abs(dy) ? dx : dy;
const maxWidth =
Math.min(
720,
area.width - CAMERA_PREVIEW_GUTTER,
(area.height - CAMERA_PREVIEW_GUTTER) * ASPECT,
) + CAMERA_PREVIEW_GUTTER;
width = Math.round(
Math.max(
Math.min(240 + CAMERA_PREVIEW_GUTTER, maxWidth),
Math.min(maxWidth, bounds.width + change),
),
);
height = Math.round((width - CAMERA_PREVIEW_GUTTER) / ASPECT) + CAMERA_PREVIEW_GUTTER;
if (west) x += bounds.width - width;
if (north) y += bounds.height - height;
}
return {
x: Math.round(Math.max(area.x, Math.min(x, area.x + area.width - width))),
y: Math.round(Math.max(area.y, Math.min(y, area.y + area.height - height))),
width,
height,
};
}
10 changes: 10 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ interface Window {
onSelectedSourceChanged: (
callback: (source: ProcessedDesktopSource | null) => void,
) => () => void;
getCameraPreviewState?: () => Promise<
import("../src/lib/cameraPreview").CameraPreviewState | null
>;
onCameraPreviewState?: (
callback: (state: import("../src/lib/cameraPreview").CameraPreviewState) => void,
) => () => void;
hideCameraPreview?: () => Promise<void>;
transformCameraPreview?: (
command: import("../src/lib/cameraPreview").CameraPreviewCommand,
) => Promise<void>;
getRecordingPrefs: () => Promise<import("./ipc/handlers").RecordingPrefs>;
setRecordingPrefs: (
prefs: Partial<import("./ipc/handlers").RecordingPrefs>,
Expand Down
8 changes: 6 additions & 2 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { DocumentService } from "../ai-edition/document-service";
import { LlmConfigStore } from "../ai-edition/llm-config-store";
import { StylePresetService } from "../ai-edition/style-preset-service";
import { AppSettingsStore } from "../app-settings";
import { getCameraPreviewWindow, syncCameraPreview } from "../cameraPreview";
import { createRunnerHost } from "../cli/runnerHost";
import { hideCountdownOverlay } from "../countdownOverlay";
import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer";
Expand Down Expand Up @@ -2175,7 +2176,7 @@ export function registerIpcHandlers(
* pick would record them unless the picker is told to leave them out.
*/
function macPickerExcludedWindowIds() {
const appWindowSourceIds = [getMainWindow(), getNotesWindow()]
const appWindowSourceIds = [getMainWindow(), getNotesWindow(), getCameraPreviewWindow()]
.filter((window): window is BrowserWindow => !!window && !window.isDestroyed())
.map((window) => window.getMediaSourceId());
return collectMacCaptureExcludedWindowIds(appWindowSourceIds);
Expand Down Expand Up @@ -2330,6 +2331,7 @@ export function registerIpcHandlers(
getMainWindow,
() => BrowserWindow.getAllWindows(),
(previous, next) => {
syncCameraPreview(next);
// Apple's picker bakes the exclusions into the filter it hands back, so a pick made
// before "Hide desktop icons" changed would still record the desktop the old way.
// Dropping it makes the HUD ask for a new pick instead of ignoring the toggle.
Expand All @@ -2350,6 +2352,8 @@ export function registerIpcHandlers(
},
);

syncCameraPreview(recordingPrefs.get());

// Asked before every take starts. A disk that fills up mid-take used to be found only
// when the take was saved, and the take was lost with it.
ipcMain.handle("check-recording-storage", async () => {
Expand Down Expand Up @@ -3226,7 +3230,7 @@ export function registerIpcHandlers(
pick?.bounds ?? request.source.bounds ?? sourceDisplay?.bounds ?? getSelectedSourceBounds();
const captureExcludedWindowSourceIds: string[] = [];
if (request.source.type === "display") {
for (const window of [getMainWindow(), getNotesWindow()]) {
for (const window of [getMainWindow(), getNotesWindow(), getCameraPreviewWindow()]) {
if (window && !window.isDestroyed()) {
captureExcludedWindowSourceIds.push(window.getMediaSourceId());
}
Expand Down
13 changes: 12 additions & 1 deletion electron/lingeringOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { isOnlyLingeringOverlay } from "./lingeringOverlay";
import { isOnlyLingeringOverlay, onlyLingeringOverlays } from "./lingeringOverlay";

const window = (destroyed = false) => ({ isDestroyed: () => destroyed });

Expand All @@ -21,3 +21,14 @@ describe("isOnlyLingeringOverlay", () => {
expect(isOnlyLingeringOverlay([gone], gone)).toBe(false);
});
});

describe("onlyLingeringOverlays", () => {
it("closes both hidden overlays when no main window remains", () => {
const countdown = window();
const camera = window();
expect(onlyLingeringOverlays([countdown, camera], [countdown, camera])).toBe(true);
expect(onlyLingeringOverlays([camera], [null, camera])).toBe(true);
expect(onlyLingeringOverlays([camera, window()], [countdown, camera])).toBe(false);
expect(onlyLingeringOverlays([], [null, camera])).toBe(false);
});
});
Loading
Loading