diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 4eb288e1..91c4c31f 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -84,6 +84,23 @@ interface Window { status: string; error?: string; }>; + showFloatingSelfView: (deviceId?: string) => Promise<{ + success: boolean; + error?: string; + }>; + hideFloatingSelfView: () => Promise<{ success: boolean; error?: string }>; + getFloatingSelfViewState: () => Promise<{ open: boolean }>; + onFloatingSelfViewStateChanged: (callback: (state: { open: boolean }) => void) => () => void; + onFloatingSelfViewCommand: ( + callback: (command: { visible: boolean; requestId: number; deviceId?: string }) => void, + ) => () => void; + reportFloatingSelfViewReady: ( + requestId: number, + ) => Promise<{ success: boolean; error?: string }>; + reportFloatingSelfViewFailed: ( + requestId: number, + ) => Promise<{ success: boolean; error?: string }>; + closeFloatingSelfViewWindow: () => Promise<{ success: boolean; error?: string }>; assetBaseUrl: string; storeRecordedVideo: ( videoData: ArrayBuffer, diff --git a/electron/floatingSelfView.test.ts b/electron/floatingSelfView.test.ts new file mode 100644 index 00000000..d5ed1b1b --- /dev/null +++ b/electron/floatingSelfView.test.ts @@ -0,0 +1,224 @@ +import type { BrowserWindow, WebContents } from "electron"; +import { describe, expect, it, vi } from "vitest"; +import { FloatingSelfViewController } from "./floatingSelfView"; + +function fixture() { + const windowHandlers = new Map void>(); + const webContentsHandlers = new Map void>(); + const sender = { + isDestroyed: vi.fn(() => false), + send: vi.fn(), + } as unknown as WebContents; + const hud = { + isDestroyed: vi.fn(() => false), + webContents: sender, + } as unknown as BrowserWindow; + const selfViewSender = { + isDestroyed: vi.fn(() => false), + isLoading: vi.fn(() => false), + send: vi.fn(), + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + webContentsHandlers.set(event, callback); + }), + once: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + webContentsHandlers.set(event, callback); + }), + } as unknown as WebContents; + const selfViewWindow = { + isDestroyed: vi.fn(() => false), + isVisible: vi.fn(() => false), + showInactive: vi.fn(), + setVisibleOnAllWorkspaces: vi.fn(), + setAlwaysOnTop: vi.fn(), + moveTop: vi.fn(), + hide: vi.fn(), + destroy: vi.fn(), + webContents: selfViewSender, + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + windowHandlers.set(event, callback); + }), + } as unknown as BrowserWindow; + const createWindow = vi.fn(() => selfViewWindow); + const controller = new FloatingSelfViewController({ + createWindow, + getHudWindow: () => hud, + showTimeoutMs: 50, + }); + return { + controller, + createWindow, + hud, + sender, + selfViewSender, + selfViewWindow, + webContentsHandlers, + windowHandlers, + }; +} + +describe("capture-safe floating self-view controller", () => { + it("pre-creates one hidden BrowserWindow and opens it only after camera readiness", async () => { + const { controller, createWindow, sender, selfViewSender, selfViewWindow } = fixture(); + controller.precreate(); + controller.precreate(); + expect(createWindow).toHaveBeenCalledTimes(1); + + const show = controller.show(sender, " camera-id "); + expect(selfViewSender.send).toHaveBeenCalledWith("floating-self-view-command", { + visible: true, + requestId: 1, + deviceId: "camera-id", + }); + expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); + + expect(controller.handleReady(selfViewSender, 1)).toEqual({ success: true }); + await expect(show).resolves.toEqual({ success: true }); + expect(selfViewWindow.showInactive).toHaveBeenCalledTimes(1); + expect(selfViewWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledTimes(2); + expect(selfViewWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }); + expect(selfViewWindow.setAlwaysOnTop).toHaveBeenCalledTimes(2); + expect(selfViewWindow.setAlwaysOnTop).toHaveBeenCalledWith(true, "screen-saver", 1); + expect(selfViewWindow.moveTop).toHaveBeenCalledTimes(1); + expect(controller.getState()).toEqual({ open: true }); + }); + + it("rejects every sender except the active HUD webContents", async () => { + const { controller, selfViewSender } = fixture(); + const attacker = {} as WebContents; + + await expect(controller.show(attacker)).resolves.toEqual({ + success: false, + error: "unauthorized-sender", + }); + expect(selfViewSender.send).not.toHaveBeenCalled(); + }); + + it("does not acquire a camera after the HUD is destroyed", async () => { + const { controller, sender, hud, selfViewSender } = fixture(); + vi.mocked(hud.isDestroyed).mockReturnValue(true); + + await expect(controller.show(sender)).resolves.toEqual({ + success: false, + error: "hud-unavailable", + }); + expect(selfViewSender.send).not.toHaveBeenCalled(); + }); + + it("contains camera failure, hides the window, and leaves the caller running", async () => { + const { controller, sender, selfViewSender, selfViewWindow } = fixture(); + const recordingStillActive = vi.fn(() => true); + const show = controller.show(sender); + + expect(controller.handleFailure(selfViewSender, 1)).toEqual({ success: true }); + await expect(show).resolves.toEqual({ + success: false, + error: "camera-unavailable", + }); + expect(selfViewWindow.hide).toHaveBeenCalled(); + expect(recordingStillActive()).toBe(true); + }); + + it("stops the secondary stream command on manual close and HUD teardown", async () => { + const { controller, sender, selfViewSender, selfViewWindow } = fixture(); + const firstShow = controller.show(sender); + controller.handleReady(selfViewSender, 1); + await firstShow; + + expect(controller.handleWindowClose(selfViewSender)).toEqual({ success: true }); + expect(selfViewWindow.hide).toHaveBeenCalledTimes(1); + expect(selfViewSender.send).toHaveBeenLastCalledWith("floating-self-view-command", { + visible: false, + requestId: 2, + }); + + const secondShow = controller.show(sender); + controller.handleReady(selfViewSender, 3); + await secondShow; + controller.hideForHudDestruction(); + expect(controller.getState()).toEqual({ open: false }); + expect(selfViewWindow.hide).toHaveBeenCalledTimes(2); + }); + + it("rejects readiness and close signals from any other renderer", () => { + const { controller } = fixture(); + controller.precreate(); + const attacker = {} as WebContents; + expect(controller.handleReady(attacker, 1)).toEqual({ + success: false, + error: "unauthorized-sender", + }); + expect(controller.handleWindowClose(attacker)).toEqual({ + success: false, + error: "unauthorized-sender", + }); + }); + + it("does not accept a null request ID when no show request is active", () => { + const { controller, selfViewSender, selfViewWindow } = fixture(); + controller.precreate(); + + expect(controller.handleReady(selfViewSender, null)).toEqual({ success: true }); + expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); + expect(selfViewWindow.hide).toHaveBeenCalledTimes(1); + expect(controller.getState()).toEqual({ open: false }); + }); + + it("ignores readiness that arrives after the show request times out", async () => { + vi.useFakeTimers(); + try { + const { controller, sender, selfViewSender, selfViewWindow } = fixture(); + const show = controller.show(sender); + + await vi.advanceTimersByTimeAsync(50); + await expect(show).resolves.toEqual({ + success: false, + error: "request-timeout", + }); + expect(controller.handleReady(selfViewSender, 1)).toEqual({ success: true }); + expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); + expect(selfViewWindow.hide).toHaveBeenCalled(); + expect(controller.getState()).toEqual({ open: false }); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps a replacement request pending when stale readiness arrives", async () => { + const { controller, sender, selfViewSender, selfViewWindow } = fixture(); + const firstShow = controller.show(sender); + const replacementShow = controller.show(sender); + + await expect(firstShow).resolves.toEqual({ + success: false, + error: "self-view-unavailable", + }); + expect(controller.handleReady(selfViewSender, 1)).toEqual({ success: true }); + expect(selfViewWindow.showInactive).not.toHaveBeenCalled(); + expect(controller.getState()).toEqual({ open: false }); + + expect(controller.handleReady(selfViewSender, 2)).toEqual({ success: true }); + await expect(replacementShow).resolves.toEqual({ success: true }); + expect(selfViewWindow.showInactive).toHaveBeenCalledTimes(1); + expect(controller.getState()).toEqual({ open: true }); + }); + + it("does not fail a replacement request when stale camera failure arrives", async () => { + const { controller, sender, selfViewSender, selfViewWindow } = fixture(); + const firstShow = controller.show(sender); + const replacementShow = controller.show(sender); + + await expect(firstShow).resolves.toEqual({ + success: false, + error: "self-view-unavailable", + }); + expect(controller.handleFailure(selfViewSender, 1)).toEqual({ success: true }); + expect(controller.getState()).toEqual({ open: false }); + + controller.handleReady(selfViewSender, 2); + await expect(replacementShow).resolves.toEqual({ success: true }); + expect(selfViewWindow.showInactive).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/floatingSelfView.ts b/electron/floatingSelfView.ts new file mode 100644 index 00000000..13c8fc5e --- /dev/null +++ b/electron/floatingSelfView.ts @@ -0,0 +1,276 @@ +import type { BrowserWindow, WebContents } from "electron"; + +export interface FloatingSelfViewResult { + success: boolean; + error?: + | "unauthorized-sender" + | "hud-unavailable" + | "self-view-unavailable" + | "camera-unavailable" + | "request-timeout"; +} + +export interface FloatingSelfViewState { + open: boolean; +} + +interface PendingShow { + requestId: number; + resolve: (result: FloatingSelfViewResult) => void; + timer: ReturnType; +} + +interface FloatingSelfViewControllerOptions { + createWindow: () => BrowserWindow; + getHudWindow: () => BrowserWindow | null; + showTimeoutMs?: number; +} + +function senderIsCurrentHud(sender: WebContents, currentHud: BrowserWindow | null): boolean { + return Boolean( + currentHud && + !currentHud.isDestroyed() && + !currentHud.webContents.isDestroyed() && + sender === currentHud.webContents, + ); +} + +function normalizeDeviceId(deviceId: unknown): string | undefined { + if (typeof deviceId !== "string") return undefined; + const trimmed = deviceId.trim(); + return trimmed && trimmed.length <= 512 ? trimmed : undefined; +} + +/** + * Owns the capture-safe BrowserWindow fallback used after native macOS PiP + * failed the recorded-file exclusion gate. The window exists (hidden) before + * capture starts; its renderer opens a separate low-resolution camera stream + * only after an authorized HUD asks to show it. + */ +export class FloatingSelfViewController { + private window: BrowserWindow | null = null; + private pendingShow: PendingShow | null = null; + private open = false; + private destroying = false; + private nextRequestId = 0; + private activeRequestId: number | null = null; + private readonly showTimeoutMs: number; + + constructor(private readonly options: FloatingSelfViewControllerOptions) { + this.showTimeoutMs = options.showTimeoutMs ?? 10_000; + } + + precreate(): BrowserWindow { + if (this.window && !this.window.isDestroyed()) return this.window; + + const win = this.options.createWindow(); + this.window = win; + this.destroying = false; + + win.on("close", (event) => { + if (this.destroying) return; + event.preventDefault(); + this.hideInternal(); + }); + win.on("closed", () => { + if (this.window === win) this.window = null; + this.open = false; + this.activeRequestId = null; + this.finishPending({ success: false, error: "self-view-unavailable" }); + this.broadcastState(); + }); + win.webContents.on("render-process-gone", () => { + this.open = false; + this.activeRequestId = null; + this.finishPending({ success: false, error: "self-view-unavailable" }); + this.broadcastState(); + }); + + return win; + } + + getState(): FloatingSelfViewState { + return { open: this.open }; + } + + ownsWindow(candidate: BrowserWindow): boolean { + return this.window === candidate; + } + + async show(sender: WebContents, deviceId?: unknown): Promise { + const hud = this.options.getHudWindow(); + if (!hud || hud.isDestroyed() || hud.webContents.isDestroyed()) { + return { success: false, error: "hud-unavailable" }; + } + if (!senderIsCurrentHud(sender, hud)) { + return { success: false, error: "unauthorized-sender" }; + } + + let win: BrowserWindow; + try { + win = this.precreate(); + } catch { + return { success: false, error: "self-view-unavailable" }; + } + + if (this.open && win.isVisible()) return { success: true }; + this.finishPending({ success: false, error: "self-view-unavailable" }); + const requestId = this.createRequestId(); + this.activeRequestId = requestId; + + return await new Promise((resolve) => { + const timer = setTimeout(() => { + if (this.pendingShow?.requestId !== requestId) return; + this.hideInternal({ success: false, error: "request-timeout" }); + }, this.showTimeoutMs); + this.pendingShow = { requestId, resolve, timer }; + + const requestCamera = () => { + if (win.isDestroyed() || win.webContents.isDestroyed()) { + if (this.activeRequestId === requestId) this.activeRequestId = null; + this.finishPending({ success: false, error: "self-view-unavailable" }); + return; + } + win.webContents.send("floating-self-view-command", { + visible: true, + requestId, + deviceId: normalizeDeviceId(deviceId), + }); + }; + + if (win.webContents.isLoading()) win.webContents.once("did-finish-load", requestCamera); + else requestCamera(); + }); + } + + hide(sender: WebContents): FloatingSelfViewResult { + const hud = this.options.getHudWindow(); + if (!senderIsCurrentHud(sender, hud)) { + return { + success: false, + error: hud ? "unauthorized-sender" : "hud-unavailable", + }; + } + this.hideInternal(); + return { success: true }; + } + + hideForHudDestruction(): void { + this.hideInternal(); + } + + handleReady(sender: WebContents, requestId: unknown): FloatingSelfViewResult { + const win = this.window; + if (!win || win.isDestroyed() || sender !== win.webContents) { + return { success: false, error: "unauthorized-sender" }; + } + if (this.activeRequestId === null || requestId !== this.activeRequestId) { + win.hide(); + return { success: true }; + } + this.open = true; + // This window is pre-created hidden before ScreenCaptureKit enumerates + // exclusions. On macOS a normal window can retain the Space where it was + // created even though the all-workspaces flag was set at construction time. + // Reassert the fullscreen collection behavior on the hidden -> visible + // transition, then once more after showInactive() in case AppKit rebuilt the + // native ordering while showing the non-activating panel. + const allSpacesOptions = { + visibleOnFullScreen: true, + skipTransformProcessType: true, + }; + win.setVisibleOnAllWorkspaces(true, allSpacesOptions); + win.setAlwaysOnTop(true, "screen-saver", 1); + win.showInactive(); + win.setVisibleOnAllWorkspaces(true, allSpacesOptions); + win.setAlwaysOnTop(true, "screen-saver", 1); + // Reassert z-order after every hidden -> visible transition. macOS may + // otherwise leave the panel behind the app that owns the active Space. + win.moveTop(); + this.broadcastState(); + this.finishPending({ success: true }); + return { success: true }; + } + + handleFailure(sender: WebContents, requestId: unknown): FloatingSelfViewResult { + const win = this.window; + if (!win || win.isDestroyed() || sender !== win.webContents) { + return { success: false, error: "unauthorized-sender" }; + } + if (this.activeRequestId === null || requestId !== this.activeRequestId) { + win.hide(); + return { success: true }; + } + this.hideInternal({ success: false, error: "camera-unavailable" }); + return { success: true }; + } + + handleWindowClose(sender: WebContents): FloatingSelfViewResult { + const win = this.window; + if (!win || win.isDestroyed() || sender !== win.webContents) { + return { success: false, error: "unauthorized-sender" }; + } + this.hideInternal(); + return { success: true }; + } + + destroy(): void { + const win = this.window; + this.destroying = true; + this.activeRequestId = null; + this.finishPending({ success: false, error: "self-view-unavailable" }); + if (win && !win.isDestroyed()) { + if (!win.webContents.isDestroyed()) { + win.webContents.send("floating-self-view-command", { + visible: false, + requestId: this.createRequestId(), + }); + } + win.destroy(); + } + this.window = null; + this.open = false; + } + + private hideInternal( + pendingResult: FloatingSelfViewResult = { + success: false, + error: "self-view-unavailable", + }, + ): void { + const win = this.window; + this.activeRequestId = null; + if (win && !win.isDestroyed()) { + if (!win.webContents.isDestroyed()) { + win.webContents.send("floating-self-view-command", { + visible: false, + requestId: this.createRequestId(), + }); + } + win.hide(); + } + const changed = this.open; + this.open = false; + this.finishPending(pendingResult); + if (changed) this.broadcastState(); + } + + private finishPending(result: FloatingSelfViewResult): void { + const pending = this.pendingShow; + if (!pending) return; + this.pendingShow = null; + clearTimeout(pending.timer); + pending.resolve(result); + } + + private createRequestId(): number { + this.nextRequestId += 1; + return this.nextRequestId; + } + + private broadcastState(): void { + const hud = this.options.getHudWindow(); + if (!hud || hud.isDestroyed() || hud.webContents.isDestroyed()) return; + hud.webContents.send("floating-self-view-state-changed", this.getState()); + } +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 958ef6d9..03936926 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -53,6 +53,7 @@ import type { CursorTelemetryReader } from "../ai-edition/deep-agent/service"; import { DocumentService } from "../ai-edition/document-service"; import { LlmConfigStore } from "../ai-edition/llm-config-store"; import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log-buffer"; +import type { FloatingSelfViewController } from "../floatingSelfView"; import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; import { RECORDINGS_DIR } from "../main"; @@ -1419,6 +1420,16 @@ function attachNativeMacCaptureOutputDrain(proc: ChildProcessWithoutNullStreams) proc.once("error", cleanup); } +class NativeMacCaptureStartError extends Error { + constructor( + message: string, + readonly code?: string, + ) { + super(message); + this.name = "NativeMacCaptureStartError"; + } +} + function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1434,7 +1445,12 @@ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { } if (event.event === "error") { cleanup(); - reject(new Error(String(event.message ?? event.code ?? "Native macOS capture failed"))); + reject( + new NativeMacCaptureStartError( + String(event.message ?? event.code ?? "Native macOS capture failed"), + typeof event.code === "string" ? event.code : undefined, + ), + ); } }; @@ -1735,7 +1751,56 @@ export function registerIpcHandlers( getCountdownOverlayWindow?: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void, _switchToHud?: () => void, + floatingSelfViewController?: FloatingSelfViewController, ) { + ipcMain.handle("floating-self-view-show", async (event, deviceId?: unknown) => { + if (process.platform !== "darwin") { + return { success: false, error: "unsupported-platform" }; + } + if (!floatingSelfViewController) { + return { success: false, error: "self-view-unavailable" }; + } + return floatingSelfViewController.show(event.sender, deviceId); + }); + + ipcMain.handle("floating-self-view-hide", (event) => { + if (process.platform !== "darwin" || !floatingSelfViewController) { + return { success: false, error: "unsupported-platform" }; + } + return floatingSelfViewController.hide(event.sender); + }); + + ipcMain.handle("floating-self-view-state", () => { + return floatingSelfViewController?.getState() ?? { open: false }; + }); + + ipcMain.handle("floating-self-view-ready", (event, requestId: unknown) => { + return ( + floatingSelfViewController?.handleReady(event.sender, requestId) ?? { + success: false, + error: "self-view-unavailable", + } + ); + }); + + ipcMain.handle("floating-self-view-failed", (event, requestId: unknown) => { + return ( + floatingSelfViewController?.handleFailure(event.sender, requestId) ?? { + success: false, + error: "self-view-unavailable", + } + ); + }); + + ipcMain.handle("floating-self-view-window-close", (event) => { + return ( + floatingSelfViewController?.handleWindowClose(event.sender) ?? { + success: false, + error: "self-view-unavailable", + } + ); + }); + async function requestScreenAccess() { if (process.platform !== "darwin") { return { success: true, granted: true, status: "granted" }; @@ -2661,6 +2726,15 @@ export function registerIpcHandlers( ...request, schemaVersion: 1, recordingId, + excludedApplicationProcessIds: request.source.type === "display" ? [process.pid] : [], + excludedWindowIds: + request.source.type === "display" + ? BrowserWindow.getAllWindows() + .map((window) => window.getMediaSourceId().match(/^window:(\d+):/)?.[1]) + .filter((id): id is string => Boolean(id)) + .map(Number) + .filter((id) => Number.isSafeInteger(id) && id > 0) + : [], source: { ...request.source, bounds, @@ -2752,7 +2826,11 @@ export function registerIpcHandlers( nativeMacPauseRanges = []; nativeMacIsPaused = false; await stopCursorRecording(); - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return { + success: false, + error: error instanceof Error ? error.message : String(error), + errorCode: error instanceof NativeMacCaptureStartError ? error.code : undefined, + }; } }); diff --git a/electron/main.ts b/electron/main.ts index a85629bf..93b5b195 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -34,6 +34,7 @@ import { parseCliArgs } from "./cli/args"; import { runCli } from "./cli/cliMain"; import { isDiagnosticModeEnabled, mainLogBuffer } from "./diagnostics/main-log-buffer"; import { buildEditMenuSubmenu, type EditorUndoRedoChannel, routeEditorUndoRedo } from "./edit-menu"; +import { FloatingSelfViewController } from "./floatingSelfView"; import { loadAndRegisterGlobalShortcut, registerOpenAppShortcut, @@ -52,6 +53,7 @@ import { checkLatestRelease } from "./update-checker"; import { createCountdownOverlayWindow, createEditorWindow, + createFloatingSelfViewWindow, createHudOverlayWindow, createNotesWindow, createSourceSelectorWindow, @@ -126,6 +128,7 @@ let mainWindow: BrowserWindow | null = null; let sourceSelectorWindow: BrowserWindow | null = null; let countdownOverlayWindow: BrowserWindow | null = null; let notesWindow: BrowserWindow | null = null; +let floatingSelfViewController: FloatingSelfViewController | null = null; let tray: Tray | null = null; let selectedSourceName = ""; const isMac = process.platform === "darwin"; @@ -135,12 +138,28 @@ const trayIconSize = isMac ? 16 : 24; const defaultTrayIcon = getTrayIcon("openscreen.png", trayIconSize); const recordingTrayIcon = getTrayIcon("rec-button.png", trayIconSize); +function quitIfOnlyFloatingSelfViewRemains() { + if (cliCommand) return; + setImmediate(() => { + const hasPrimaryWindow = BrowserWindow.getAllWindows().some( + (window) => !window.isDestroyed() && !floatingSelfViewController?.ownsWindow(window), + ); + if (!hasPrimaryWindow) app.quit(); + }); +} + function createWindow() { if (mainWindow && !mainWindow.isDestroyed()) { return; } - mainWindow = createHudOverlayWindow(); + const hudWindow = createHudOverlayWindow(); + mainWindow = hudWindow; + hudWindow.once("closed", () => { + if (mainWindow === hudWindow) mainWindow = null; + floatingSelfViewController?.hideForHudDestruction(); + quitIfOnlyFloatingSelfViewRemains(); + }); } function showMainWindow() { @@ -876,6 +895,11 @@ function createEditorWindowWrapper() { // "cancel": flag reset, window stays open }); }); + const editorWindow = mainWindow; + editorWindow.once("closed", () => { + if (mainWindow === editorWindow) mainWindow = null; + quitIfOnlyFloatingSelfViewRemains(); + }); } function createSourceSelectorWindowWrapper() { @@ -885,6 +909,7 @@ function createSourceSelectorWindowWrapper() { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("source-selector-closed"); } + quitIfOnlyFloatingSelfViewRemains(); }); return sourceSelectorWindow; } @@ -897,6 +922,7 @@ function createNotesWindowWrapper() { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("notes-window-closed"); } + quitIfOnlyFloatingSelfViewRemains(); }); return notesWindow; } @@ -910,6 +936,7 @@ function createCountdownOverlayWindowWrapper() { countdownOverlayWindow = createCountdownOverlayWindow(); countdownOverlayWindow.on("closed", () => { countdownOverlayWindow = null; + quitIfOnlyFloatingSelfViewRemains(); }); return countdownOverlayWindow; } @@ -954,6 +981,7 @@ app.on("before-quit", (event) => { // quit is deferred below: the user asked to leave, and a check they can re-run from the // tray is not worth holding the helper's teardown behind. updateCheckAbort?.abort(); + floatingSelfViewController?.destroy(); if (sttShutdownFinished) return; event.preventDefault(); if (sttShutdownPromise) return; @@ -1142,6 +1170,17 @@ appReady?.then(async () => { setupApplicationMenu(); await ensureRecordingsDir(); + if (process.platform === "darwin") { + floatingSelfViewController = new FloatingSelfViewController({ + createWindow: createFloatingSelfViewWindow, + getHudWindow: () => mainWindow, + }); + // The native window ID must exist before ScreenCaptureKit enumerates + // shareable content for a display recording. The camera itself stays closed + // until a recording starts and the HUD asks to show this hidden window. + floatingSelfViewController.precreate(); + } + function switchToHudWrapper() { if (mainWindow) { isForceClosing = true; @@ -1174,6 +1213,7 @@ appReady?.then(async () => { } }, switchToHudWrapper, + floatingSelfViewController ?? undefined, ); // Native STT (whisper.cpp + forced alignment) — single instance per app. diff --git a/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/CaptureExclusion.swift b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/CaptureExclusion.swift new file mode 100644 index 00000000..6a64592c --- /dev/null +++ b/electron/native/screencapturekit/Sources/OpenScreenCaptureCore/CaptureExclusion.swift @@ -0,0 +1,127 @@ +import Foundation + +public struct CaptureExclusionRequestFields: Decodable, Equatable, Sendable { + public let applicationProcessIDs: [Int32] + public let windowIDs: [UInt32] + + private enum CodingKeys: String, CodingKey { + case excludedApplicationProcessIds + case excludedWindowIds + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + applicationProcessIDs = + try container.decodeIfPresent([Int32].self, forKey: .excludedApplicationProcessIds) ?? [] + windowIDs = try container.decodeIfPresent([UInt32].self, forKey: .excludedWindowIds) ?? [] + } +} + +public struct CaptureExclusionApplication: Equatable, Sendable { + public let processID: Int32 + public let bundleIdentifier: String? + + public init(processID: Int32, bundleIdentifier: String?) { + self.processID = processID + self.bundleIdentifier = bundleIdentifier + } +} + +public enum CaptureExclusionStrategy: Equatable, Sendable { + case none + case applications(bundleIdentifiers: [String], processIDs: [Int32]) + case windows(windowIDs: [UInt32]) +} + +public struct CaptureExclusionResolution: Equatable, Sendable { + public let strategy: CaptureExclusionStrategy + public let requestedProcessIDs: [Int32] + public let requestedWindowIDs: [UInt32] + public let matchedApplicationCount: Int + public let matchedWindowCount: Int + + public init( + strategy: CaptureExclusionStrategy, + requestedProcessIDs: [Int32], + requestedWindowIDs: [UInt32], + matchedApplicationCount: Int, + matchedWindowCount: Int + ) { + self.strategy = strategy + self.requestedProcessIDs = requestedProcessIDs + self.requestedWindowIDs = requestedWindowIDs + self.matchedApplicationCount = matchedApplicationCount + self.matchedWindowCount = matchedWindowCount + } +} + +public enum CaptureExclusionResolutionError: Error, Equatable { + case safeExclusionUnavailable +} + +/// Selects one safe ScreenCaptureKit filter strategy without importing the framework. +/// Window IDs are a separate fallback; they are never treated as `exceptingWindows`. +public func resolveCaptureExclusion( + requestedProcessIDs: [Int32], + requestedWindowIDs: [UInt32], + applications: [CaptureExclusionApplication], + availableWindowIDs: [UInt32] +) throws -> CaptureExclusionResolution { + let uniqueProcessIDs = Array(Set(requestedProcessIDs)).sorted() + let uniqueWindowIDs = Array(Set(requestedWindowIDs)).sorted() + if uniqueProcessIDs.isEmpty && uniqueWindowIDs.isEmpty { + return CaptureExclusionResolution( + strategy: .none, + requestedProcessIDs: [], + requestedWindowIDs: [], + matchedApplicationCount: 0, + matchedWindowCount: 0 + ) + } + + let requestedPIDSet = Set(uniqueProcessIDs) + let directlyMatched = applications.filter { requestedPIDSet.contains($0.processID) } + let bundleIdentifiers = Array( + Set(directlyMatched.compactMap { application in + guard let bundle = application.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundle.isEmpty + else { return nil } + return bundle + }) + ).sorted() + + if !bundleIdentifiers.isEmpty { + let bundleSet = Set(bundleIdentifiers) + let expandedProcessIDs = applications + .filter { application in + guard let bundle = application.bundleIdentifier else { return false } + return bundleSet.contains(bundle) + } + .map(\.processID) + .sorted() + return CaptureExclusionResolution( + strategy: .applications( + bundleIdentifiers: bundleIdentifiers, + processIDs: expandedProcessIDs + ), + requestedProcessIDs: uniqueProcessIDs, + requestedWindowIDs: uniqueWindowIDs, + matchedApplicationCount: expandedProcessIDs.count, + matchedWindowCount: 0 + ) + } + + let availableWindowSet = Set(availableWindowIDs) + let matchedWindowIDs = uniqueWindowIDs.filter { availableWindowSet.contains($0) } + if !uniqueWindowIDs.isEmpty && matchedWindowIDs.count == uniqueWindowIDs.count { + return CaptureExclusionResolution( + strategy: .windows(windowIDs: matchedWindowIDs), + requestedProcessIDs: uniqueProcessIDs, + requestedWindowIDs: uniqueWindowIDs, + matchedApplicationCount: 0, + matchedWindowCount: matchedWindowIDs.count + ) + } + + throw CaptureExclusionResolutionError.safeExclusionUnavailable +} diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift index 5add8074..974b4b40 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift @@ -65,12 +65,34 @@ struct RecordingRequest: Decodable { let schemaVersion: Int? let recordingId: Int? + let excludedApplicationProcessIds: [Int32] + let excludedWindowIds: [UInt32] let source: Source let video: Video let audio: Audio let webcam: Webcam let cursor: Cursor let outputs: Outputs + + private enum CodingKeys: String, CodingKey { + case schemaVersion, recordingId, excludedApplicationProcessIds, excludedWindowIds + case source, video, audio, webcam, cursor, outputs + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let exclusionFields = try CaptureExclusionRequestFields(from: decoder) + schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) + recordingId = try container.decodeIfPresent(Int.self, forKey: .recordingId) + excludedApplicationProcessIds = exclusionFields.applicationProcessIDs + excludedWindowIds = exclusionFields.windowIDs + source = try container.decode(Source.self, forKey: .source) + video = try container.decode(Video.self, forKey: .video) + audio = try container.decode(Audio.self, forKey: .audio) + webcam = try container.decode(Webcam.self, forKey: .webcam) + cursor = try container.decode(Cursor.self, forKey: .cursor) + outputs = try container.decode(Outputs.self, forKey: .outputs) + } } enum HelperError: Error, CustomStringConvertible { @@ -81,6 +103,16 @@ enum HelperError: Error, CustomStringConvertible { case invalidSourceType(String) case permissionDenied(String) case writerSetupFailed(String) + case selfCaptureExclusionFailed(String) + + var code: String { + switch self { + case .selfCaptureExclusionFailed: + return "self-capture-exclusion-failed" + default: + return "helper-error" + } + } var description: String { switch self { @@ -98,6 +130,8 @@ enum HelperError: Error, CustomStringConvertible { return message case .writerSetupFailed(let message): return message + case .selfCaptureExclusionFailed(let message): + return message } } } @@ -149,7 +183,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { let content = try await SCShareableContent.excludingDesktopWindows( false, - onScreenWindowsOnly: true + onScreenWindowsOnly: false ) let target = try makeCaptureTarget(from: content) outputWidth = target.width @@ -387,7 +421,71 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { guard let display = content.displays.first(where: { $0.displayID == displayId }) else { throw HelperError.sourceNotFound("No ScreenCaptureKit display found for id \(displayId).") } - let filter = SCContentFilter(display: display, excludingWindows: []) + let applicationIdentities = content.applications.map { + CaptureExclusionApplication( + processID: $0.processID, + bundleIdentifier: $0.bundleIdentifier + ) + } + let resolution: CaptureExclusionResolution + do { + resolution = try resolveCaptureExclusion( + requestedProcessIDs: request.excludedApplicationProcessIds, + requestedWindowIDs: request.excludedWindowIds, + applications: applicationIdentities, + availableWindowIDs: content.windows.map(\.windowID) + ) + } catch { + emit([ + "event": "capture-exclusion-resolution", + "strategy": "failed", + "requestedProcessIds": request.excludedApplicationProcessIds, + "requestedWindowIds": request.excludedWindowIds, + "availableApplicationCount": content.applications.count, + "availableWindowCount": content.windows.count, + ]) + throw HelperError.selfCaptureExclusionFailed( + "OpenScreen could not safely exclude itself from this full-display recording." + ) + } + + let filter: SCContentFilter + switch resolution.strategy { + case .applications(let bundleIdentifiers, let processIDs): + let bundleSet = Set(bundleIdentifiers) + let excludedApplications = content.applications.filter { + bundleSet.contains($0.bundleIdentifier) + } + filter = SCContentFilter( + display: display, + excludingApplications: excludedApplications, + exceptingWindows: [] + ) + emit([ + "event": "capture-exclusion-resolution", + "strategy": "applications", + "requestedProcessIds": resolution.requestedProcessIDs, + "resolvedProcessIds": processIDs, + "bundleIdentifiers": bundleIdentifiers, + "matchedApplicationCount": resolution.matchedApplicationCount, + ]) + case .windows(let windowIDs): + let windowSet = Set(windowIDs) + let excludedWindows = content.windows.filter { windowSet.contains($0.windowID) } + filter = SCContentFilter(display: display, excludingWindows: excludedWindows) + emit([ + "event": "capture-exclusion-resolution", + "strategy": "windows", + "requestedProcessIds": resolution.requestedProcessIDs, + "requestedWindowIds": resolution.requestedWindowIDs, + "resolvedWindowIds": windowIDs, + "matchedWindowCount": resolution.matchedWindowCount, + ]) + case .none: + throw HelperError.selfCaptureExclusionFailed( + "OpenScreen did not receive a safe exclusion target for this full-display recording." + ) + } let size = captureSize( for: filter, fallbackPointSize: display.frame.size, @@ -850,7 +948,7 @@ struct OpenScreenScreenCaptureKitHelper { try await recorder.start() await stopTask.value } catch let error as HelperError { - emitError(code: "helper-error", message: error.description) + emitError(code: error.code, message: error.description) exit(1) } catch { emitError(code: "helper-error", message: "\(error)") diff --git a/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/CaptureExclusionTests.swift b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/CaptureExclusionTests.swift new file mode 100644 index 00000000..1b817073 --- /dev/null +++ b/electron/native/screencapturekit/Tests/OpenScreenCaptureCoreTests/CaptureExclusionTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import OpenScreenCaptureCore + +final class CaptureExclusionTests: XCTestCase { + private let applications = [ + CaptureExclusionApplication(processID: 10, bundleIdentifier: "com.openscreen.app"), + CaptureExclusionApplication(processID: 11, bundleIdentifier: "com.openscreen.app"), + CaptureExclusionApplication(processID: 20, bundleIdentifier: "com.example.other"), + ] + + func testExpandsRequestedPIDToEveryApplicationWithSameBundle() throws { + let result = try resolveCaptureExclusion( + requestedProcessIDs: [10], + requestedWindowIDs: [100], + applications: applications, + availableWindowIDs: [100] + ) + XCTAssertEqual( + result.strategy, + .applications(bundleIdentifiers: ["com.openscreen.app"], processIDs: [10, 11]) + ) + XCTAssertEqual(result.matchedApplicationCount, 2) + } + + func testUsesResolvedWindowsOnlyWhenApplicationResolutionIsUnavailable() throws { + let result = try resolveCaptureExclusion( + requestedProcessIDs: [999], + requestedWindowIDs: [101, 100, 101], + applications: applications, + availableWindowIDs: [100, 101, 200] + ) + XCTAssertEqual(result.strategy, .windows(windowIDs: [100, 101])) + XCTAssertEqual(result.matchedWindowCount, 2) + } + + func testRefusesPartialWindowResolution() { + XCTAssertThrowsError( + try resolveCaptureExclusion( + requestedProcessIDs: [999], + requestedWindowIDs: [100, 101], + applications: applications, + availableWindowIDs: [100] + ) + ) { error in + XCTAssertEqual(error as? CaptureExclusionResolutionError, .safeExclusionUnavailable) + } + } + + func testRefusesMissingApplicationAndWindowMatches() { + XCTAssertThrowsError( + try resolveCaptureExclusion( + requestedProcessIDs: [999], + requestedWindowIDs: [], + applications: applications, + availableWindowIDs: [] + ) + ) + } + + func testLegacyRequestWithoutExclusionsSelectsNoFiltering() throws { + let fields = try JSONDecoder().decode( + CaptureExclusionRequestFields.self, + from: Data("{}".utf8) + ) + XCTAssertEqual(fields.applicationProcessIDs, []) + XCTAssertEqual(fields.windowIDs, []) + + let result = try resolveCaptureExclusion( + requestedProcessIDs: [], + requestedWindowIDs: [], + applications: applications, + availableWindowIDs: [100] + ) + XCTAssertEqual(result.strategy, .none) + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 6aff1640..ef5e12b3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -156,6 +156,38 @@ contextBridge.exposeInMainWorld("electronAPI", { requestNativeMacCursorAccess: () => { return ipcRenderer.invoke("request-native-mac-cursor-access"); }, + showFloatingSelfView: (deviceId?: string) => + ipcRenderer.invoke("floating-self-view-show", deviceId) as Promise<{ + success: boolean; + error?: string; + }>, + hideFloatingSelfView: () => + ipcRenderer.invoke("floating-self-view-hide") as Promise<{ + success: boolean; + error?: string; + }>, + getFloatingSelfViewState: () => + ipcRenderer.invoke("floating-self-view-state") as Promise<{ open: boolean }>, + onFloatingSelfViewStateChanged: (callback: (state: { open: boolean }) => void) => { + const listener = (_event: unknown, state: { open: boolean }) => callback(state); + ipcRenderer.on("floating-self-view-state-changed", listener); + return () => ipcRenderer.removeListener("floating-self-view-state-changed", listener); + }, + onFloatingSelfViewCommand: ( + callback: (command: { visible: boolean; requestId: number; deviceId?: string }) => void, + ) => { + const listener = ( + _event: unknown, + command: { visible: boolean; requestId: number; deviceId?: string }, + ) => callback(command); + ipcRenderer.on("floating-self-view-command", listener); + return () => ipcRenderer.removeListener("floating-self-view-command", listener); + }, + reportFloatingSelfViewReady: (requestId: number) => + ipcRenderer.invoke("floating-self-view-ready", requestId), + reportFloatingSelfViewFailed: (requestId: number) => + ipcRenderer.invoke("floating-self-view-failed", requestId), + closeFloatingSelfViewWindow: () => ipcRenderer.invoke("floating-self-view-window-close"), storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke("store-recorded-video", videoData, fileName); }, diff --git a/electron/windows.ts b/electron/windows.ts index 4b5ceb7f..629b6205 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -50,8 +50,8 @@ const CONTENT_PROTECTION_FORCED = process.env["OPENSCREEN_FORCE_CONTENT_PROTECTI * partly true regardless — ScreenCaptureKit ignores `sharingType`, so any * SCK-based recorder (including *ours*, see * `electron/native/screencapturekit/`) captures these windows anyway. The - * durable fix is to exclude our own windows via `SCContentFilter`'s - * `excludingWindows:`, which that helper currently passes as `[]`. + * durable fix is the helper's application-exclusion filter, with a fully + * resolved own-window exclusion filter as its fail-closed fallback. */ const CONTENT_PROTECTION_BREAKS_DISPLAY = (() => { if (process.platform !== "darwin") return false; @@ -381,6 +381,77 @@ export function createHudOverlayWindow(): BrowserWindow { return win; } +/** + * Capture-safe macOS webcam self-view. + * + * This window is deliberately created while still hidden, before a display + * recording can start. ScreenCaptureKit can then resolve it as an ordinary + * OpenScreen BrowserWindow when the application-exclusion filter is built. + * Native video PiP cannot be used here: on macOS 26 its special window is + * visible in full-display recordings even when the owning application is + * excluded. + */ +export function createFloatingSelfViewWindow(): BrowserWindow { + const { workArea } = screen.getPrimaryDisplay(); + const width = 320; + const height = 180; + const margin = 24; + const win = new BrowserWindow({ + width, + height, + minWidth: 240, + minHeight: 135, + maxWidth: 640, + maxHeight: 360, + x: workArea.x + workArea.width - width - margin, + y: workArea.y + workArea.height - height - margin, + frame: false, + // Electron's macOS `panel` type adds the non-activating panel style mask. + // Unlike a normal hidden BrowserWindow, it is explicitly designed to float + // above fullscreen apps and appear on every Space. This window is macOS-only + // (the controller is created only in the Darwin boot path). + type: "panel", + backgroundColor: "#050608", + resizable: true, + movable: true, + fullscreenable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: true, + show: false, + title: "OpenScreen self-view", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + additionalArguments: [ASSET_BASE_URL_ARG], + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); + + win.setAspectRatio(16 / 9); + applyContentProtection(win, "floating self-view"); + if (process.platform === "darwin") { + // NSFloatingWindowLevel can still fall behind a maximized app or a native + // fullscreen Space. The self-view is an explicit recording control, so use + // Electron's documented fullscreen-safe level while recording and keep the + // all-Spaces behavior below. Capture safety comes from ScreenCaptureKit's + // application filter and content protection, not from the window level. + win.setAlwaysOnTop(true, "screen-saver", 1); + win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=floating-self-view"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "floating-self-view" }, + }); + } + + return win; +} + /** * Main editor window. Starts maximised with a hidden title bar on macOS; not * always-on-top and appears in the taskbar/dock. diff --git a/scripts/build-macos-compositor-addon.mjs b/scripts/build-macos-compositor-addon.mjs index 200b9cd8..9b8779ac 100644 --- a/scripts/build-macos-compositor-addon.mjs +++ b/scripts/build-macos-compositor-addon.mjs @@ -27,6 +27,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { parseOtoolReferences } from "./macos-ffmpeg-relocation.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); @@ -157,10 +158,9 @@ function installAtomically(from, to) { function vendorFfmpegDylibs(nodePath, ffmpegDir) { const outDir = path.dirname(nodePath); const libDir = path.join(ffmpegDir, "lib"); - const linked = execFileSync("otool", ["-L", nodePath], { encoding: "utf8" }) - .split("\n") - .map((line) => line.trim().split(" ")[0]) - .filter((p) => /\/lib(av|sw)\w+\.\d+\.dylib$/.test(p)); + const linked = parseOtoolReferences( + execFileSync("otool", ["-L", nodePath], { encoding: "utf8" }), + ).filter((p) => /\/lib(av|sw)\w+\.\d+\.dylib$/.test(p)); if (linked.length === 0) { throw new Error(`${nodePath} links no ffmpeg dylib — nothing to vendor, which is wrong.`); } @@ -178,10 +178,9 @@ function vendorFfmpegDylibs(nodePath, ffmpegDir) { } // Inter-library references, and the addon's own. for (const target of [...names.map((n) => path.join(outDir, n)), nodePath]) { - const deps = execFileSync("otool", ["-L", target], { encoding: "utf8" }) - .split("\n") - .map((line) => line.trim().split(" ")[0]) - .filter((p) => p.startsWith("/") && /lib(av|sw)\w+\.\d+\.dylib$/.test(p)); + const deps = parseOtoolReferences( + execFileSync("otool", ["-L", target], { encoding: "utf8" }), + ).filter((p) => p.startsWith("/") && /lib(av|sw)\w+\.\d+\.dylib$/.test(p)); for (const dep of deps) { execFileSync("install_name_tool", ["-change", dep, `@rpath/${path.basename(dep)}`, target]); } @@ -210,11 +209,7 @@ function vendorFfmpegDylibs(nodePath, ffmpegDir) { .slice(1) // first line is the filename echoed back .map((l) => l.trim()) .filter(Boolean); - const deps = execFileSync("otool", ["-L", file], { encoding: "utf8" }) - .split("\n") - .slice(1) // ditto - .map((l) => l.trim().split(" ")[0]) - .filter(Boolean); + const deps = parseOtoolReferences(execFileSync("otool", ["-L", file], { encoding: "utf8" })); return [...id, ...deps].filter( (p) => p.startsWith("/") && !p.startsWith("/usr/lib/") && !p.startsWith("/System/"), ); diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index d611aa62..05c03dd3 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -23,6 +23,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { relocateMacFfmpegInstall } from "./macos-ffmpeg-relocation.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); @@ -167,6 +168,10 @@ if (fs.existsSync(path.join(DEST, "include"))) { const work = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-ffmpeg-")); const tarball = path.join(work, `ffmpeg-${VERSION}.tar.xz`); +// FFmpeg writes the configure prefix into shell/make fragments without quoting +// it. A normal checkout such as `Vibe coding/openscreen` therefore fails at the +// final dylib link. The completed SDK is relocated to DEST after installation. +const stagedDest = path.join(work, "install"); downloadTarball(tarball); @@ -179,7 +184,7 @@ console.log("Configuring (LGPL, shared)…"); run( "./configure", [ - `--prefix=${DEST}`, + `--prefix=${stagedDest}`, "--enable-shared", "--disable-static", "--disable-doc", @@ -200,6 +205,7 @@ run( "--enable-videotoolbox", "--enable-audiotoolbox", "--disable-x86asm", + "--extra-ldflags=-Wl,-headerpad_max_install_names", `--arch=${process.arch === "arm64" ? "arm64" : "x86_64"}`, "--cc=clang", ], @@ -211,6 +217,8 @@ const jobs = execFileSync("sysctl", ["-n", "hw.ncpu"], { encoding: "utf8" }).tri run("make", ["-j", jobs], { cwd: src }); run("make", ["install"], { cwd: src }); +relocateMacFfmpegInstall(stagedDest, DEST); + if (!isLgpl(DEST)) { fs.rmSync(DEST, { recursive: true, force: true }); throw new Error( diff --git a/scripts/macos-ffmpeg-relocation.mjs b/scripts/macos-ffmpeg-relocation.mjs new file mode 100644 index 00000000..ea7db5bf --- /dev/null +++ b/scripts/macos-ffmpeg-relocation.mjs @@ -0,0 +1,97 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +/** Replaces only an exact path prefix or one of its descendants. */ +export function relocatePathPrefix(value, from, to) { + const relative = path.relative(from, value); + if (relative === "") return to; + if (relative.startsWith("..") || path.isAbsolute(relative)) return value; + return path.join(to, relative); +} + +/** Parses `otool -L`/`-D` without treating spaces inside a path as separators. */ +export function parseOtoolReferences(output) { + return output + .split("\n") + .slice(1) + .map((line) => line.trim().replace(/\s+\(compatibility version .*$/, "")) + .filter(Boolean); +} + +function otoolLines(flag, file) { + return parseOtoolReferences(execFileSync("otool", [flag, file], { encoding: "utf8" })); +} + +function rewriteMachO(file, stagedPrefix, destinationPrefix, isLibrary) { + let changed = false; + + if (isLibrary) { + const oldId = otoolLines("-D", file)[0]; + const newId = oldId ? relocatePathPrefix(oldId, stagedPrefix, destinationPrefix) : oldId; + if (oldId && newId !== oldId) { + execFileSync("install_name_tool", ["-id", newId, file]); + changed = true; + } + } + + for (const dependency of otoolLines("-L", file)) { + const relocated = relocatePathPrefix(dependency, stagedPrefix, destinationPrefix); + if (relocated !== dependency) { + execFileSync("install_name_tool", ["-change", dependency, relocated, file]); + changed = true; + } + } + + if (changed) execFileSync("codesign", ["--force", "--sign", "-", file]); +} + +export function makeDylibSymlinksRelative(destinationPrefix) { + const libDir = path.join(destinationPrefix, "lib"); + for (const name of fs.readdirSync(libDir)) { + const file = path.join(libDir, name); + if (!fs.lstatSync(file).isSymbolicLink()) continue; + const target = fs.readlinkSync(file); + if (!path.isAbsolute(target)) continue; + + const localTarget = path.basename(target); + if (!fs.existsSync(path.join(libDir, localTarget))) { + throw new Error(`${file} points outside the relocated SDK: ${target}`); + } + fs.unlinkSync(file); + fs.symlinkSync(localTarget, file); + } +} + +/** + * FFmpeg's generated make fragments do not quote an install prefix containing + * spaces. Build into a temporary no-space prefix, copy the completed SDK to its + * real repository destination, then retarget its Mach-O IDs and dependencies. + */ +export function relocateMacFfmpegInstall(stagedPrefix, destinationPrefix) { + fs.mkdirSync(path.dirname(destinationPrefix), { recursive: true }); + fs.cpSync(stagedPrefix, destinationPrefix, { recursive: true, errorOnExist: true }); + makeDylibSymlinksRelative(destinationPrefix); + + const libDir = path.join(destinationPrefix, "lib"); + for (const name of fs.readdirSync(libDir)) { + const file = path.join(libDir, name); + if (!name.endsWith(".dylib") || !fs.lstatSync(file).isFile()) continue; + rewriteMachO(file, stagedPrefix, destinationPrefix, true); + } + + for (const name of ["ffmpeg", "ffprobe"]) { + const file = path.join(destinationPrefix, "bin", name); + if (fs.existsSync(file)) rewriteMachO(file, stagedPrefix, destinationPrefix, false); + } + + for (const dir of [path.join(destinationPrefix, "lib", "pkgconfig")]) { + if (!fs.existsSync(dir)) continue; + for (const name of fs.readdirSync(dir)) { + if (!name.endsWith(".pc")) continue; + const file = path.join(dir, name); + const source = fs.readFileSync(file, "utf8"); + fs.writeFileSync(file, source.replaceAll(stagedPrefix, destinationPrefix)); + } + } +} diff --git a/scripts/macos-ffmpeg-relocation.test.mjs b/scripts/macos-ffmpeg-relocation.test.mjs new file mode 100644 index 00000000..b445a74b --- /dev/null +++ b/scripts/macos-ffmpeg-relocation.test.mjs @@ -0,0 +1,64 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + makeDylibSymlinksRelative, + parseOtoolReferences, + relocatePathPrefix, +} from "./macos-ffmpeg-relocation.mjs"; + +describe("macOS ffmpeg relocation", () => { + it("moves the prefix and its descendants into a path containing spaces", () => { + const staged = "/private/tmp/openscreen-ffmpeg/install"; + const destination = "/Users/test/Vibe coding/openscreen/crates/thirdparty/ffmpeg"; + + expect(relocatePathPrefix(staged, staged, destination)).toBe(destination); + expect( + relocatePathPrefix(path.join(staged, "lib/libavutil.60.dylib"), staged, destination), + ).toBe(path.join(destination, "lib/libavutil.60.dylib")); + }); + + it("does not rewrite sibling paths with a matching string prefix", () => { + const staged = "/private/tmp/openscreen-ffmpeg/install"; + const destination = "/Users/test/Vibe coding/openscreen/ffmpeg"; + const sibling = "/private/tmp/openscreen-ffmpeg/install-old/libavutil.dylib"; + + expect(relocatePathPrefix(sibling, staged, destination)).toBe(sibling); + expect(relocatePathPrefix("/usr/lib/libSystem.B.dylib", staged, destination)).toBe( + "/usr/lib/libSystem.B.dylib", + ); + }); + + it("repairs absolute dylib symlinks after copying the staged SDK", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-relocation-test-")); + const libDir = path.join(root, "lib"); + fs.mkdirSync(libDir); + fs.writeFileSync(path.join(libDir, "libavutil.60.1.dylib"), "fixture"); + fs.symlinkSync( + "/private/tmp/staged/lib/libavutil.60.1.dylib", + path.join(libDir, "libavutil.dylib"), + ); + + try { + makeDylibSymlinksRelative(root); + expect(fs.readlinkSync(path.join(libDir, "libavutil.dylib"))).toBe("libavutil.60.1.dylib"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("preserves spaces while parsing otool dependency output", () => { + const output = [ + "/tmp/compositor_view.node:", + "\t/Users/test/Vibe coding/ffmpeg/lib/libavformat.62.dylib (compatibility version 62.0.0, current version 62.12.102)", + "\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1351.0.0)", + "", + ].join("\n"); + + expect(parseOtoolReferences(output)).toEqual([ + "/Users/test/Vibe coding/ffmpeg/lib/libavformat.62.dylib", + "/usr/lib/libSystem.B.dylib", + ]); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index aa7619d2..36d4c0d8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { installBrowserShims } from "./native/browserShim"; installBrowserShims(); import { CountdownOverlay } from "./components/launch/CountdownOverlay.tsx"; +import { FloatingSelfViewWindow } from "./components/launch/FloatingSelfViewWindow"; import { LaunchWindow } from "./components/launch/LaunchWindow"; import { NotesWindow } from "./components/launch/NotesWindow.tsx"; import { SourceSelector } from "./components/launch/SourceSelector"; @@ -55,6 +56,13 @@ export default function App() { document.documentElement.style.background = "transparent"; document.getElementById("root")?.style.setProperty("background", "transparent"); } + if (type === "floating-self-view") { + document.documentElement.style.height = "100%"; + document.documentElement.style.overflow = "hidden"; + document.body.style.height = "100%"; + document.body.style.margin = "0"; + document.body.style.overflow = "hidden"; + } // HUD is a fixed-size BrowserWindow; pin the document shell and hide overflow // so the renderer can't introduce scrollbars (see issue #305). @@ -86,6 +94,8 @@ export default function App() { return ; case "countdown-overlay": return ; + case "floating-self-view": + return ; case "cli-export": return ( diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts index 4faea3d5..d83908ff 100644 --- a/src/components/ai-edition/recordingImport.test.ts +++ b/src/components/ai-edition/recordingImport.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { replaceTimeline as replaceTimelineOp } from "@/lib/ai-edition/document/timeline"; import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema"; +import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { undo } from "@/lib/ai-edition/store/undo"; import { clearHistory, past } from "@/lib/ai-edition/store/undoStack"; +import { probeVideoDimensions, probeVideoDuration } from "@/lib/ai-edition/timeline/duration"; import { importPendingRecording } from "./recordingImport"; // The first describe stubs the store actions, so the bridge is never reached @@ -16,9 +18,16 @@ const bridge = vi.hoisted(() => ({ save: vi.fn(), })); vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: bridge } })); +vi.mock("@/lib/ai-edition/timeline/duration", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, probeVideoDimensions: vi.fn(), probeVideoDuration: vi.fn() }; +}); const createProject = vi.fn(async () => undefined); const addAsset = vi.fn(async () => null); +const saveDocument = vi.fn( + async (_document: AxcutDocument, _options?: { history?: boolean }) => true, +); const replaceTimeline = vi.fn(async () => undefined); // Read before anything stubs them: the first describe replaces these actions on the @@ -26,6 +35,7 @@ const replaceTimeline = vi.fn(async () => undefined); const realActions = { createProject: useProjectStore.getState().createProject, addAsset: useProjectStore.getState().addAsset, + saveDocument: useProjectStore.getState().saveDocument, replaceTimeline: useProjectStore.getState().replaceTimeline, }; @@ -49,12 +59,16 @@ function stubElectronApi(screenVideoPath: string | null) { describe("importPendingRecording", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(probeVideoDimensions).mockResolvedValue({ width: 2940, height: 1912 }); + vi.mocked(probeVideoDuration).mockResolvedValue(42); useProjectStore.setState({ document: null, // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched createProject: createProject as any, // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched addAsset: addAsset as any, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched + saveDocument: saveDocument as any, replaceTimeline, }); }); @@ -89,23 +103,35 @@ describe("importPendingRecording", () => { expect(addAsset).toHaveBeenCalledTimes(1); }); - it("seeds a placeholder clip when the imported asset has none", async () => { + it("seeds the probed clip in the same automatic save", async () => { stubElectronApi("/recordings/recording-1.webm"); addAsset.mockImplementationOnce(async () => { + const document = createEmptyDocument({ projectId: "p1", title: "Recording" }); useProjectStore.setState({ - // biome-ignore lint/suspicious/noExplicitAny: only the two fields the seed reads - document: { assets: [{ id: "a1" }], timeline: { clips: [] } } as any, + document: { + ...document, + assets: [ + { + id: "a1", + kind: "video", + label: "Recording", + originalPath: "/recordings/recording-1.webm", + cameraTrack: null, + }, + ], + project: { ...document.project, primaryAssetId: "a1" }, + }, }); return null; }); await importPendingRecording(); - expect(replaceTimeline).toHaveBeenCalledWith( - [{ startSec: 0, endSec: 60 }], - "Auto-imported recording", - { history: false }, - ); + const savedDocument = saveDocument.mock.calls[0]?.[0] as AxcutDocument; + expect(savedDocument.assets[0]?.durationSec).toBe(42); + expect(savedDocument.timeline.clips).toHaveLength(1); + expect(savedDocument.timeline.clips[0]?.timelineEndSec).toBe(42); + expect(saveDocument).toHaveBeenCalledWith(savedDocument, { history: false }); }); }); @@ -144,6 +170,8 @@ describe("what the recording import leaves on the undo stack", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(probeVideoDimensions).mockResolvedValue({ width: 2940, height: 1912 }); + vi.mocked(probeVideoDuration).mockResolvedValue(42); useProjectStore.getState().clear(); useProjectStore.setState(realActions); clearHistory(); @@ -163,6 +191,17 @@ describe("what the recording import leaves on the undo stack", () => { expect(undo()).toBe(false); }); + it("starts a recording project in the source shape with the clean eight-percent padding", async () => { + await importPendingRecording(); + + const settings = getEditorSettings(useProjectStore.getState().document); + expect(settings.aspectRatio).toBe("735:478"); + expect(settings.padding).toBe(8); + expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(1); + expect(useProjectStore.getState().document?.assets[0]?.durationSec).toBe(42); + expect(past).toHaveLength(0); + }); + it("still has its clip after the first Ctrl+Z", async () => { await importPendingRecording(); diff --git a/src/components/ai-edition/recordingImport.ts b/src/components/ai-edition/recordingImport.ts index 9a347439..c95b94f4 100644 --- a/src/components/ai-edition/recordingImport.ts +++ b/src/components/ai-edition/recordingImport.ts @@ -14,7 +14,12 @@ // derived `currentVideoPath`); the only renderer that still needs the session // after this point is the CLI runner, which lives in its own process. +import { toFileUrl } from "@/components/video-editor/projectPersistence"; +import { replaceTimeline as replaceTimelineOp } from "@/lib/ai-edition/document/timeline"; +import { patchEditorSettings } from "@/lib/ai-edition/store/editorSettings"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { probeVideoDimensions, probeVideoDuration } from "@/lib/ai-edition/timeline/duration"; +import { toAspectRatioToken } from "@/utils/aspectRatioUtils"; /** * Imports the recording the HUD handed over into a new project, and consumes the @@ -35,28 +40,58 @@ export async function importPendingRecording(): Promise { const label = screenPath.split(/[\\/]/).pop() || "Recording"; await useProjectStore.getState().createProject(`Recording ${new Date().toLocaleString()}`); await useProjectStore.getState().addAsset(screenPath, label); + + // A Mac display is commonly not 16:9 (the current built-in panel records at + // 2940×1912). Starting every recording project at 16:9 therefore adds wide + // side bars before the user's intentional 8% padding is applied, making the + // padding look different horizontally and vertically. New recording projects + // should start in the recording's own shape; existing projects retain their + // stored format. Probe before the editor paints when possible, and keep the + // dynamic `native` token as the safe fallback until normal metadata probing + // fills the asset dimensions. + const importedDocument = useProjectStore.getState().document; + if (importedDocument) { + const sourceUrl = toFileUrl(screenPath); + const [dimensions, durationSec] = await Promise.all([ + probeVideoDimensions(sourceUrl), + probeVideoDuration(sourceUrl), + ]); + const nativeAspect = dimensions + ? toAspectRatioToken(dimensions.width, dimensions.height) + : null; + let framedDocument = patchEditorSettings(importedDocument, { + aspectRatio: nativeAspect ?? "native", + }); + + // The import already has the source mounted for metadata probing, so use + // that result to seed the timeline in the same atomic save. Waiting for a + // later preview `loadedmetadata` event can strand the editor at 0:00 with + // "No Webcam" even though both recording files are healthy. A 60-second + // placeholder preserves the existing WebM fallback and is corrected by the + // normal metadata callback when the real duration becomes available. + if (framedDocument.timeline.clips.length === 0 && framedDocument.assets.length > 0) { + const knownDuration = durationSec ?? 60; + const primaryAssetId = framedDocument.project.primaryAssetId ?? framedDocument.assets[0]?.id; + const withDuration = primaryAssetId + ? { + ...framedDocument, + assets: framedDocument.assets.map((asset) => + asset.id === primaryAssetId ? { ...asset, durationSec: knownDuration } : asset, + ), + } + : framedDocument; + framedDocument = replaceTimelineOp( + withDuration, + [{ startSec: 0, endSec: knownDuration }], + "Auto-imported recording", + ); + } + const saved = await useProjectStore.getState().saveDocument(framedDocument, { history: false }); + if (!saved) throw new Error("Could not save the recording's native frame shape"); + } // Consumed: the recording now lives in a project. Cleared here rather than // after the timeline seed below so a failure down there can't hand the same // recording to the next editor window. await api.setCurrentRecordingSession(null); - - // ponytail: MediaRecorder WebMs ship with duration = NaN until - // fix-webm-duration patches the EBML header; until that flows through the - // asset, drop a default 60s clip into the timeline so the editor isn't stuck - // on "No clips yet" the moment the user lands in the project. Real duration - // overwrites this when handleLoadedMetadata fires with a finite value. - const doc = useProjectStore.getState().document; - if (doc && doc.timeline.clips.length === 0 && doc.assets.length > 0) { - // `history: false`. Nothing here is an edit: the user finished a recording and the - // editor built them a project around it, unattended, on mount. Recording it left a - // brand-new project sitting at `past.length === 1` before the user had touched - // anything, so their FIRST Ctrl+Z restored the state before the seed -- an empty - // timeline -- and the persist that follows an undo wrote that empty timeline to disk. - await useProjectStore - .getState() - .replaceTimeline([{ startSec: 0, endSec: 60 }], "Auto-imported recording", { - history: false, - }); - } return true; } diff --git a/src/components/launch/FloatingSelfViewWindow.test.tsx b/src/components/launch/FloatingSelfViewWindow.test.tsx new file mode 100644 index 00000000..52574304 --- /dev/null +++ b/src/components/launch/FloatingSelfViewWindow.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { FloatingSelfViewWindow } from "./FloatingSelfViewWindow"; + +describe("FloatingSelfViewWindow", () => { + let command: + | ((value: { visible: boolean; requestId: number; deviceId?: string }) => void) + | undefined; + let getUserMedia: ReturnType; + + beforeEach(() => { + command = undefined; + getUserMedia = vi.fn(); + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia }, + }); + Object.defineProperty(HTMLMediaElement.prototype, "play", { + configurable: true, + value: vi.fn(async () => undefined), + }); + window.electronAPI = { + onFloatingSelfViewCommand: vi.fn((callback) => { + command = callback; + return () => { + command = undefined; + }; + }), + reportFloatingSelfViewReady: vi.fn(async () => ({ success: true })), + reportFloatingSelfViewFailed: vi.fn(async () => ({ success: true })), + closeFloatingSelfViewWindow: vi.fn(async () => ({ success: true })), + } as unknown as Window["electronAPI"]; + }); + + afterEach(() => { + cleanup(); + }); + + it("opens a low-resolution secondary stream only on show and stops it on hide", async () => { + const stop = vi.fn(); + const track = { stop, addEventListener: vi.fn() } as unknown as MediaStreamTrack; + getUserMedia.mockResolvedValue({ + getTracks: () => [track], + getVideoTracks: () => [track], + }); + render( + + + , + ); + + expect(getUserMedia).not.toHaveBeenCalled(); + await act(async () => command?.({ visible: true, requestId: 17, deviceId: "camera-2" })); + await waitFor(() => + expect(window.electronAPI.reportFloatingSelfViewReady).toHaveBeenCalledWith(17), + ); + expect(getUserMedia).toHaveBeenCalledWith({ + audio: false, + video: { + deviceId: { exact: "camera-2" }, + width: { ideal: 640, max: 640 }, + height: { ideal: 360, max: 480 }, + frameRate: { ideal: 24, max: 30 }, + }, + }); + + act(() => command?.({ visible: false, requestId: 18 })); + expect(stop).toHaveBeenCalledTimes(1); + }); + + it("reports camera acquisition failure without surfacing an exception", async () => { + getUserMedia.mockRejectedValue(new Error("camera busy")); + render( + + + , + ); + + await act(async () => command?.({ visible: true, requestId: 22 })); + await waitFor(() => + expect(window.electronAPI.reportFloatingSelfViewFailed).toHaveBeenCalledWith(22), + ); + expect(window.electronAPI.reportFloatingSelfViewReady).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/launch/FloatingSelfViewWindow.tsx b/src/components/launch/FloatingSelfViewWindow.tsx new file mode 100644 index 00000000..67427652 --- /dev/null +++ b/src/components/launch/FloatingSelfViewWindow.tsx @@ -0,0 +1,106 @@ +import { X } from "lucide-react"; +import { type CSSProperties, useCallback, useEffect, useRef } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; + +function stopTracks(stream: MediaStream | null) { + for (const track of stream?.getTracks() ?? []) track.stop(); +} + +/** Renderer for the hidden-before-capture BrowserWindow self-view fallback. */ +export function FloatingSelfViewWindow() { + const t = useScopedT("launch"); + const videoRef = useRef(null); + const streamRef = useRef(null); + const requestGeneration = useRef(0); + + const stop = useCallback(() => { + requestGeneration.current += 1; + const video = videoRef.current; + if (video) video.srcObject = null; + stopTracks(streamRef.current); + streamRef.current = null; + }, []); + + const start = useCallback( + async (requestId: number, deviceId?: string) => { + stop(); + const generation = requestGeneration.current; + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + ...(deviceId ? { deviceId: { exact: deviceId } } : {}), + width: { ideal: 640, max: 640 }, + height: { ideal: 360, max: 480 }, + frameRate: { ideal: 24, max: 30 }, + }, + }); + if (requestGeneration.current !== generation) { + stopTracks(stream); + return; + } + + streamRef.current = stream; + const videoTrack = stream.getVideoTracks()[0]; + if (!videoTrack) throw new Error("Camera returned no video track"); + videoTrack.addEventListener( + "ended", + () => { + if (requestGeneration.current !== generation) return; + stop(); + void window.electronAPI.reportFloatingSelfViewFailed(requestId); + }, + { once: true }, + ); + + const video = videoRef.current; + if (!video) throw new Error("Self-view video element is unavailable"); + video.srcObject = stream; + await video.play(); + if (requestGeneration.current !== generation) return; + await window.electronAPI.reportFloatingSelfViewReady(requestId); + } catch { + if (requestGeneration.current !== generation) return; + stop(); + await window.electronAPI.reportFloatingSelfViewFailed(requestId).catch(() => undefined); + } + }, + [stop], + ); + + useEffect(() => { + const unsubscribe = window.electronAPI.onFloatingSelfViewCommand((command) => { + if (command.visible) void start(command.requestId, command.deviceId); + else stop(); + }); + return () => { + unsubscribe(); + stop(); + }; + }, [start, stop]); + + return ( +
+
+ ); +} diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 360499c7..5044c693 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -1,4 +1,4 @@ -import { Check, Languages, NotepadText, Settings } from "lucide-react"; +import { Check, Languages, NotepadText, PictureInPicture2, Settings } from "lucide-react"; import { memo } from "react"; import { formatTimePadded } from "../../utils/timeUtils"; import { Button } from "../ui/button"; @@ -435,6 +435,34 @@ export const HudRecordingControls = memo(function HudRecordingControls({ ); }); +export const HudSelfViewButton = memo(function HudSelfViewButton({ + open, + disabled, + label, + onClick, +}: { + open: boolean; + disabled: boolean; + label: string; + onClick: () => void; +}) { + return ( + + + + ); +}); + export const HudLanguageButton = memo(function HudLanguageButton({ vertical, code, diff --git a/src/components/launch/HudDeviceSettings.tsx b/src/components/launch/HudDeviceSettings.tsx index 4b40206d..12625297 100644 --- a/src/components/launch/HudDeviceSettings.tsx +++ b/src/components/launch/HudDeviceSettings.tsx @@ -22,6 +22,8 @@ export interface HudDeviceSettingsLabels { cameraUnavailable: string; preview: string; previewUnavailable: string; + floatingSelfView: string; + floatingSelfViewHint: string; about: string; checkForUpdates: string; checkingForUpdates: string; @@ -95,9 +97,12 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ versionLabel, canCheckForUpdates, checkingForUpdates, + showFloatingSelfViewSetting, + floatingSelfViewEnabled, onSelectMic, onSelectCamera, onCheckForUpdates, + onFloatingSelfViewEnabledChange, onClose, panelRef, }: { @@ -113,9 +118,12 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ versionLabel: string | null; canCheckForUpdates: boolean; checkingForUpdates: boolean; + showFloatingSelfViewSetting: boolean; + floatingSelfViewEnabled: boolean; onSelectMic: (device: MicrophoneDevice) => void; onSelectCamera: (device: CameraDevice) => void; onCheckForUpdates: () => void; + onFloatingSelfViewEnabledChange: (enabled: boolean) => void; onClose: () => void; panelRef: (el: HTMLDivElement | null) => void; }) { @@ -218,6 +226,22 @@ export const HudDeviceSettings = memo(function HudDeviceSettings({ ) : null} + {showFloatingSelfViewSetting ? ( + + ) : null} + {/* The HUD has no other settings surface, and an app the user cannot ask "which version am I running?" is an app whose bug reports arrive without one. The update button is absent — not disabled — where a package manager owns the diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 8821194b..17760b73 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -133,6 +133,48 @@ margin: 1px 0; } +.hudPreferenceRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 8px 6px 4px; + padding: 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.035); + cursor: pointer; +} + +.hudPreferenceLabel, +.hudPreferenceHint { + display: block; +} + +.hudPreferenceLabel { + font-size: 12px; + font-weight: 600; + color: #e9edf3; +} + +.hudPreferenceHint { + margin-top: 2px; + font-size: 10px; + line-height: 1.35; + color: #828c99; +} + +.hudPreferenceCheckbox { + width: 16px; + height: 16px; + flex: 0 0 auto; + accent-color: #10b981; +} + +.hudPreferenceCheckbox:focus-visible { + outline: 2px solid #10b981; + outline-offset: 3px; +} + /* Device settings. Same shell as the popovers, and deliberately the same width as the notice column (HUD_STACK_WIDTH) so opening it only ever changes the overlay window's height, never its width. */ diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 60894f9e..b74146e7 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -224,6 +224,10 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo })), getAppInfo: vi.fn(async () => appInfoState.value), checkForUpdates: updateCheckMock, + showFloatingSelfView: vi.fn(async () => ({ success: true })), + hideFloatingSelfView: vi.fn(async () => ({ success: true })), + getFloatingSelfViewState: vi.fn(async () => ({ open: false })), + onFloatingSelfViewStateChanged: vi.fn(() => () => undefined), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), onHudOverlayCursor: vi.fn((callback) => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 8a2427fc..e3b78cc3 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; import { type CameraDevice, useCameraDevices } from "../../hooks/useCameraDevices"; +import { useFloatingSelfView } from "../../hooks/useFloatingSelfView"; import { type MicrophoneDevice, useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { usePortalOwnsSource } from "../../hooks/usePortalOwnsSource"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; @@ -20,6 +22,7 @@ import { HudNotice, HudRecordButton, HudRecordingControls, + HudSelfViewButton, HudSettingsButton, HudSourceButton, HudStudioButton, @@ -100,6 +103,7 @@ export function LaunchWindow() { setSystemAudioEnabled, webcamEnabled, setWebcamEnabled, + webcamPreviewStream, webcamDeviceId, setWebcamDeviceId, setWebcamDeviceName, @@ -122,6 +126,21 @@ export function LaunchWindow() { ); const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const [isLinuxHud, setIsLinuxHud] = useState(false); + const isMacHud = window.electronAPI?.getPlatform?.() === "darwin"; + const [floatingSelfViewEnabled, setFloatingSelfViewEnabled] = useState( + () => loadUserPreferences().floatingSelfViewEnabled, + ); + const handleSelfViewUnavailable = useCallback(() => { + toast.error(t("selfView.unavailable")); + }, [t]); + const floatingSelfView = useFloatingSelfView({ + recording, + webcamEnabled, + stream: webcamPreviewStream, + autoShowEnabled: floatingSelfViewEnabled, + isMac: isMacHud, + onUnavailable: handleSelfViewUnavailable, + }); // The running version, and whether this copy may offer an update check at all — a // Store/Flathub/Snap/Nix install is kept current by its package manager and is offered // nothing (electron/install-channel.ts). Asked once: neither answer changes while the app @@ -690,6 +709,11 @@ export function LaunchWindow() { }); }, [closePopovers]); + const handleFloatingSelfViewPreference = useCallback((enabled: boolean) => { + setFloatingSelfViewEnabled(enabled); + saveUserPreferences({ floatingSelfViewEnabled: enabled }); + }, []); + const toggleSystemAudio = useCallback(() => { if (controlsLocked) return; setSystemAudioEnabled(!systemAudioEnabled); @@ -897,6 +921,8 @@ export function LaunchWindow() { cameraUnavailable: t("webcam.unavailable"), preview: t("deviceSettings.preview"), previewUnavailable: t("deviceSettings.previewUnavailable"), + floatingSelfView: t("selfView.autoShow"), + floatingSelfViewHint: t("selfView.autoShowHint"), about: t("deviceSettings.about"), checkForUpdates: tCommon("actions.checkForUpdates"), checkingForUpdates: t("deviceSettings.checkingForUpdates"), @@ -1057,6 +1083,24 @@ export function LaunchWindow() { /> )} + {isMacHud && + recording && + webcamEnabled && + webcamPreviewStream?.getVideoTracks().some((track) => track.readyState === "live") && ( + void floatingSelfView.toggle()} + /> + )} + {!isLinuxHud && ( )} @@ -1109,9 +1153,12 @@ export function LaunchWindow() { // main process refuses the check then — an offered button would be dead. canCheckForUpdates={(appInfo?.canCheckForUpdates ?? false) && !recording} checkingForUpdates={isCheckingForUpdates} + showFloatingSelfViewSetting={isMacHud} + floatingSelfViewEnabled={floatingSelfViewEnabled} onSelectMic={handleSelectMicDevice} onSelectCamera={handleSelectCameraDevice} onCheckForUpdates={handleCheckForUpdates} + onFloatingSelfViewEnabledChange={handleFloatingSelfViewPreference} onClose={closeDeviceSettings} panelRef={setPopoverEl} /> diff --git a/src/components/video-editor/editorDefaults.test.ts b/src/components/video-editor/editorDefaults.test.ts index 8b515f1c..99b1b138 100644 --- a/src/components/video-editor/editorDefaults.test.ts +++ b/src/components/video-editor/editorDefaults.test.ts @@ -11,6 +11,11 @@ import { import { normalizeProjectEditor } from "./projectPersistence"; describe("editor defaults SSOT", () => { + it("starts clean projects at eight percent padding", () => { + expect(DEFAULT_EDITOR_LAYOUT_SETTINGS.padding).toBe(8); + expect(DEFAULT_PREFS.padding).toBe(8); + }); + it("keeps history defaults aligned with editor defaults", () => { expect(INITIAL_EDITOR_STATE).toMatchObject({ ...DEFAULT_EDITOR_APPEARANCE_SETTINGS, @@ -52,4 +57,8 @@ describe("editor defaults SSOT", () => { gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset, }); }); + + it("preserves padding stored in an existing project", () => { + expect(normalizeProjectEditor({ padding: 50 }).padding).toBe(50); + }); }); diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index a98c93c5..b26e06cc 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -50,7 +50,7 @@ export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { cropRegion: typeof DEFAULT_CROP_REGION; wallpaper: string; } = { - padding: 50, + padding: 8, aspectRatio: "16:9", cropRegion: DEFAULT_CROP_REGION, wallpaper: DEFAULT_WALLPAPER, diff --git a/src/hooks/useFloatingSelfView.test.tsx b/src/hooks/useFloatingSelfView.test.tsx new file mode 100644 index 00000000..0037837c --- /dev/null +++ b/src/hooks/useFloatingSelfView.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useFloatingSelfView } from "./useFloatingSelfView"; + +type FakeTrack = { + readyState: MediaStreamTrackState; + getSettings: () => MediaTrackSettings; +}; + +function makeStream( + track: FakeTrack = { + readyState: "live", + getSettings: () => ({ deviceId: "camera-1" }), + }, +): MediaStream { + return { + getVideoTracks: () => [track as MediaStreamTrack], + } as MediaStream; +} + +function Harness({ + recording, + stream, + autoShowEnabled = true, + webcamEnabled = true, + isMac = true, + onUnavailable = vi.fn(), +}: { + recording: boolean; + stream: MediaStream | null; + autoShowEnabled?: boolean; + webcamEnabled?: boolean; + isMac?: boolean; + onUnavailable?: () => void; +}) { + const selfView = useFloatingSelfView({ + recording, + stream, + autoShowEnabled, + webcamEnabled, + isMac, + onUnavailable, + }); + return ( + <> + + + {String(selfView.supported)}:{String(selfView.ready)}:{String(selfView.open)} + + + ); +} + +describe("useFloatingSelfView", () => { + beforeEach(() => { + window.electronAPI = { + showFloatingSelfView: vi.fn(async () => ({ success: true })), + hideFloatingSelfView: vi.fn(async () => ({ success: true })), + getFloatingSelfViewState: vi.fn(async () => ({ open: false })), + onFloatingSelfViewStateChanged: vi.fn(() => () => undefined), + } as unknown as Window["electronAPI"]; + }); + + afterEach(() => { + cleanup(); + }); + + it("auto-opens once when a new recording becomes active", async () => { + const stream = makeStream(); + const view = render(); + + view.rerender(); + + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(1)); + expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledWith("camera-1"); + expect(screen.getByTestId("state").textContent).toBe("true:true:true"); + }); + + it("opens again after recording restart, but never twice within one take", async () => { + const stream = makeStream(); + const view = render(); + + view.rerender(); + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(1)); + view.rerender(); + expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(1); + + view.rerender(); + view.rerender(); + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(2)); + }); + + it("keeps manual show available when auto-show is disabled", async () => { + const stream = makeStream(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(1)); + }); + + it("allows manual close and reopen without stopping the recorder's camera track", async () => { + const track = { + readyState: "live" as const, + getSettings: () => ({ deviceId: "camera-1" }), + }; + const stream = makeStream(track); + render(); + + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(window.electronAPI.hideFloatingSelfView).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(2)); + expect(track.readyState).toBe("live"); + }); + + it("closes after recording stops or the recorder camera track is lost", async () => { + const track: FakeTrack = { + readyState: "live", + getSettings: () => ({ deviceId: "camera-1" }), + }; + const stream = makeStream(track); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalled()); + + view.rerender(); + await waitFor(() => expect(window.electronAPI.hideFloatingSelfView).toHaveBeenCalled()); + const hidesAfterStop = vi.mocked(window.electronAPI.hideFloatingSelfView).mock.calls.length; + + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(2)); + track.readyState = "ended"; + view.rerender(); + await waitFor(() => + expect(window.electronAPI.hideFloatingSelfView).toHaveBeenCalledTimes(hidesAfterStop + 1), + ); + }); + + it("closes the fallback when the HUD is destroyed", async () => { + const stream = makeStream(); + const view = render(); + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(window.electronAPI.showFloatingSelfView).toHaveBeenCalledTimes(1)); + + view.unmount(); + + await waitFor(() => expect(window.electronAPI.hideFloatingSelfView).toHaveBeenCalled()); + }); + + it("contains unsupported and rejected requests without changing recording state", async () => { + const onUnavailable = vi.fn(); + const stream = makeStream(); + const view = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(onUnavailable).toHaveBeenCalledTimes(1)); + + vi.mocked(window.electronAPI.showFloatingSelfView).mockResolvedValueOnce({ + success: false, + error: "camera-unavailable", + }); + view.rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "toggle" })); + await waitFor(() => expect(onUnavailable).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId("state").textContent).toBe("true:true:false"); + }); +}); diff --git a/src/hooks/useFloatingSelfView.ts b/src/hooks/useFloatingSelfView.ts new file mode 100644 index 00000000..3de7b279 --- /dev/null +++ b/src/hooks/useFloatingSelfView.ts @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface FloatingSelfViewResult { + supported: boolean; + ready: boolean; + open: boolean; + show: () => Promise; + hide: () => Promise; + toggle: () => Promise; +} + +interface FloatingSelfViewOptions { + recording: boolean; + webcamEnabled: boolean; + stream: MediaStream | null; + autoShowEnabled: boolean; + isMac: boolean; + onUnavailable?: () => void; +} + +export interface FloatingSelfViewRequestResult { + success: boolean; + error?: "unsupported" | "not-ready" | "request-rejected"; +} + +function hasLiveVideo(stream: MediaStream | null): boolean { + return Boolean(stream?.getVideoTracks().some((track) => track.readyState === "live")); +} + +/** + * Owns the capture-safe macOS BrowserWindow self-view lifecycle. The existing + * recorder stream remains the source of truth for availability; the hidden, + * pre-created self-view window opens its own low-resolution stream only when + * this hook asks main to show it. + */ +export function useFloatingSelfView({ + recording, + webcamEnabled, + stream, + autoShowEnabled, + isMac, + onUnavailable, +}: FloatingSelfViewOptions): FloatingSelfViewResult { + const [open, setOpen] = useState(false); + const previousRecording = useRef(recording); + const pendingAutoOpen = useRef(false); + const autoOpenAttempted = useRef(false); + + const supported = isMac && typeof window !== "undefined" && Boolean(window.electronAPI); + const ready = hasLiveVideo(stream); + + const request = useCallback(async (): Promise => { + if (!supported) return { success: false, error: "unsupported" }; + if (!ready || !hasLiveVideo(stream)) { + return { success: false, error: "not-ready" }; + } + + try { + const deviceId = stream?.getVideoTracks()[0]?.getSettings?.().deviceId; + const result = await window.electronAPI.showFloatingSelfView(deviceId); + if (result.success) setOpen(true); + return result.success ? { success: true } : { success: false, error: "request-rejected" }; + } catch { + return { success: false, error: "request-rejected" }; + } + }, [ready, stream, supported]); + + const show = useCallback(async () => { + const result = await request(); + if (!result.success) onUnavailable?.(); + return result.success; + }, [onUnavailable, request]); + + const hide = useCallback(async () => { + if (!isMac || !window.electronAPI) { + setOpen(false); + return; + } + try { + await window.electronAPI.hideFloatingSelfView(); + } catch { + // The main process also closes the self-view when the HUD goes away. + } + setOpen(false); + }, [isMac]); + + const toggle = useCallback(async () => { + if (open) await hide(); + else await show(); + }, [hide, open, show]); + + useEffect(() => { + if (!supported) return; + const unsubscribe = window.electronAPI.onFloatingSelfViewStateChanged((state) => { + setOpen(state.open); + }); + void window.electronAPI + .getFloatingSelfViewState() + .then((state) => setOpen(state.open)) + .catch(() => setOpen(false)); + return unsubscribe; + }, [supported]); + + useEffect(() => { + const started = recording && !previousRecording.current; + previousRecording.current = recording; + if (started) { + pendingAutoOpen.current = autoShowEnabled; + autoOpenAttempted.current = false; + } + if (!recording) { + pendingAutoOpen.current = false; + autoOpenAttempted.current = false; + void hide(); + } + }, [autoShowEnabled, hide, recording]); + + useEffect(() => { + if ( + !pendingAutoOpen.current || + autoOpenAttempted.current || + !recording || + !webcamEnabled || + !ready || + !hasLiveVideo(stream) || + !supported + ) { + return; + } + + autoOpenAttempted.current = true; + pendingAutoOpen.current = false; + void show(); + }, [ready, recording, show, stream, supported, webcamEnabled]); + + useEffect(() => { + if (!recording || !webcamEnabled || !hasLiveVideo(stream)) void hide(); + }, [hide, recording, stream, webcamEnabled]); + + useEffect(() => () => void hide(), [hide]); + + return { supported, ready, open, show, hide, toggle }; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 14eec5ab..c0acf7f5 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -53,7 +53,7 @@ const AUDIO_BITRATE_SYSTEM = 192_000; const WEBCAM_TARGET_FRAME_RATE = 30; -type UseScreenRecorderReturn = { +export type UseScreenRecorderReturn = { recording: boolean; paused: boolean; saving: boolean; @@ -79,6 +79,8 @@ type UseScreenRecorderReturn = { setSystemAudioEnabled: (enabled: boolean) => void; webcamEnabled: boolean; setWebcamEnabled: (enabled: boolean) => Promise; + /** The recorder-owned camera stream. Consumers may display it but must never stop its tracks. */ + webcamPreviewStream: MediaStream | null; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; softwareEncoderFallbackNoticeVisible: boolean; @@ -216,6 +218,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [webcamDeviceName, setWebcamDeviceName] = useState(undefined); const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabledState] = useState(false); + const [webcamPreviewStream, setWebcamPreviewStream] = useState(null); const [cursorCaptureMode, setCursorCaptureMode] = useState("editable-overlay"); const [softwareEncoderFallbackNoticeVisible, setSoftwareEncoderFallbackNoticeVisible] = useState(false); @@ -356,6 +359,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const stopWebcamPreviewStream = useCallback(() => { if (!webcamStream.current) { + setWebcamPreviewStream(null); return; } @@ -365,6 +369,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { track.stop(); }); webcamStream.current = null; + setWebcamPreviewStream(null); webcamReady.current = true; }, []); @@ -425,7 +430,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { acquiredStream = stream; stream.getVideoTracks().forEach((track) => { track.onended = () => { - webcamStream.current = null; + if (webcamStream.current === stream) { + webcamStream.current = null; + setWebcamPreviewStream(null); + } if (!restarting.current) { setWebcamEnabledState(false); toast.error(t("recording.cameraDisconnected")); @@ -433,11 +441,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; }); webcamStream.current = stream; + setWebcamPreviewStream(stream); webcamReady.current = true; } catch (cameraError) { if (!cancelled) { console.warn("Failed to get webcam access:", cameraError); setWebcamEnabledState(false); + setWebcamPreviewStream(null); const isDeviceError = cameraError instanceof DOMException && [ @@ -462,7 +472,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { track.onended = null; track.stop(); }); - webcamStream.current = null; + if (webcamStream.current === acquiredStream) { + webcamStream.current = null; + setWebcamPreviewStream(null); + } } }; }, [webcamEnabled, webcamDeviceId, t]); @@ -1325,6 +1338,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const result = await window.electronAPI.startNativeMacRecording(request); if (!result.success || !result.recordingId) { + if (result.errorCode === "self-capture-exclusion-failed") { + throw new Error(t("recording.selfCaptureExclusionFailed")); + } throw new Error(result.error ?? "Native macOS capture failed."); } if (!isCountdownRunActive(countdownRunToken)) { @@ -2297,6 +2313,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setSystemAudioEnabled, webcamEnabled, setWebcamEnabled, + webcamPreviewStream, cursorCaptureMode, setCursorCaptureMode, softwareEncoderFallbackNoticeVisible, diff --git a/src/hooks/useScreenRecorder.webcamPreviewStream.test.tsx b/src/hooks/useScreenRecorder.webcamPreviewStream.test.tsx new file mode 100644 index 00000000..f569fe19 --- /dev/null +++ b/src/hooks/useScreenRecorder.webcamPreviewStream.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/contexts/I18nContext", () => { + const translate = (key: string) => key; + return { useScopedT: () => translate }; +}); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import { useScreenRecorder } from "./useScreenRecorder"; + +function fakeStream() { + const track = { + readyState: "live" as MediaStreamTrackState, + onended: null as (() => void) | null, + stop: vi.fn(), + getSettings: vi.fn(() => ({})), + }; + const stream = { + getTracks: () => [track as unknown as MediaStreamTrack], + getVideoTracks: () => [track as unknown as MediaStreamTrack], + } as MediaStream; + return { stream, track }; +} + +describe("useScreenRecorder webcamPreviewStream", () => { + beforeEach(() => { + window.electronAPI = { + getRecordingPrefs: vi.fn(async () => null), + hideCountdownOverlay: vi.fn(async () => true), + requestCameraAccess: vi.fn(async () => ({ + success: true, + granted: true, + status: "granted", + })), + } as unknown as Window["electronAPI"]; + }); + + it("tracks acquisition, device changes, disable, and camera loss without exposing ownership", async () => { + const first = fakeStream(); + const second = fakeStream(); + const getUserMedia = vi + .fn() + .mockResolvedValueOnce(first.stream) + .mockResolvedValueOnce(second.stream); + Object.defineProperty(navigator, "mediaDevices", { + value: { getUserMedia }, + configurable: true, + }); + + const view = renderHook(() => useScreenRecorder()); + await act(async () => { + await view.result.current.setWebcamEnabled(true); + }); + await waitFor(() => expect(view.result.current.webcamPreviewStream).toBe(first.stream)); + + act(() => view.result.current.setWebcamDeviceId("camera-two")); + await waitFor(() => expect(view.result.current.webcamPreviewStream).toBe(second.stream)); + expect(first.track.stop).toHaveBeenCalledTimes(1); + + act(() => second.track.onended?.()); + await waitFor(() => expect(view.result.current.webcamPreviewStream).toBeNull()); + expect(view.result.current.webcamEnabled).toBe(false); + + // Re-enable once more to prove disabling tears down the recorder-owned stream. + const third = fakeStream(); + getUserMedia.mockResolvedValueOnce(third.stream); + await act(async () => { + await view.result.current.setWebcamEnabled(true); + }); + await waitFor(() => expect(view.result.current.webcamPreviewStream).toBe(third.stream)); + await act(async () => { + await view.result.current.setWebcamEnabled(false); + }); + await waitFor(() => expect(view.result.current.webcamPreviewStream).toBeNull()); + expect(third.track.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 3bfe7666..5bfb9a8e 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -45,6 +45,7 @@ "cameraCaptureUnavailable": "تعذّر فتح الكاميرا. يجري التسجيل بدون كاميرا.", "microphoneDefaulted": "تعذّر تحديد الميكروفون المحدَّد؛ يجري التسجيل من الإدخال الافتراضي.", "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة.", + "selfCaptureExclusionFailed": "لم يبدأ التسجيل لأن OpenScreen لم يتمكن من استبعاد نوافذه بأمان من تسجيل الشاشة الكاملة.", "accessibilityAllowAndRetry": "اسمح بوصول تسهيلات الاستخدام لـ OpenScreen، ثم اضغط على التسجيل مرة أخرى لبدء العد التنازلي.", "selectSource": "يرجى تحديد مصدر للتسجيل" }, diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index 0198af38..c149d9d0 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -74,6 +74,13 @@ "switch": "التبديل إلى {{language}}", "keepDefault": "الاحتفاظ باللغة الحالية" }, + "selfView": { + "show": "إظهار العرض الذاتي", + "hide": "إخفاء العرض الذاتي", + "autoShow": "إظهار العرض الذاتي العائم تلقائيًا", + "autoShowHint": "افتح معاينة الكاميرا عند بدء التسجيل", + "unavailable": "العرض الذاتي العائم غير متاح" + }, "cursor": { "useEditableCursor": "استخدام مؤشر قابل للتحرير", "useSystemCursor": "استخدام مؤشر النظام" diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 4c3dd14c..1c986aa9 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -45,6 +45,7 @@ "cameraCaptureUnavailable": "The camera could not be opened. Recording without it.", "microphoneDefaulted": "The chosen microphone could not be identified. Recording the default input.", "permissionDenied": "Recording permission denied. Please allow screen recording.", + "selfCaptureExclusionFailed": "Recording did not start because OpenScreen could not safely keep its own windows out of the full-screen capture.", "accessibilityAllowAndRetry": "Allow Accessibility access for OpenScreen, then press record again to start the countdown.", "selectSource": "Please select a source to record" }, diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index ad2385c0..b9678735 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -52,6 +52,13 @@ "camera": "Camera", "cameraDevice": "Camera device" }, + "selfView": { + "show": "Show self-view", + "hide": "Hide self-view", + "autoShow": "Auto-show floating self-view", + "autoShowHint": "Open your camera preview when recording starts", + "unavailable": "Floating self-view is unavailable" + }, "cursor": { "useEditableCursor": "Use editable cursor", "useSystemCursor": "Use system cursor" diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index e2ef2142..9605ae5e 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -37,6 +37,7 @@ "cameraCaptureUnavailable": "No se pudo abrir la cámara. Grabando sin ella.", "microphoneDefaulted": "No se pudo identificar el micrófono elegido. Grabando la entrada predeterminada.", "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla.", + "selfCaptureExclusionFailed": "La grabación no comenzó porque OpenScreen no pudo excluir sus propias ventanas de forma segura de la captura de pantalla completa.", "accessibilityAllowAndRetry": "Permite el acceso de accesibilidad para OpenScreen y luego pulsa grabar de nuevo para iniciar la cuenta atrás.", "selectSource": "Por favor selecciona una fuente para grabar" }, diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 7a2c92a5..8670f342 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -52,6 +52,13 @@ "camera": "Cámara", "cameraDevice": "Dispositivo de cámara" }, + "selfView": { + "show": "Mostrar vista propia", + "hide": "Ocultar vista propia", + "autoShow": "Mostrar automáticamente la vista propia flotante", + "autoShowHint": "Abre la vista previa de tu cámara al iniciar la grabación", + "unavailable": "La vista propia flotante no está disponible" + }, "cursor": { "useEditableCursor": "Usar cursor editable", "useSystemCursor": "Usar cursor del sistema" diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 61694c0d..2856b5fb 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -43,6 +43,7 @@ "cameraCaptureUnavailable": "Impossible d'ouvrir la caméra. Enregistrement sans elle.", "microphoneDefaulted": "Micro choisi non identifié. Enregistrement de l'entrée par défaut.", "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran.", + "selfCaptureExclusionFailed": "L’enregistrement n’a pas démarré car OpenScreen n’a pas pu exclure ses propres fenêtres de la capture plein écran en toute sécurité.", "accessibilityAllowAndRetry": "Autorisez l'accès Accessibilité pour OpenScreen, puis appuyez de nouveau sur enregistrer pour lancer le compte à rebours.", "selectSource": "Veuillez sélectionner une source à enregistrer" }, diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 858fd58f..46f4f3c5 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -52,6 +52,13 @@ "camera": "Caméra", "cameraDevice": "Périphérique caméra" }, + "selfView": { + "show": "Afficher l’aperçu caméra", + "hide": "Masquer l’aperçu caméra", + "autoShow": "Afficher automatiquement l’aperçu flottant", + "autoShowHint": "Ouvre l’aperçu de votre caméra au début de l’enregistrement", + "unavailable": "L’aperçu caméra flottant est indisponible" + }, "cursor": { "useEditableCursor": "Utiliser le curseur éditable", "useSystemCursor": "Utiliser le curseur système" diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 81603e04..32381638 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -45,6 +45,7 @@ "cameraCaptureUnavailable": "Impossibile aprire la fotocamera. Registrazione senza di essa.", "microphoneDefaulted": "Impossibile identificare il microfono scelto. Registrazione dall'ingresso predefinito.", "permissionDenied": "Autorizzazione di registrazione negata. Consenti la registrazione dello schermo.", + "selfCaptureExclusionFailed": "La registrazione non è iniziata perché OpenScreen non ha potuto escludere in sicurezza le proprie finestre dalla cattura a schermo intero.", "accessibilityAllowAndRetry": "Consenti l'accesso all'accessibilità per OpenScreen, poi premi di nuovo registra per avviare il conto alla rovescia.", "selectSource": "Seleziona una sorgente da registrare" }, diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index ed5088d6..fde32a5b 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -52,6 +52,13 @@ "camera": "Fotocamera", "cameraDevice": "Dispositivo fotocamera" }, + "selfView": { + "show": "Mostra anteprima personale", + "hide": "Nascondi anteprima personale", + "autoShow": "Mostra automaticamente l’anteprima mobile", + "autoShowHint": "Apre l’anteprima della fotocamera all’avvio della registrazione", + "unavailable": "L’anteprima personale mobile non è disponibile" + }, "cursor": { "useEditableCursor": "Usa cursore modificabile", "useSystemCursor": "Usa cursore di sistema" diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index d053b2fb..e01af625 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -41,6 +41,7 @@ "microphoneDenied": "マイクへのアクセスが拒否されました。音声なしで録画を続行します。", "cameraDenied": "カメラのアクセスが拒否されました。ウェブカメラなしで録画を続行します。", "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", + "selfCaptureExclusionFailed": "OpenScreen 自身のウィンドウを全画面録画から安全に除外できなかったため、録画を開始しませんでした。", "cameraDisconnected": "ウェブカメラが切断されました。", "cameraNotFound": "カメラが見つかりません。", "cameraCaptureUnavailable": "カメラを開けませんでした。カメラなしで録画します。", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index ab9cebbd..8609aeec 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -52,6 +52,13 @@ "camera": "カメラ", "cameraDevice": "カメラデバイス" }, + "selfView": { + "show": "セルフビューを表示", + "hide": "セルフビューを非表示", + "autoShow": "フローティングセルフビューを自動表示", + "autoShowHint": "録画開始時にカメラプレビューを開きます", + "unavailable": "フローティングセルフビューを利用できません" + }, "cursor": { "useEditableCursor": "編集可能なカーソルを使う", "useSystemCursor": "システムカーソルを使う" diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index c45d8d74..eb05d336 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -41,6 +41,7 @@ "microphoneDenied": "마이크 접근이 거부되었습니다. 오디오 없이 녹화를 계속합니다.", "cameraDenied": "카메라 접근이 거부되었습니다. 웹캠 없이 녹화를 계속합니다.", "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", + "selfCaptureExclusionFailed": "OpenScreen 자체 창을 전체 화면 캡처에서 안전하게 제외할 수 없어 녹화를 시작하지 않았습니다.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", "cameraNotFound": "카메라를 찾을 수 없습니다.", "cameraCaptureUnavailable": "카메라를 열 수 없습니다. 카메라 없이 녹화합니다.", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 19208922..ec601bef 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -52,6 +52,13 @@ "camera": "카메라", "cameraDevice": "카메라 장치" }, + "selfView": { + "show": "셀프 뷰 표시", + "hide": "셀프 뷰 숨기기", + "autoShow": "플로팅 셀프 뷰 자동 표시", + "autoShowHint": "녹화를 시작할 때 카메라 미리보기를 엽니다", + "unavailable": "플로팅 셀프 뷰를 사용할 수 없습니다" + }, "cursor": { "useEditableCursor": "편집 가능한 커서 사용", "useSystemCursor": "시스템 커서 사용" diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 5551edf9..92ffe974 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -45,6 +45,7 @@ "cameraCaptureUnavailable": "Não foi possível abrir a câmera. Gravando sem ela.", "microphoneDefaulted": "Não foi possível identificar o microfone escolhido. Gravando a entrada padrão.", "permissionDenied": "Permissão de gravação negada. Por favor, permita a gravação de tela.", + "selfCaptureExclusionFailed": "A gravação não começou porque o OpenScreen não conseguiu excluir com segurança as próprias janelas da captura de tela inteira.", "accessibilityAllowAndRetry": "Permita o acesso de Acessibilidade para o OpenScreen e pressione gravar novamente para iniciar a contagem regressiva.", "selectSource": "Por favor, selecione uma fonte para gravar" }, diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 4d1fc927..6bb539c9 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -52,6 +52,13 @@ "camera": "Câmera", "cameraDevice": "Dispositivo de câmera" }, + "selfView": { + "show": "Mostrar minha imagem", + "hide": "Ocultar minha imagem", + "autoShow": "Mostrar automaticamente a imagem flutuante", + "autoShowHint": "Abre a prévia da câmera ao iniciar a gravação", + "unavailable": "A imagem flutuante não está disponível" + }, "cursor": { "useEditableCursor": "Usar cursor editável", "useSystemCursor": "Usar cursor do sistema" diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index e6eeae17..fc172136 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -45,6 +45,7 @@ "cameraCaptureUnavailable": "Не удалось открыть камеру. Запись идёт без неё.", "microphoneDefaulted": "Не удалось определить выбранный микрофон. Записывается вход по умолчанию.", "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана.", + "selfCaptureExclusionFailed": "Запись не началась: OpenScreen не удалось безопасно исключить собственные окна из полноэкранного захвата.", "accessibilityAllowAndRetry": "Разрешите OpenScreen доступ к Универсальному доступу, затем снова нажмите запись, чтобы начать обратный отсчет.", "selectSource": "Пожалуйста, выберите источник для записи" }, diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 0af9564c..42566310 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -74,6 +74,13 @@ "switch": "Переключить на {{language}}", "keepDefault": "Оставить текущий язык" }, + "selfView": { + "show": "Показать себя", + "hide": "Скрыть себя", + "autoShow": "Автоматически показывать плавающее окно", + "autoShowHint": "Открывает предпросмотр камеры при начале записи", + "unavailable": "Плавающее окно камеры недоступно" + }, "cursor": { "useEditableCursor": "Использовать редактируемый курсор", "useSystemCursor": "Использовать системный курсор" diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 555fc7de..3c723702 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -33,6 +33,7 @@ "microphoneDenied": "Mikrofon erişimi reddedildi. Kayıt ses olmadan devam edecek.", "cameraDenied": "Kamera erişimi reddedildi. Kayıt kamera olmadan devam edecek.", "permissionDenied": "Kayıt izni reddedildi. Lütfen ekran kaydına izin verin.", + "selfCaptureExclusionFailed": "OpenScreen kendi pencerelerini tam ekran kaydından güvenli biçimde çıkaramadığı için kayıt başlamadı.", "cameraDisconnected": "Webcam bağlantısı kesildi.", "cameraNotFound": "Kamera bulunamadı.", "cameraCaptureUnavailable": "Kamera açılamadı. Kamerasız kaydediliyor.", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 632709c8..574847cb 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -52,6 +52,13 @@ "camera": "Kamera", "cameraDevice": "Kamera cihazı" }, + "selfView": { + "show": "Kendimi göster", + "hide": "Kendimi gizle", + "autoShow": "Yüzen öz görünümü otomatik göster", + "autoShowHint": "Kayıt başladığında kamera önizlemesini açar", + "unavailable": "Yüzen öz görünüm kullanılamıyor" + }, "cursor": { "useEditableCursor": "Düzenlenebilir imleci kullan", "useSystemCursor": "Sistem imlecini kullan" diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 17db6ec4..e96e7717 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -45,6 +45,7 @@ "cameraCaptureUnavailable": "Không thể mở máy ảnh. Đang ghi mà không có máy ảnh.", "microphoneDefaulted": "Không xác định được micrô đã chọn. Đang ghi từ đầu vào mặc định.", "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình.", + "selfCaptureExclusionFailed": "Không thể bắt đầu ghi vì OpenScreen không thể loại trừ an toàn các cửa sổ của chính ứng dụng khỏi bản ghi toàn màn hình.", "accessibilityAllowAndRetry": "Cho phép OpenScreen truy cập Trợ năng, sau đó nhấn ghi lại để bắt đầu đếm ngược.", "selectSource": "Vui lòng chọn một nguồn để ghi" }, diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 98405bf9..f7264ca3 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -74,6 +74,13 @@ "switch": "Chuyển sang {{language}}", "keepDefault": "Giữ ngôn ngữ hiện tại" }, + "selfView": { + "show": "Hiện hình ảnh của tôi", + "hide": "Ẩn hình ảnh của tôi", + "autoShow": "Tự động hiện cửa sổ hình ảnh nổi", + "autoShowHint": "Mở bản xem trước camera khi bắt đầu ghi", + "unavailable": "Không thể dùng cửa sổ hình ảnh nổi" + }, "cursor": { "useEditableCursor": "Dùng con trỏ có thể chỉnh sửa", "useSystemCursor": "Dùng con trỏ hệ thống" diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index de7e52b5..9b35cbe3 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -45,6 +45,7 @@ "cameraCaptureUnavailable": "无法打开摄像头,正在不使用摄像头录制。", "microphoneDefaulted": "无法识别所选麦克风,正在录制默认输入设备。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。", + "selfCaptureExclusionFailed": "未开始录制,因为 OpenScreen 无法安全地从全屏录制中排除自身窗口。", "accessibilityAllowAndRetry": "允许 OpenScreen 使用辅助功能权限,然后再次按录制以开始倒计时。", "selectSource": "请选择要录制的源" }, diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index e7efe8ee..dde7497f 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -52,6 +52,13 @@ "camera": "摄像头", "cameraDevice": "摄像头设备" }, + "selfView": { + "show": "显示自拍画面", + "hide": "隐藏自拍画面", + "autoShow": "自动显示悬浮自拍画面", + "autoShowHint": "开始录制时打开摄像头预览", + "unavailable": "悬浮自拍画面不可用" + }, "cursor": { "useEditableCursor": "使用可编辑光标", "useSystemCursor": "使用系统光标" diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index f567810a..db85d94c 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -41,6 +41,7 @@ "microphoneDenied": "麥克風權限被拒絕。錄製將繼續,但不包含音訊。", "cameraDenied": "攝影機權限被拒絕。錄製將繼續,但不包含攝影機畫面。", "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", + "selfCaptureExclusionFailed": "未開始錄製,因為 OpenScreen 無法安全地從全螢幕錄製中排除自身視窗。", "cameraDisconnected": "網路攝影機已中斷連線。", "cameraNotFound": "找不到攝影機。", "cameraCaptureUnavailable": "無法開啟攝影機,將在沒有攝影機的情況下錄製。", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 025e422e..7f8c04ff 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -52,6 +52,13 @@ "camera": "攝影機", "cameraDevice": "攝影機裝置" }, + "selfView": { + "show": "顯示自拍畫面", + "hide": "隱藏自拍畫面", + "autoShow": "自動顯示浮動自拍畫面", + "autoShowHint": "開始錄製時開啟相機預覽", + "unavailable": "浮動自拍畫面無法使用" + }, "cursor": { "useEditableCursor": "使用可編輯游標", "useSystemCursor": "使用系統游標" diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 6a76e801..b669c8db 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -140,6 +140,9 @@ const DECLARED: WritePath[] = [ w("src/components/ai-edition/NewEditorShell.tsx", "pasteRegion", "save", "gesture"), // The window is closing and the user answered "save". w("src/components/ai-edition/NewEditorShell.tsx", "unsubSaveBeforeClose", "save", "gesture"), + // A fresh recorder hand-off adopts the captured display's native shape before + // the editor paints. It is project initialization, not a user edit. + w("src/components/ai-edition/recordingImport.ts", "importPendingRecording", "save", "automatic"), // The agent's document. The optimistic write is not the edit — the save is, and // it names the pre-agent document as what Ctrl+Z returns to. diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 07982f47..7ba2c1f6 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { DEFAULT_EDITOR_LAYOUT_SETTINGS } from "@/components/video-editor/editorDefaults"; import { DEFAULT_CROP_REGION, DEFAULT_CURSOR_SIZE, @@ -44,6 +45,8 @@ describe("getEditorSettings", () => { expect(snap.webcamLayoutPreset).toBe(DEFAULT_WEBCAM_LAYOUT_PRESET); expect(snap.webcamMaskShape).toBe(DEFAULT_WEBCAM_MASK_SHAPE); expect(snap.cursor.size).toBe(DEFAULT_CURSOR_SIZE); + expect(snap.padding).toBe(8); + expect(snap.padding).toBe(DEFAULT_EDITOR_LAYOUT_SETTINGS.padding); }); it("returns the defaults when the document is null", () => { @@ -63,6 +66,7 @@ describe("getEditorSettings", () => { webcamMaskShape: "circle", cursorSize: 5, cursorSmoothing: 0.8, + padding: 50, }, }; const snap = getEditorSettings(doc); @@ -74,6 +78,7 @@ describe("getEditorSettings", () => { expect(snap.webcamMaskShape).toBe("circle"); expect(snap.cursor.size).toBe(5); expect(snap.cursor.smoothing).toBe(0.8); + expect(snap.padding).toBe(50); }); it("falls back to defaults for unknown or wrong-type values", () => { diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 47f532dc..e52c8f7d 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -8,6 +8,7 @@ // are used everywhere; values that v3 owns directly (zoomRanges, annotations, // transcripts, clips) stay in their dedicated fields. +import { DEFAULT_EDITOR_LAYOUT_SETTINGS } from "@/components/video-editor/editorDefaults"; import { type CropRegion, type CursorVisualSettings, @@ -116,7 +117,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { showBlur: false, motionBlurAmount: 0.2, borderRadius: 40, - padding: 50, + padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, cropRegion: DEFAULT_CROP_REGION, webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET, webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE, diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index e5137c3d..f6019129 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -6,6 +6,10 @@ export type NativeMacSourceType = "display" | "window"; export type NativeMacRecordingRequest = { schemaVersion: 1; recordingId?: number; + /** Main-process injected. Renderer callers must not populate capture exclusions. */ + excludedApplicationProcessIds?: number[]; + /** Main-process injected native BrowserWindow IDs used only as a safe fallback. */ + excludedWindowIds?: number[]; source: { type: NativeMacSourceType; sourceId: string; @@ -89,6 +93,7 @@ export type NativeMacRecordingStartResult = { path?: string; helperPath?: string; error?: string; + errorCode?: string; }; export function parseMacWindowIdFromSourceId(sourceId?: string | null) { diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index ca66c53d..65c02905 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -133,4 +133,25 @@ describe("user preferences", () => { expect(loadUserPreferences().hideSoftwareEncoderFallbackNotice).toBe(false); }); + + it("defaults the floating self-view to auto-show for legacy preferences", () => { + localStorage.setItem("openscreen_user_preferences", JSON.stringify({ trayLayout: "vertical" })); + + expect(loadUserPreferences().floatingSelfViewEnabled).toBe(true); + }); + + it("persists the floating self-view auto-show preference", () => { + saveUserPreferences({ floatingSelfViewEnabled: false }); + + expect(loadUserPreferences().floatingSelfViewEnabled).toBe(false); + }); + + it("falls back to auto-show when the floating self-view preference is malformed", () => { + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ floatingSelfViewEnabled: "no" }), + ); + + expect(loadUserPreferences().floatingSelfViewEnabled).toBe(true); + }); }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index f98fb422..b2751966 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -26,6 +26,8 @@ export interface UserPreferences { preferSoftwareEncoder: boolean; /** Stop showing the notice that recording fell back to software encoding */ hideSoftwareEncoderFallbackNotice: boolean; + /** Automatically show the macOS floating webcam self-view when recording starts */ + floatingSelfViewEnabled: boolean; } export const DEFAULT_PREFS: UserPreferences = { @@ -38,6 +40,7 @@ export const DEFAULT_PREFS: UserPreferences = { trayLayout: "horizontal", preferSoftwareEncoder: false, hideSoftwareEncoderFallbackNotice: false, + floatingSelfViewEnabled: true, }; /** Parses stored preferences without throwing on malformed JSON. */ @@ -99,6 +102,10 @@ export function loadUserPreferences(): UserPreferences { typeof raw.hideSoftwareEncoderFallbackNotice === "boolean" ? raw.hideSoftwareEncoderFallbackNotice : DEFAULT_PREFS.hideSoftwareEncoderFallbackNotice, + floatingSelfViewEnabled: + typeof raw.floatingSelfViewEnabled === "boolean" + ? raw.floatingSelfViewEnabled + : DEFAULT_PREFS.floatingSelfViewEnabled, }; } diff --git a/src/main.tsx b/src/main.tsx index 939360e6..d6a6b126 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,7 +21,8 @@ if ( showNotes || windowType === "hud-overlay" || windowType === "source-selector" || - windowType === "countdown-overlay" + windowType === "countdown-overlay" || + windowType === "floating-self-view" ) { document.body.style.background = "transparent"; document.documentElement.style.background = "transparent"; diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index bb6e143a..9be731ba 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -22,6 +22,8 @@ The HUD starts and controls a recording. The source selector chooses a display o Electron applies `setContentProtection(true)` to the HUD window (`electron/windows.ts:83`). This keeps the controller out of captures and also makes it invisible in screenshots. For a testing session only, `OPENSCREEN_DISABLE_CONTENT_PROTECTION=1` disables the protection; the code warns that the HUD then appears in captures. The tray icon is the reliable way to refocus OpenScreen or stop a recording when the click-through HUD is not convenient or is not visible. +On macOS, the HUD can also open a floating webcam self-view. Native video PiP was tested first, but the saved-file proof on macOS 26 showed its special window inside a full-display ScreenCaptureKit recording even while the owning application was excluded. The shipped path is therefore a frameless, always-on-top BrowserWindow: 320×180 by default, 240×135 minimum, 640×360 maximum, resizable, and visible across Spaces and fullscreen apps. Main creates it hidden before capture so its native ID participates in self-exclusion; its renderer opens a separate low-resolution camera stream only after an authorized active HUD asks to show it, and stops every secondary track on hide, failure, or teardown. A self-view camera failure is non-blocking and never changes the recorder's own stream. The preference is intentionally an auto-show preference only: disabling it prevents the recording-start action, while the in-recording HUD button can still show or hide the self-view manually. + ## Capture backends | Platform | Backend | Code | Produces | @@ -32,6 +34,8 @@ Electron applies `setContentProtection(true)` to the HUD window (`electron/windo The division is an invariant: the native helper owns capture, timing, and encoding; Electron owns session orchestration, output-path selection, persistence, and editor handoff. When the Linux helper binary is absent, the recorder falls back to the Electron `getDisplayMedia` path, which is the one case where Electron still owns the media. +Full-display macOS capture has an additional invariant: OpenScreen must prove that its own application is excluded before capture begins. Main injects its PID and the native IDs of current BrowserWindows; renderer requests cannot choose them. The helper enumerates off-Space shareable windows, resolves the PID to a ScreenCaptureKit application, expands that match to every running application entry with the same bundle identifier, and constructs `SCContentFilter(display:excludingApplications:exceptingWindows: [])`. The empty `exceptingWindows` array matters: those windows would be added back from an excluded app, not excluded again. A separate `excludingWindows` filter is allowed only when application resolution is unavailable and every requested native window ID resolves. If neither strategy is complete, full-display capture stops with `self-capture-exclusion-failed`; single-window capture continues to use `desktopIndependentWindow` and does not depend on self-exclusion. + ## Helper contract A native session is a child process boundary. Electron starts the platform helper with one structured JSON request and sends runtime commands on stdin; `stop` finalizes the output. The helper emits newline-delimited JSON events on stdout. The shared shape contains `schemaVersion`, `recordingId`, a `source` (display or window and its bounds), `video`, `audio`, optional `webcam`, optional cursor mode, and `outputs` paths. The helper reports `ready`, `recording-started`, warnings, errors, and `recording-stopped` events. Windows accepts legacy textual start/stop messages during compatibility handling; the structured events are the reference contract. @@ -41,7 +45,7 @@ Stopping is the part of that boundary that has broken repeatedly (issues #34, #1 | Contract field or behavior | Windows | macOS | Linux | | --- | --- | --- | --- | | Schema | `schemaVersion: 2` | `schemaVersion: 1` | `schemaVersion: 1` | -| Source identity | `sourceId`, `displayId`, optional `windowHandle` | `sourceId`, `displayId`, optional `windowId` | **None, and none is possible.** See below | +| Source identity | `sourceId`, `displayId`, optional `windowHandle` | `sourceId`, `displayId`, optional `windowId`; main-injected exclusion PIDs/window IDs for full displays | **None, and none is possible.** See below | | Video | FPS, dimensions, bitrate | FPS, dimensions, bitrate, and `hideSystemCursor` | FPS and optional bitrate; dimensions come from what the compositor negotiates | | Audio | System loopback and selected microphone flags/device metadata | System audio and microphone flags/device metadata; microphone support is runtime-gated | System and microphone flags; the microphone is matched by PipeWire `node.description`, not by Chromium device id | | Webcam | Native Media Foundation first, exact Electron-resolved DirectShow fallback; muxed into primary MP4 by default | Electron sidecar attached to the session | Electron sidecar attached to the session | diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 62178b67..ce030525 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -413,6 +413,13 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] Confirm the tray or menu-bar item can refocus the HUD after it is hidden. - [ ] Confirm the HUD and notes window are excluded from captured video when content protection is enabled. - [ ] Confirm a physical webcam picture-in-picture records and plays back with the selected layout. +- [ ] With webcam enabled, start a new recording and confirm the capture-safe floating self-view opens automatically; pause/resume must leave it open, while stop, cancel, restart teardown, camera loss, and HUD destruction must close it. +- [ ] Disable **Auto-show floating self-view**, start another recording, and confirm it stays closed. Use the HUD's **Show self-view** control to open it, close it from macOS, then reopen it from the HUD. Verify keyboard focus and activation on the same control. +- [ ] Drag and resize the self-view across all four corners, another Space, and a fullscreen app. Confirm the frameless window stays above ordinary windows and remains within its 240×135–640×360 bounds. +- [ ] Record a full display at 60 fps over high-contrast content with both HUD and floating self-view visible. Move and resize the self-view during the take, extract frames from the saved file, and prove that neither OpenScreen window appears while the underlying content remains present. Do this final proof with content protection enabled and no testing override. +- [ ] Repeat capture exclusion for a selected single window, and confirm source capture still starts even when full-display self-exclusion diagnostics are deliberately made unavailable. +- [ ] Review the helper diagnostics for exclusion strategy, resolution counts, PIDs, bundle identifiers, and window IDs. Confirm they contain no window titles or captured content, and that an intentionally incomplete full-display exclusion fails before recording with localized UI copy. +- [ ] Simulate secondary webcam-track termination and a rejected self-view camera request; confirm recording continues, all secondary tracks stop, and the failure is non-blocking. Physically disconnect the camera and move across displays when the hardware is available; otherwise mark those two checks skipped, not passed. - [ ] Export MP4 and GIF and confirm both files open in a native macOS media viewer. - [ ] Confirm closing and relaunching the packaged app does not leave an orphaned capture or editor window. - [ ] On the newest supported macOS, confirm the HUD and notes windows are visible on screen rather than blanked by content protection.