diff --git a/electron/desktopWallpaper.test.ts b/electron/desktopWallpaper.test.ts index 927de72d9..a39802a61 100644 --- a/electron/desktopWallpaper.test.ts +++ b/electron/desktopWallpaper.test.ts @@ -5,6 +5,8 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const decode = vi.hoisted(() => vi.fn()); +const nativeWallpaper = vi.hoisted(() => vi.fn()); +vi.mock("./ipc/windowsWindowGeometry", () => ({ readWindowsWindowGeometry: nativeWallpaper })); vi.mock("electron", () => ({ nativeImage: { createFromPath: decode } })); vi.mock("./ipc/ffmpeg/binary", () => ({ getFfmpegBinaryPath: () => "/recordly/ffmpeg" })); import { @@ -15,6 +17,7 @@ import { let directory: string; beforeEach(async () => { + nativeWallpaper.mockReset().mockRejectedValue(new Error("older helper")); directory = await fs.mkdtemp(path.join(os.tmpdir(), "recordly-desktop-wallpaper-")); decode .mockReset() @@ -25,6 +28,22 @@ afterEach(async () => { }); describe("desktop wallpaper source", () => { + it("rejects a native no-wallpaper result without trying the slow fallback", async () => { + nativeWallpaper.mockResolvedValue(""); + const run = vi.fn(); + await expect(resolveDesktopWallpaperSource("win32", run)).rejects.toThrow( + "No desktop wallpaper", + ); + expect(run).not.toHaveBeenCalled(); + }); + it("uses the native Windows lookup without launching PowerShell", async () => { + const wallpaper = "C:\\Users\\Name\\背景.jpg"; + nativeWallpaper.mockResolvedValue(wallpaper); + const run = vi.fn(); + expect(await resolveDesktopWallpaperSource("win32", run)).toBe(wallpaper); + expect(nativeWallpaper).toHaveBeenCalledWith(["--desktop-wallpaper"]); + expect(run).not.toHaveBeenCalled(); + }); it("reads macOS wallpaper through AppKit without System Events automation", async () => { const run = vi.fn(async () => JSON.stringify({ source: "/Users/test/My wallpaper.heic" })); expect(await resolveDesktopWallpaperSource("darwin", run)).toBe( diff --git a/electron/desktopWallpaper.ts b/electron/desktopWallpaper.ts index 8dabff485..61ea1a4b8 100644 --- a/electron/desktopWallpaper.ts +++ b/electron/desktopWallpaper.ts @@ -6,6 +6,7 @@ import os from "node:os"; import { promisify } from "node:util"; import { nativeImage } from "electron"; import { getFfmpegBinaryPath } from "./ipc/ffmpeg/binary"; +import { readWindowsWindowGeometry } from "./ipc/windowsWindowGeometry"; const execFileAsync = promisify(execFile); type CommandRunner = (command: string, args: string[]) => Promise; @@ -121,19 +122,26 @@ export async function resolveDesktopWallpaperSource( await run("/usr/bin/osascript", ["-l", "JavaScript", "-e", MAC_WALLPAPER_SCRIPT]), ); } else if (platform === "win32") { - const powershell = path.win32.join( - process.env.SystemRoot || "C:\\Windows", - "System32", - "WindowsPowerShell", - "v1.0", - "powershell.exe", - ); - result = await run(powershell, [ - "-NoProfile", - "-NonInteractive", - "-Command", - WINDOWS_WALLPAPER_SCRIPT, - ]); + try { + const nativePath = await readWindowsWindowGeometry(["--desktop-wallpaper"]); + if (typeof nativePath !== "string") throw new Error("Invalid native wallpaper path"); + result = nativePath; + } catch { + // Older helper bundles retain the PowerShell fallback. + const powershell = path.win32.join( + process.env.SystemRoot || "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + result = await run(powershell, [ + "-NoProfile", + "-NonInteractive", + "-Command", + WINDOWS_WALLPAPER_SCRIPT, + ]); + } } else { throw new Error("Desktop wallpaper is unavailable on this platform"); } diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 28aa51367..b26e25c58 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -254,7 +254,13 @@ interface Window { hudOverlayRendererReady: () => void; hudOverlaySetWebcamPreviewVisible: (visible: boolean) => void; hudOverlaySetInteractiveBounds: ( - bounds: Array<{ left: number; top: number; right: number; bottom: number }> | null, + bounds: Array<{ + left: number; + top: number; + right: number; + bottom: number; + shape?: "ellipse"; + }> | null, ) => void; getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; getHudOverlayMousePassthroughSupported: () => Promise<{ @@ -1173,9 +1179,18 @@ interface Window { onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; - getWindowChrome: () => Promise<{ trafficLightsVisible: boolean }>; + getWindowChrome: () => Promise<{ + trafficLightsVisible: boolean; + customControls?: boolean; + maximized?: boolean; + }>; + windowControl: (action: "minimize" | "maximize" | "close") => Promise<{ success: boolean }>; onWindowChromeChanged: ( - callback: (chrome: { trafficLightsVisible: boolean }) => void, + callback: (chrome: { + trafficLightsVisible: boolean; + customControls?: boolean; + maximized?: boolean; + }) => void, ) => () => void; getPlatform: () => Promise; isWindowFullscreen: () => Promise; diff --git a/electron/hudPointerRecovery.test.ts b/electron/hudPointerRecovery.test.ts new file mode 100644 index 000000000..204a7ee41 --- /dev/null +++ b/electron/hudPointerRecovery.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { startHudPointerRecovery } from "./hudPointerRecovery"; + +afterEach(() => vi.useRealTimers()); + +it("leaves the webcam's transparent corners click-through while recovering its center", async () => { + vi.useFakeTimers(); + let cursor = { x: 301, y: 501 }; + const recover = vi.fn(); + const stop = startHudPointerRecovery({ + canRecover: () => true, + getCursor: () => cursor, + getBounds: () => [{ left: 300, top: 500, right: 500, bottom: 700, shape: "ellipse" }], + recover, + }); + await vi.advanceTimersByTimeAsync(50); + expect(recover).not.toHaveBeenCalled(); + cursor = { x: 400, y: 600 }; + await vi.advanceTimersByTimeAsync(50); + expect(recover).toHaveBeenCalledOnce(); + stop(); +}); + +it("restores buttons without any renderer mouse events, including after native state is lost again", async () => { + vi.useFakeTimers(); + let cursor = { x: 500, y: 720 }; + const recover = vi.fn(); + const stop = startHudPointerRecovery({ + canRecover: () => true, + getCursor: () => cursor, + getBounds: () => [{ left: 300, top: 700, right: 900, bottom: 760 }], + recover, + }); + await vi.advanceTimersByTimeAsync(50); + expect(recover).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(50); + expect(recover).toHaveBeenCalledTimes(2); + cursor = { x: 100, y: 100 }; + await vi.advanceTimersByTimeAsync(100); + expect(recover).toHaveBeenCalledTimes(2); + stop(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("does not intercept the native source picker or hidden HUD and picks up new popovers", async () => { + vi.useFakeTimers(); + let enabled = false; + let bounds = [{ left: 300, top: 700, right: 900, bottom: 760 }]; + const recover = vi.fn(); + const stop = startHudPointerRecovery({ + canRecover: () => enabled, + getCursor: () => ({ x: 500, y: 600 }), + getBounds: () => bounds, + recover, + }); + await vi.advanceTimersByTimeAsync(100); + expect(recover).not.toHaveBeenCalled(); + enabled = true; + await vi.advanceTimersByTimeAsync(50); + expect(recover).not.toHaveBeenCalled(); + bounds = [...bounds, { left: 300, top: 500, right: 900, bottom: 690 }]; + await vi.advanceTimersByTimeAsync(50); + expect(recover).toHaveBeenCalledOnce(); + stop(); +}); diff --git a/electron/hudPointerRecovery.ts b/electron/hudPointerRecovery.ts new file mode 100644 index 000000000..6438d01b5 --- /dev/null +++ b/electron/hudPointerRecovery.ts @@ -0,0 +1,20 @@ +import { + type HudInteractiveBounds, + isPointerOverHudSurface, +} from "../src/components/launch/hudMousePassthrough"; + +/** Recover Windows hover hit testing without relying on forwarded renderer mouse events. */ +export function startHudPointerRecovery(deps: { + canRecover: () => boolean; + getCursor: () => { x: number; y: number }; + getBounds: () => HudInteractiveBounds[] | null; + recover: () => void; +}) { + const timer = setInterval(() => { + if (!deps.canRecover()) return; + const { x, y } = deps.getCursor(); + if (isPointerOverHudSurface(deps.getBounds() ?? [], x, y)) deps.recover(); + }, 50); + timer.unref?.(); + return () => clearInterval(timer); +} diff --git a/electron/ipc/capturePickerSources.test.ts b/electron/ipc/capturePickerSources.test.ts index f6780f5b0..985269384 100644 --- a/electron/ipc/capturePickerSources.test.ts +++ b/electron/ipc/capturePickerSources.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + const mocks = vi.hoisted(() => ({ exec: vi.fn(), sources: vi.fn(), mac: vi.fn(), dip: vi.fn(), + geometry: vi.fn(), })); +vi.mock("./windowsWindowGeometry", () => ({ readWindowsWindowGeometry: mocks.geometry })); vi.mock("electron", () => ({ systemPreferences: { getMediaAccessStatus: () => "granted" } })); vi.mock("node:util", () => ({ promisify: () => mocks.exec })); vi.mock("./cursor/bounds", () => ({ @@ -15,17 +18,32 @@ vi.mock("./utils", () => ({ parseWindowId: (id?: string) => Number(id?.split(":")[1]), getScreen: () => ({ screenToDipRect: mocks.dip }), })); + import { listCapturePickerWindows } from "./capturePickerSources"; + const platform = process.platform; afterEach(() => Object.defineProperty(process, "platform", { value: platform })); beforeEach(() => { vi.resetAllMocks(); + mocks.geometry.mockRejectedValue(new Error("older helper")); mocks.sources.mockResolvedValue([ { id: "window:101:0", name: "Front" }, { id: "window:102:0", name: "Back" }, ]); }); describe("capture picker source adapters", () => { + it("uses native Windows geometry without starting PowerShell", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + mocks.geometry.mockResolvedValue([ + { id: 101, ownerPid: process.pid + 1, x: 100, y: 80, width: 800, height: 600 }, + ]); + mocks.dip.mockImplementation((_window, bounds) => bounds); + expect((await listCapturePickerWindows()).map((entry) => entry.id)).toEqual([ + "window:101:0", + ]); + expect(mocks.geometry).toHaveBeenCalledWith(["--list-windows"]); + expect(mocks.exec).not.toHaveBeenCalled(); + }); it("keeps Linux window selection on its source list without X11 helper probes", async () => { Object.defineProperty(process, "platform", { value: "linux" }); await expect(listCapturePickerWindows()).rejects.toThrow("Use the source list on Linux"); @@ -35,8 +53,8 @@ describe("capture picker source adapters", () => { Object.defineProperty(process, "platform", { value: "win32" }); mocks.exec.mockResolvedValue({ stdout: JSON.stringify([ - { id: 999, x: 0, y: 0, width: 20, height: 20 }, - { id: 101, x: -1600, y: 0, width: 800, height: 600 }, + { id: 999, ownerPid: 99, x: 0, y: 0, width: 20, height: 20 }, + { id: 101, ownerPid: 99, x: -1600, y: 0, width: 800, height: 600 }, ]), }); mocks.dip.mockReturnValue({ x: -800, y: 0, width: 400, height: 300 }); @@ -54,6 +72,24 @@ describe("capture picker source adapters", () => { ]); expect(mocks.dip).toHaveBeenCalledWith(null, expect.objectContaining({ x: -1600 })); }); + it("excludes Recordly's fullscreen Windows HUD without hiding other apps named Recordly", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + mocks.sources.mockResolvedValue([ + { id: "window:100:0", name: "Recordly" }, + { id: "window:101:0", name: "Recordly — browser" }, + ]); + mocks.exec.mockResolvedValue({ + stdout: JSON.stringify([ + { id: 100, ownerPid: process.pid, x: 0, y: 0, width: 1920, height: 1080 }, + { id: 101, ownerPid: process.pid + 1, x: 100, y: 100, width: 800, height: 600 }, + ]), + }); + mocks.dip.mockImplementation((_window, bounds) => bounds); + expect((await listCapturePickerWindows()).map((entry) => entry.id)).toEqual([ + "window:101:0", + ]); + expect(mocks.dip).toHaveBeenCalledTimes(1); + }); it("excludes Recordly's own macOS windows and invalid geometry", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); mocks.mac.mockResolvedValue([ diff --git a/electron/ipc/capturePickerSources.ts b/electron/ipc/capturePickerSources.ts index 37d78bbe2..3a3fc8925 100644 --- a/electron/ipc/capturePickerSources.ts +++ b/electron/ipc/capturePickerSources.ts @@ -1,10 +1,11 @@ -import { systemPreferences } from "electron"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { systemPreferences } from "electron"; import { getNativeMacWindowsFrontToBack } from "./cursor/bounds"; +import { getDesktopSources } from "./register/sources"; import type { CapturePickerWindow, SelectedSource } from "./types"; import { getScreen, parseWindowId } from "./utils"; -import { getDesktopSources } from "./register/sources"; +import { readWindowsWindowGeometry } from "./windowsWindowGeometry"; const exec = promisify(execFile); @@ -19,18 +20,20 @@ public static class RecordlyPickerWindows { [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); [DllImport("user32.dll")] static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT rect); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint processId); [DllImport("user32.dll")] static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context); [DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int attribute, out RECT rect, int size); - public class Entry { public long id; public int x, y, width, height; } + public class Entry { public long id; public uint ownerPid; public int x, y, width, height; } public static Entry[] List() { SetThreadDpiAwarenessContext(new IntPtr(-4)); var list = new List(); var seen = new HashSet(); for (var h = GetTopWindow(IntPtr.Zero); h != IntPtr.Zero && seen.Add(h.ToInt64()); h = GetWindow(h, 2)) { if (!IsWindowVisible(h) || IsIconic(h)) continue; + uint ownerPid; GetWindowThreadProcessId(h, out ownerPid); RECT rect; if (DwmGetWindowAttribute(h, 9, out rect, Marshal.SizeOf(typeof(RECT))) != 0 && !GetWindowRect(h, out rect)) continue; if (rect.Right <= rect.Left || rect.Bottom <= rect.Top) continue; - list.Add(new Entry { id = h.ToInt64(), x = rect.Left, y = rect.Top, width = rect.Right - rect.Left, height = rect.Bottom - rect.Top }); + list.Add(new Entry { id = h.ToInt64(), ownerPid = ownerPid, x = rect.Left, y = rect.Top, width = rect.Right - rect.Left, height = rect.Bottom - rect.Top }); } return list.ToArray(); } @@ -76,17 +79,26 @@ export async function listCapturePickerWindows(): Promise typeof source.id === "string" && source.id.startsWith("window:"), ); const byId = new Map(capturable.map((source) => [parseWindowId(source.id), source])); - const { stdout } = await exec( - "powershell.exe", - ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT], - { timeout: 10000, maxBuffer: 4 * 1024 * 1024 }, - ); - const entries: unknown = JSON.parse(stdout); + let entries: unknown; + try { + entries = await readWindowsWindowGeometry(["--list-windows"]); + } catch { + // Keep selection working with older packaged helper binaries. + const { stdout } = await exec( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT], + { timeout: 15000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, + ); + entries = JSON.parse(stdout); + } if (!Array.isArray(entries)) throw new Error("Unable to read window positions."); return entries.flatMap((entry) => { const source = byId.get(entry.id); if ( !source || + // The transparent fullscreen HUD sits above other apps. Never let it + // (or another Recordly window) mask the windows underneath in hit tests. + entry.ownerPid === process.pid || ![entry.x, entry.y, entry.width, entry.height].every(Number.isFinite) || entry.width <= 0 || entry.height <= 0 diff --git a/electron/ipc/recording/focus.test.ts b/electron/ipc/recording/focus.test.ts new file mode 100644 index 000000000..aa95863c4 --- /dev/null +++ b/electron/ipc/recording/focus.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ windows: vi.fn(), show: vi.fn(), hide: vi.fn() })); +vi.mock("../../windows", () => ({ + showRecordingFocus: mocks.show, + hideRecordingFocus: mocks.hide, +})); +vi.mock("../windowsWindowControl", () => ({ resolveWindowsWindowBounds: mocks.windows })); +vi.mock("../cursor/bounds", () => ({ + resolveLinuxWindowBounds: vi.fn(), + resolveMacWindowBounds: vi.fn(), +})); +vi.mock("../state", () => ({ isCursorCaptureActive: true, selectedWindowBounds: null })); +vi.mock("./ffmpeg", () => ({ getDisplayBoundsForSource: vi.fn() })); + +import { recordingFocus } from "./focus"; + +const platform = process.platform; +afterEach(() => { + recordingFocus.stop(); + Object.defineProperty(process, "platform", { value: platform }); + vi.resetAllMocks(); +}); + +it("keeps a Windows surround visible during recording when the telemetry bounds cache is empty", async () => { + Object.defineProperty(process, "platform", { value: "win32" }); + const source = { id: "window:101:0", name: "Test window" }; + const bounds = { x: 100, y: 80, width: 800, height: 600 }; + mocks.windows.mockResolvedValue(bounds); + await recordingFocus.start(source); + expect(mocks.windows).toHaveBeenCalledWith(source); + expect(mocks.show).toHaveBeenCalledWith(bounds); +}); diff --git a/electron/ipc/recording/focus.ts b/electron/ipc/recording/focus.ts index 2bbfa4fa5..63547d577 100644 --- a/electron/ipc/recording/focus.ts +++ b/electron/ipc/recording/focus.ts @@ -1,8 +1,8 @@ import { createRecordingFocusController } from "../../recordingFocus"; import { hideRecordingFocus, showRecordingFocus } from "../../windows"; import { resolveLinuxWindowBounds, resolveMacWindowBounds } from "../cursor/bounds"; -import { isCursorCaptureActive, selectedWindowBounds } from "../state"; import { getSourceArea } from "../sourceArea"; +import { isCursorCaptureActive, selectedWindowBounds } from "../state"; import { resolveWindowsWindowBounds } from "../windowsWindowControl"; import { getDisplayBoundsForSource } from "./ffmpeg"; @@ -11,9 +11,12 @@ export const recordingFocus = createRecordingFocusController({ const area = getSourceArea(source); if (area) return area; if (source.id?.startsWith("window:")) { - if (isCursorCaptureActive) return selectedWindowBounds; - if (process.platform === "darwin") return resolveMacWindowBounds(source); + // Windows bounds queries are shared with telemetry. Keep resolving the + // actual HWND across the transition instead of hiding the mask when + // recording clears or temporarily misses its telemetry bounds cache. if (process.platform === "win32") return resolveWindowsWindowBounds(source); + if (isCursorCaptureActive && selectedWindowBounds) return selectedWindowBounds; + if (process.platform === "darwin") return resolveMacWindowBounds(source); if (process.platform === "linux") return resolveLinuxWindowBounds(source); return null; } diff --git a/electron/ipc/register/settings.test.ts b/electron/ipc/register/settings.test.ts index 2f6569344..5d525c568 100644 --- a/electron/ipc/register/settings.test.ts +++ b/electron/ipc/register/settings.test.ts @@ -8,10 +8,12 @@ const mocks = vi.hoisted(() => ({ healthySend: vi.fn(), focusStart: vi.fn(), focusStop: vi.fn(), + fromContents: vi.fn(), })); vi.mock("electron", () => ({ app: {}, BrowserWindow: { + fromWebContents: mocks.fromContents, getAllWindows: () => [ { isDestroyed: () => false, @@ -57,6 +59,36 @@ import { writeAppSettingsStore, } from "../../appSettingsStore"; +describe("custom window controls", () => { + const platform = process.platform; + afterEach(() => Object.defineProperty(process, "platform", { value: platform })); + it.each(["win32", "linux", "darwin"])("reports appropriate controls for %s", (platform) => { + Object.defineProperty(process, "platform", { value: platform }); + mocks.fromContents.mockReturnValue({ + isMaximized: () => true, + isFullScreen: () => false, + isSimpleFullScreen: () => false, + }); + registerSettingsHandlers(); + expect(mocks.handlers.get("get-window-chrome")!({ sender: {} })).toEqual({ + customControls: platform !== "darwin", + maximized: true, + trafficLightsVisible: platform === "darwin", + }); + }); + it("applies controls to the sender's window rather than a global window", () => { + const sender = {}; + const close = vi.fn(); + mocks.fromContents.mockReturnValue({ isDestroyed: () => false, close }); + registerSettingsHandlers(); + expect(mocks.handlers.get("window-control")!({ sender }, "close")).toEqual({ + success: true, + }); + expect(mocks.fromContents).toHaveBeenCalledWith(sender); + expect(close).toHaveBeenCalledOnce(); + }); +}); + describe("app setting read status", () => { beforeEach(() => { mocks.syncHandlers.clear(); @@ -163,6 +195,7 @@ describe("inline recording countdown", () => { mocks.handlers.clear(); mocks.send.mockReset(); mocks.healthySend.mockClear(); + mocks.focusStart.mockClear(); registerSettingsHandlers(); }); afterEach(() => { @@ -175,6 +208,7 @@ describe("inline recording countdown", () => { const pending = mocks.handlers.get("start-countdown")!({ sender }, 3); await vi.advanceTimersByTimeAsync(3000); await expect(pending).resolves.toEqual({ success: true }); + expect(mocks.focusStart).not.toHaveBeenCalled(); expect(mocks.send.mock.calls).toEqual([ ["countdown-tick", 3], ["countdown-tick", 2], diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index a1e95b690..63ae0681a 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -1,4 +1,5 @@ import { recordingFocus } from "../recording/focus"; +import { applyWindowControl } from "../../windowControls"; import { createCountdownController } from "../../countdownController"; import fs from "node:fs/promises"; import { app, BrowserWindow, ipcMain } from "electron"; @@ -15,7 +16,6 @@ import { } from "../settings/recordingPreferencesStore"; import { countdownInProgress, - selectedSource, countdownRemaining, setCountdownInProgress, setCountdownRemaining, @@ -56,6 +56,8 @@ export function registerSettingsHandlers() { ipcMain.handle("get-window-chrome", (event) => { const win = BrowserWindow.fromWebContents(event.sender); return { + customControls: process.platform === "win32" || process.platform === "linux", + maximized: win?.isMaximized() ?? false, trafficLightsVisible: process.platform === "darwin" && !!win && @@ -63,6 +65,9 @@ export function registerSettingsHandlers() { !win.isSimpleFullScreen(), }; }); + ipcMain.handle("window-control", (event, action: unknown) => ({ + success: applyWindowControl(BrowserWindow.fromWebContents(event.sender), action), + })); ipcMain.handle("get-platform", () => { return process.platform; @@ -233,7 +238,6 @@ export function registerSettingsHandlers() { const cancel = () => countdown.cancel(); owner.once("destroyed", cancel); owner.once("render-process-gone", cancel); - void recordingFocus.start(selectedSource); try { const result = await countdown.start(seconds); if (!result.success) recordingFocus.stop(); diff --git a/electron/ipc/windowsWindowControl.test.ts b/electron/ipc/windowsWindowControl.test.ts new file mode 100644 index 000000000..6ce5b68ce --- /dev/null +++ b/electron/ipc/windowsWindowControl.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ exec: vi.fn(), geometry: vi.fn() })); +vi.mock("./windowsWindowGeometry", () => ({ readWindowsWindowGeometry: mocks.geometry })); +vi.mock("node:util", () => ({ promisify: () => mocks.exec })); +vi.mock("./utils", () => ({ + parseWindowId: (id: string) => Number(id.split(":")[1]), + getScreen: () => ({}), +})); + +import { resolveWindowsWindowBounds } from "./windowsWindowControl"; + +afterEach(() => vi.resetAllMocks()); +beforeEach(() => mocks.geometry.mockRejectedValue(new Error("older helper"))); + +it("uses the native helper for HWND bounds and accepts a closed window without falling back", async () => { + const source = { id: "window:103:0", name: "Test window" }; + const bounds = { x: 100, y: 80, width: 800, height: 600 }; + mocks.geometry.mockResolvedValueOnce(bounds).mockResolvedValueOnce(null); + expect(await resolveWindowsWindowBounds(source)).toEqual(bounds); + expect(await resolveWindowsWindowBounds(source)).toBeNull(); + expect(mocks.geometry).toHaveBeenCalledWith(["--window-bounds", "103"]); + expect(mocks.exec).not.toHaveBeenCalled(); +}); + +it("shares slow HWND lookups between preview and telemetry, then refreshes moved windows", async () => { + const source = { id: "window:101:0", name: "Test window" }; + const bounds = { x: 100, y: 80, width: 800, height: 600 }; + let finish!: (result: { stdout: string }) => void; + mocks.exec.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const preview = resolveWindowsWindowBounds(source); + const telemetry = resolveWindowsWindowBounds(source); + await Promise.resolve(); + expect(mocks.exec).toHaveBeenCalledOnce(); + finish({ stdout: JSON.stringify(bounds) }); + expect(await preview).toEqual(bounds); + expect(await telemetry).toEqual(bounds); + mocks.exec.mockResolvedValueOnce({ stdout: JSON.stringify({ ...bounds, x: 200 }) }); + expect(await resolveWindowsWindowBounds(source)).toEqual({ ...bounds, x: 200 }); + expect(mocks.exec).toHaveBeenCalledTimes(2); +}); + +it("retries after a failed lookup rather than keeping a failed query cached", async () => { + const source = { id: "window:102:0", name: "Test window" }; + mocks.exec.mockRejectedValueOnce(new Error("timeout")); + expect(await resolveWindowsWindowBounds(source)).toBeNull(); + const bounds = { x: 100, y: 80, width: 800, height: 600 }; + mocks.exec.mockResolvedValueOnce({ stdout: JSON.stringify(bounds) }); + expect(await resolveWindowsWindowBounds(source)).toEqual(bounds); +}); diff --git a/electron/ipc/windowsWindowControl.ts b/electron/ipc/windowsWindowControl.ts index 9c4c43e8d..3a4da026a 100644 --- a/electron/ipc/windowsWindowControl.ts +++ b/electron/ipc/windowsWindowControl.ts @@ -3,6 +3,7 @@ import { promisify } from "node:util"; import type { SelectedSource, WindowBounds } from "./types"; import { getScreen, parseWindowId } from "./utils"; import { convertPhysicalBoundsToDip } from "./windowsCaptureSelection"; +import { readWindowsWindowGeometry } from "./windowsWindowGeometry"; const execFileAsync = promisify(execFile); @@ -27,14 +28,49 @@ export async function bringWindowsWindowForward(windowId: number): Promise }); } -export async function resolveWindowsWindowBounds( - source: SelectedSource, -): Promise { +const pendingBoundsQueries = new Map>(); + +export function resolveWindowsWindowBounds(source: SelectedSource): Promise { + const key = source.id || source.windowTitle || source.name; + const pending = pendingBoundsQueries.get(key); + if (pending) return pending; + // Add-Type can take longer than the 250ms polling interval. Share the + // in-flight lookup between the preview and cursor telemetry rather than + // launching overlapping PowerShell processes until they start timing out. + const query = queryWindowsWindowBounds(source).finally(() => pendingBoundsQueries.delete(key)); + pendingBoundsQueries.set(key, query); + return query; +} + +async function queryWindowsWindowBounds(source: SelectedSource): Promise { const windowId = parseWindowId(source.id); const windowTitle = typeof source.windowTitle === "string" ? source.windowTitle.trim() : source.name.trim(); if (!windowId && !windowTitle) return null; + if (windowId) { + try { + const bounds = (await readWindowsWindowGeometry([ + "--window-bounds", + String(windowId), + ])) as WindowBounds | null; + if (!bounds) return null; + if ( + ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite) || + bounds.width <= 0 || + bounds.height <= 0 + ) + return null; + const electronScreen = getScreen(); + return typeof electronScreen.screenToDipPoint === "function" + ? convertPhysicalBoundsToDip(bounds, (point) => + electronScreen.screenToDipPoint(point), + ) + : bounds; + } catch { + // Older helper bundles do not have geometry commands; retain their fallback. + } + } const script = [ "$windowId = $env:RECORDLY_WINDOW_ID", @@ -77,9 +113,10 @@ export async function resolveWindowsWindowBounds( try { const { stdout } = await execFileAsync( "powershell.exe", - ["-NoProfile", "-Command", script], + ["-NoProfile", "-NonInteractive", "-Command", script], { - timeout: 5000, + timeout: 15000, + windowsHide: true, env: { ...process.env, RECORDLY_WINDOW_ID: String(windowId ?? ""), diff --git a/electron/ipc/windowsWindowGeometry.ts b/electron/ipc/windowsWindowGeometry.ts new file mode 100644 index 000000000..9ce1704ef --- /dev/null +++ b/electron/ipc/windowsWindowGeometry.ts @@ -0,0 +1,15 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { getCursorMonitorExePath } from "./paths/binaries"; + +const exec = promisify(execFile); + +/** Short-lived Win32 probes reuse the shipped helper without starting its cursor monitor. */ +export async function readWindowsWindowGeometry(args: string[]): Promise { + const { stdout } = await exec(getCursorMonitorExePath(), args, { + timeout: 5000, + windowsHide: true, + maxBuffer: 4 * 1024 * 1024, + }); + return JSON.parse(stdout); +} diff --git a/electron/native/bin/win32-x64/cursor-monitor.exe b/electron/native/bin/win32-x64/cursor-monitor.exe index 70738d118..136a2d986 100644 Binary files a/electron/native/bin/win32-x64/cursor-monitor.exe and b/electron/native/bin/win32-x64/cursor-monitor.exe differ diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index dd02a3777..af4ad61a1 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -12,10 +12,10 @@ }, "cursor-monitor": { "binaryName": "cursor-monitor.exe", - "binarySha256": "f1d8f30e8d7bee19ecea91c9a90a95ea4824138b8336b18030fa0602a641d70d", + "binarySha256": "a7927fe59c3b64cdca86a187580fd9e79327ece1fd4c17567c508e0617556015", "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "45bb72e4039e061a354af87317d7c42ec3d2118369b3f4379046ff497b701e72", - "updatedAt": "2026-07-11T11:58:56.534Z" + "sourceFingerprint": "a18388230190d5d555472927bad21efb7e753e155ca76cc1b78f20823dffc046", + "updatedAt": "2026-11-13T04:40:28.720Z" }, "recordly-gpu-export": { "binaryName": "recordly-gpu-export.exe", diff --git a/electron/native/cursor-monitor/CMakeLists.txt b/electron/native/cursor-monitor/CMakeLists.txt index 8155aed95..5f0b1e95e 100644 --- a/electron/native/cursor-monitor/CMakeLists.txt +++ b/electron/native/cursor-monitor/CMakeLists.txt @@ -6,4 +6,4 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(cursor-monitor src/main.cpp) -target_link_libraries(cursor-monitor PRIVATE user32) +target_link_libraries(cursor-monitor PRIVATE user32 dwmapi) diff --git a/electron/native/cursor-monitor/src/main.cpp b/electron/native/cursor-monitor/src/main.cpp index 9a811f7be..386f1812c 100644 --- a/electron/native/cursor-monitor/src/main.cpp +++ b/electron/native/cursor-monitor/src/main.cpp @@ -1,10 +1,14 @@ #include +#include +#include #include #include +#include #include #include #include #include +#include static std::atomic g_running{true}; @@ -19,9 +23,78 @@ static void stdinListener() { g_running.store(false); } -int main() { +static bool windowBounds(HWND window, RECT& rect) { + if (!IsWindowVisible(window) || IsIconic(window)) return false; + if (DwmGetWindowAttribute(window, DWMWA_EXTENDED_FRAME_BOUNDS, &rect, sizeof(rect)) != S_OK && + !GetWindowRect(window, &rect)) return false; + return rect.right > rect.left && rect.bottom > rect.top; +} + +static void printBounds(const RECT& rect) { + std::cout << "\"x\":" << rect.left << ",\"y\":" << rect.top + << ",\"width\":" << rect.right - rect.left + << ",\"height\":" << rect.bottom - rect.top; +} + +int main(int argc, char* argv[]) { std::setvbuf(stdout, nullptr, _IONBF, 0); + if (argc > 1) { + SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + const std::string command = argv[1]; + if (command == "--desktop-wallpaper" && argc == 2) { + wchar_t wallpaper[32768]{}; + if (!SystemParametersInfoW(SPI_GETDESKWALLPAPER, 32768, wallpaper, 0)) return 1; + const int length = lstrlenW(wallpaper); + if (length == 0) { + std::cout << "\"\"\n"; + return 0; + } + const int bytes = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wallpaper, + length, nullptr, 0, nullptr, nullptr); + if (bytes <= 0) return 1; + std::string utf8(bytes, '\0'); + if (!WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wallpaper, + length, utf8.data(), bytes, nullptr, nullptr)) return 1; + std::cout << std::quoted(utf8) << '\n'; + return 0; + } + if (command == "--window-bounds" && argc == 3) { + try { + size_t consumed = 0; + const auto handle = std::stoull(argv[2], &consumed); + if (consumed != std::string(argv[2]).size() || handle == 0) return 1; + RECT rect{}; + if (!windowBounds(reinterpret_cast(static_cast(handle)), rect)) { + std::cout << "null\n"; + } else { + std::cout << '{'; printBounds(rect); std::cout << "}\n"; + } + return 0; + } catch (...) { return 1; } + } + if (command == "--list-windows" && argc == 2) { + std::unordered_set seen; + bool first = true; + std::cout << '['; + for (HWND window = GetTopWindow(nullptr); window && seen.insert(window).second; + window = GetWindow(window, GW_HWNDNEXT)) { + RECT rect{}; + if (!windowBounds(window, rect)) continue; + DWORD ownerPid = 0; + GetWindowThreadProcessId(window, &ownerPid); + if (!first) std::cout << ','; + first = false; + std::cout << "{\"id\":" << reinterpret_cast(window) + << ",\"ownerPid\":" << ownerPid << ','; + printBounds(rect); std::cout << '}'; + } + std::cout << "]\n"; + return 0; + } + return 1; + } + std::unordered_map cursorMap; cursorMap[LoadCursor(NULL, IDC_ARROW)] = "arrow"; cursorMap[LoadCursor(NULL, IDC_IBEAM)] = "text"; diff --git a/electron/preload.ts b/electron/preload.ts index 4c2ac8ca3..51e4eac12 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -229,7 +229,13 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.send("hud-overlay-set-webcam-preview-visible", visible); }, hudOverlaySetInteractiveBounds: ( - bounds: Array<{ left: number; top: number; right: number; bottom: number }> | null, + bounds: Array<{ + left: number; + top: number; + right: number; + bottom: number; + shape?: "ellipse"; + }> | null, ) => { ipcRenderer.send("hud-overlay-set-interactive-bounds", bounds); }, @@ -1147,10 +1153,22 @@ contextBridge.exposeInMainWorld("electronAPI", { return () => ipcRenderer.removeListener("menu-save-project-as", listener); }, getWindowChrome: () => ipcRenderer.invoke("get-window-chrome"), - onWindowChromeChanged: (callback: (chrome: { trafficLightsVisible: boolean }) => void) => { + windowControl: (action: "minimize" | "maximize" | "close") => + ipcRenderer.invoke("window-control", action), + onWindowChromeChanged: ( + callback: (chrome: { + trafficLightsVisible: boolean; + customControls?: boolean; + maximized?: boolean; + }) => void, + ) => { const listener = ( _event: Electron.IpcRendererEvent, - chrome: { trafficLightsVisible: boolean }, + chrome: { + trafficLightsVisible: boolean; + customControls?: boolean; + maximized?: boolean; + }, ) => callback(chrome); ipcRenderer.on("window-chrome-changed", listener); return () => ipcRenderer.removeListener("window-chrome-changed", listener); diff --git a/electron/recordingFocus.test.ts b/electron/recordingFocus.test.ts index 105b751c2..821783fd4 100644 --- a/electron/recordingFocus.test.ts +++ b/electron/recordingFocus.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRecordingFocusController, outsideRecordingRects } from "./recordingFocus"; + const display = { x: 0, y: 0, width: 1600, height: 1000 }; const area = { x: 200, y: 100, width: 900, height: 650 }; const source = { id: "area:1", name: "Area", area }; @@ -7,6 +8,28 @@ const intersects = (a: typeof area, b: typeof area) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; describe("outside recording geometry", () => { + it("leaves the HUD, webcam and menus undimmed without covering the capture", () => { + const controls = [ + { x: 600, y: 900, width: 400, height: 60 }, + { x: 100, y: 750, width: 200, height: 200 }, + { x: 600, y: 680, width: 300, height: 180 }, + ]; + const strips = outsideRecordingRects([display], area, controls); + for (const rect of strips) { + expect(intersects(rect, area)).toBe(false); + for (const control of controls) expect(intersects(rect, control)).toBe(false); + } + for (let x = 0; x < display.width; x += 20) { + for (let y = 0; y < display.height; y += 20) { + const contains = (rect: typeof area) => + x >= rect.x && + x < rect.x + rect.width && + y >= rect.y && + y < rect.y + rect.height; + expect(strips.some(contains)).toBe(![area, ...controls].some(contains)); + } + } + }); it("covers precisely the exterior without touching recorded pixels or overlapping strips", () => { const strips = outsideRecordingRects([display], area); expect(strips).toHaveLength(4); @@ -99,9 +122,12 @@ it("removes masks if the window disappears or bounds detection fails", async () expect(deps.hide).toHaveBeenCalledTimes(2); controller.stop(); }); -it("source notifications do not start a mask outside an active countdown or recording", () => { +it("does not dim the desktop when selecting a source before recording", async () => { const { deps, controller } = fixture(); controller.sourceChanged(source); + await vi.advanceTimersByTimeAsync(0); expect(deps.resolve).not.toHaveBeenCalled(); + expect(deps.show).not.toHaveBeenCalled(); + controller.sourceChanged(null); expect(vi.getTimerCount()).toBe(0); }); diff --git a/electron/recordingFocus.ts b/electron/recordingFocus.ts index fb274b413..e67b0c8c1 100644 --- a/electron/recordingFocus.ts +++ b/electron/recordingFocus.ts @@ -1,14 +1,18 @@ import type { CaptureArea, SelectedSource } from "./ipc/types"; /** Non-overlapping strips which never cover a pixel inside the recording. */ -export function outsideRecordingRects(displays: CaptureArea[], target: CaptureArea): CaptureArea[] { +export function outsideRecordingRects( + displays: CaptureArea[], + target: CaptureArea, + exclusions: CaptureArea[] = [], +): CaptureArea[] { if ( ![target.x, target.y, target.width, target.height].every(Number.isFinite) || target.width <= 0 || target.height <= 0 ) return []; - return displays.flatMap((display) => { + const rectangles = displays.flatMap((display) => { // Round the hole outward: fractional display points must never dim a captured edge. const left = Math.max(display.x, Math.floor(target.x)); const top = Math.max(display.y, Math.floor(target.y)); @@ -27,6 +31,11 @@ export function outsideRecordingRects(displays: CaptureArea[], target: CaptureAr { x: right, y: top, width: display.x + display.width - right, height: bottom - top }, ].filter((rect) => rect.width > 0 && rect.height > 0); }); + return exclusions.reduce( + (remaining, exclusion) => + remaining.flatMap((rect) => outsideRecordingRects([rect], exclusion)), + rectangles, + ); } export function createRecordingFocusController(deps: { @@ -81,9 +90,9 @@ export function createRecordingFocusController(deps: { }, stop, sourceChanged(next: SelectedSource | null) { - if (source) { - void this.start(next); - } + // A selection alone must not dim the desktop. Only update an already + // active recording surround; recording state starts and stops it. + if (source) void this.start(next); }, }; } diff --git a/electron/windowControls.test.ts b/electron/windowControls.test.ts new file mode 100644 index 000000000..823320547 --- /dev/null +++ b/electron/windowControls.test.ts @@ -0,0 +1,38 @@ +import { expect, it, vi } from "vitest"; +import { applyWindowControl } from "./windowControls"; + +function windowFixture() { + return { + isDestroyed: () => false, + isMaximized: vi.fn(() => false), + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + close: vi.fn(), + }; +} + +it("minimizes and toggles maximize/restore on the calling window", () => { + const win = windowFixture(); + expect(applyWindowControl(win, "minimize")).toBe(true); + expect(win.minimize).toHaveBeenCalledOnce(); + applyWindowControl(win, "maximize"); + expect(win.maximize).toHaveBeenCalledOnce(); + win.isMaximized.mockReturnValue(true); + applyWindowControl(win, "maximize"); + expect(win.unmaximize).toHaveBeenCalledOnce(); +}); + +it("uses normal close so the unsaved-change handler can cancel it", () => { + const win = windowFixture(); + expect(applyWindowControl(win, "close")).toBe(true); + expect(win.close).toHaveBeenCalledOnce(); +}); + +it("ignores unknown actions and destroyed or missing windows", () => { + const win = windowFixture(); + expect(applyWindowControl(win, "quit")).toBe(false); + expect(applyWindowControl(null, "close")).toBe(false); + expect(applyWindowControl({ ...win, isDestroyed: () => true }, "close")).toBe(false); + expect(win.close).not.toHaveBeenCalled(); +}); diff --git a/electron/windowControls.ts b/electron/windowControls.ts new file mode 100644 index 000000000..2c169a92b --- /dev/null +++ b/electron/windowControls.ts @@ -0,0 +1,25 @@ +import type { BrowserWindow } from "electron"; + +type ControlledWindow = Pick< + BrowserWindow, + "isDestroyed" | "isMaximized" | "minimize" | "maximize" | "unmaximize" | "close" +>; + +export function applyWindowControl(win: ControlledWindow | null, action: unknown): boolean { + if (!win || win.isDestroyed()) return false; + switch (action) { + case "minimize": + win.minimize(); + return true; + case "maximize": + if (win.isMaximized()) win.unmaximize(); + else win.maximize(); + return true; + case "close": + // Preserve the ordinary close event and its unsaved-project prompt. + win.close(); + return true; + default: + return false; + } +} diff --git a/electron/windows.ts b/electron/windows.ts index 803f99498..c32423e94 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -20,6 +20,7 @@ import { shouldExpandHudOverlayFallback, } from "./hudOverlayBounds"; import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions"; +import { startHudPointerRecovery } from "./hudPointerRecovery"; import { sendNativeHudCaptureExclusions } from "./ipc/recording/nativeCaptureCommand"; import { currentProjectPath, @@ -61,6 +62,7 @@ let hudOverlayInteractiveBounds: Array<{ top: number; right: number; bottom: number; + shape?: "ellipse"; }> | null = null; let countdownWindow: BrowserWindow | null = null; let capturePickerWindows: BrowserWindow[] = []; @@ -501,7 +503,7 @@ ipcMain.on( ( _event, bounds: - | Array<{ left: number; top: number; right: number; bottom: number }> + | Array<{ left: number; top: number; right: number; bottom: number; shape?: "ellipse" }> | null | undefined, ) => { @@ -743,6 +745,23 @@ export function createHudOverlayWindow(): BrowserWindow { // Reset the user's saved HUD position when displays change so the bar // doesn't end up stranded off-screen after a monitor is disconnected. const screen = getScreen(); + const stopPointerRecovery = + process.platform === "win32" + ? startHudPointerRecovery({ + canRecover: () => + !win.isDestroyed() && win.isVisible() && !hudOverlaySourceSelectionActive, + getCursor: () => screen.getCursorScreenPoint(), + getBounds: () => hudOverlayInteractiveBounds, + recover: () => { + // A sibling overlay or focus change can break forwarded mousemove. + // Reassert the native flag over controls even if our cached state says + // they are already interactive. The renderer still handles leaving, + // popover dismissal and drag capture as usual. + hudOverlayIgnoringMouse = false; + win.setIgnoreMouseEvents(false); + }, + }) + : () => undefined; const handleDisplayRemoved = () => { hudUserPosition = null; }; @@ -766,6 +785,7 @@ export function createHudOverlayWindow(): BrowserWindow { screen.on("display-metrics-changed", handleDisplayMetricsChanged); win.on("closed", () => { + stopPointerRecovery(); ipcMain.removeListener("hud-overlay-renderer-ready", handleHudRendererReady); screen.removeListener("display-removed", handleDisplayRemoved); screen.removeListener("display-metrics-changed", handleDisplayMetricsChanged); @@ -1060,6 +1080,7 @@ export function createEditorWindow(): BrowserWindow { }), minWidth: 800, minHeight: 600, + ...(!isMac && { frame: false }), ...(process.platform !== "darwin" && { icon: WINDOW_ICON_PATH, }), @@ -1097,11 +1118,15 @@ export function createEditorWindow(): BrowserWindow { if (!win.isDestroyed()) win.webContents.send("window-chrome-changed", { trafficLightsVisible: isMac && !win.isFullScreen() && !win.isSimpleFullScreen(), + customControls: !isMac, + maximized: win.isMaximized(), }); }; win.on("enter-full-screen", publishWindowChrome); win.on("leave-full-screen", publishWindowChrome); win.on("resize", publishWindowChrome); + win.on("maximize", publishWindowChrome); + win.on("unmaximize", publishWindowChrome); win.webContents.on("did-finish-load", publishWindowChrome); win.once("ready-to-show", () => { @@ -1591,11 +1616,22 @@ export function hideAreaRecordingBorder(): void { /** Click-through exterior strips leave the recorded pixels completely untouched. */ export function showRecordingFocus(target: CaptureArea): void { + const hud = getHudOverlayWindow(); + const protectedAreas = + hud?.isVisible() && !hud.isMinimized() + ? (hudOverlayInteractiveBounds ?? []).map((rect) => ({ + x: rect.left - 8, + y: rect.top - 8, + width: rect.right - rect.left + 16, + height: rect.bottom - rect.top + 16, + })) + : []; const rectangles = outsideRecordingRects( getScreen() .getAllDisplays() .map((display) => display.bounds), target, + protectedAreas, ); for (let i = 0; i < rectangles.length; i++) { const bounds = rectangles[i]; @@ -1620,7 +1656,11 @@ export function showRecordingFocus(target: CaptureArea): void { }); win.setIgnoreMouseEvents(true); win.setContentProtection(true); - win.setAlwaysOnTop(true, "floating", -1); + win.setAlwaysOnTop( + true, + process.platform === "win32" ? "screen-saver" : "floating", + -1, + ); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true, skipTransformProcessType: true, @@ -1632,8 +1672,11 @@ export function showRecordingFocus(target: CaptureArea): void { `data:text/html;charset=utf-8,${encodeURIComponent('')}`, ) .then(() => { - if (!created.isDestroyed() && recordingFocusWindows.includes(created)) + if (!created.isDestroyed() && recordingFocusWindows.includes(created)) { created.showInactive(); + created.setIgnoreMouseEvents(true); + raiseHudAboveRecordingFocus(); + } }) .catch(() => { if (!created.isDestroyed()) created.close(); @@ -1647,10 +1690,22 @@ export function showRecordingFocus(target: CaptureArea): void { old.height !== bounds.height ) win.setBounds(bounds); + // Bringing the capture target forward must not leave the surround + // behind it. Restore hidden strips without taking keyboard focus. + if (process.platform === "win32") win.setAlwaysOnTop(true, "screen-saver", -1); + if (!win.isVisible()) win.showInactive(); + win.setIgnoreMouseEvents(true); } } for (const win of recordingFocusWindows.splice(rectangles.length)) if (!win.isDestroyed()) win.close(); + raiseHudAboveRecordingFocus(); +} + +function raiseHudAboveRecordingFocus() { + if (capturePickerWindows.some((win) => !win.isDestroyed())) return; + const hud = getHudOverlayWindow(); + if (hud?.isVisible() && !hud.isMinimized()) hud.moveTop(); } export function hideRecordingFocus(): void { diff --git a/src/components/auth/OnboardingPermissions.test.tsx b/src/components/auth/OnboardingPermissions.test.tsx index 2a5d88aa7..a635be0d5 100644 --- a/src/components/auth/OnboardingPermissions.test.tsx +++ b/src/components/auth/OnboardingPermissions.test.tsx @@ -66,6 +66,13 @@ vi.mock("@/contexts/I18nContext", () => ({ })); vi.mock("@/lib/appSettings", () => ({ saveAppSetting: vi.fn() })); vi.mock("@heroui/react", () => ({ Button: "button" })); +vi.mock("@phosphor-icons/react", () => ({ + ArrowLeft: "arrow-left", + Check: "check", + CheckCircle: "check-circle", + WarningCircle: "warning-circle", +})); +vi.mock("@/components/video-editor/SettingsRow", () => ({ SettingsRow: "settings-row" })); vi.mock("motion/react", () => ({ useReducedMotion: () => true, motion: { div: "div" } })); vi.mock("@/components/feedback/ErrorReportButton", () => ({ ErrorReportButton: "report" })); vi.mock("./PermissionsConfetti", () => ({ PermissionsConfetti: "confetti" })); @@ -85,9 +92,9 @@ function nodes(node: ReactNode): ReactElement>[] { const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; -function render() { +function render(onFinish = vi.fn()) { state.index = 0; - const result = OnboardingPermissions({ onFinish: vi.fn() }); + const result = OnboardingPermissions({ onFinish }); for (const effect of state.effects.splice(0)) effect(); return nodes(result); } @@ -124,6 +131,23 @@ afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); +it.each([ + "win32", + "linux", +])("skips macOS permission setup on %s without requesting access", async (platform) => { + vi.mocked(window.electronAPI.getPlatform).mockResolvedValue(platform); + const onFinish = vi.fn(); + const checking = render(onFinish); + expect(checking.some((node) => node.type === "header")).toBe(false); + await flush(); + expect(render(onFinish)).toEqual([]); + expect(onFinish).toHaveBeenCalledOnce(); + render(onFinish); + expect(onFinish).toHaveBeenCalledOnce(); + expect(window.electronAPI.getScreenRecordingPermissionStatus).not.toHaveBeenCalled(); + expect(window.electronAPI.getAccessibilityPermissionStatus).not.toHaveBeenCalled(); + expect(window.electronAPI.openScreenRecordingPreferences).not.toHaveBeenCalled(); +}); it("keeps a failed permission request visible through successful status polls and clears it on retry", async () => { render(); await flush(); diff --git a/src/components/auth/OnboardingPermissions.tsx b/src/components/auth/OnboardingPermissions.tsx index 1a0006f52..437f4afe8 100644 --- a/src/components/auth/OnboardingPermissions.tsx +++ b/src/components/auth/OnboardingPermissions.tsx @@ -42,6 +42,7 @@ export function OnboardingPermissions({ const error = requestError ?? statusError; const mounted = useRef(false); const refreshing = useRef(false); + const finishedWithoutPermissions = useRef(false); const canSimulate = import.meta.env.DEV && preview; useEffect(() => { if (!preview) saveAppSetting("recordly.onboarding.permissionsRequested", true); @@ -148,20 +149,26 @@ export function OnboardingPermissions({ setBusy(undefined); } }; + useEffect(() => { + if (platform !== "other" || finishedWithoutPermissions.current) return; + finishedWithoutPermissions.current = true; + onFinish(); + }, [platform, onFinish]); + + // Windows and Linux choose a capture source when recording starts. They do + // not have the macOS screen-recording and Accessibility permission setup. + if (platform === "other") return null; + if (platform === "checking") return
{t("checking")}
; return (
- + - } - description={platform === "other" ? t("otherDescription") : undefined} - /> - {canSimulate && platform !== "other" && ( + } /> + {canSimulate && (
{ setSimulatedMissing({ screen: true, accessibility: true }); setRequestError(undefined); @@ -180,82 +187,74 @@ export function OnboardingPermissions({
)} - {platform !== "other" && ( - <> -
- {[ - { - key: "screen" as const, - name: t("screen"), - description: t("screenDescription"), - granted: granted.screen, - }, - { - key: "accessibility" as const, - name: t("accessibility"), - description: t("accessibilityDescription"), - granted: granted.accessibility, - }, - ].map(({ key, name, description, granted }, index) => ( - -
-

{name}

-

- {description} -

-
-
- {granted ? ( - - - ) : ( - - )} -
-
- ))} -
- {error && ( -

- {error} -

- )} - + + )} +
+ + ))} + + {error && ( +

+ {error} +

)} {showFooter && ( diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 78b200b65..6858cf4e4 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -20,6 +20,7 @@ import { CountdownDelayIcon } from "./CountdownDelayIcon"; import { HudInteractionContext } from "./contexts/HudInteractionContext"; import { FloatingWebcamPreviewView } from "./FloatingWebcamPreviewView"; import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; +import { getHudInteractiveBounds } from "./hudMousePassthrough"; import { useHudBarDrag } from "./hooks/useHudBarDrag"; import { useHudStateTransition } from "./hooks/useHudStateTransition"; import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; @@ -159,29 +160,21 @@ function LaunchWindowContent() { useEffect(() => { const reportInteractiveBounds = () => { - const elements = [hudBarRef.current, recordingWebcamPreviewContainerRef.current].filter( - (element): element is HTMLDivElement => element !== null, - ); - - if (elements.length === 0) { - window.electronAPI?.hudOverlaySetInteractiveBounds?.(null); - return; - } - window.electronAPI?.hudOverlaySetInteractiveBounds?.( - elements.map((element) => { - const rect = element.getBoundingClientRect(); - return { - left: window.screenX + rect.left, - top: window.screenY + rect.top, - right: window.screenX + rect.right, - bottom: window.screenY + rect.bottom, - }; - }), + getHudInteractiveBounds(document).map((rect) => ({ + left: window.screenX + rect.left, + top: window.screenY + rect.top, + right: window.screenX + rect.right, + bottom: window.screenY + rect.bottom, + shape: rect.shape, + })), ); }; reportInteractiveBounds(); + // Keep native recovery current even when Windows stops forwarding mouse + // events. Include popovers and toasts, not just the main bar and webcam. + const boundsTimer = window.setInterval(reportInteractiveBounds, 100); const resizeObserver = new ResizeObserver(reportInteractiveBounds); if (hudBarRef.current) resizeObserver.observe(hudBarRef.current); if (recordingWebcamPreviewContainerRef.current) { @@ -191,17 +184,13 @@ function LaunchWindowContent() { window.addEventListener("resize", reportInteractiveBounds); return () => { + window.clearInterval(boundsTimer); resizeObserver.disconnect(); window.removeEventListener("mousemove", reportInteractiveBounds); window.removeEventListener("resize", reportInteractiveBounds); window.electronAPI?.hudOverlaySetInteractiveBounds?.(null); }; - }, [ - recordingHudOffset, - recordingWebcamPreviewContainerRef, - showRecordingWebcamPreview, - webcamPreview.webcamPreviewOffset, - ]); + }, [recordingWebcamPreviewContainerRef]); const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = useLaunchHudInteractionState({ diff --git a/src/components/video-editor/layout/AppFrameHeader.tsx b/src/components/video-editor/layout/AppFrameHeader.tsx index 4cd72a59a..130f5d909 100644 --- a/src/components/video-editor/layout/AppFrameHeader.tsx +++ b/src/components/video-editor/layout/AppFrameHeader.tsx @@ -1,4 +1,5 @@ -import type { CSSProperties, ReactNode } from "react"; +import { type CSSProperties, type ReactNode, useEffect, useState } from "react"; +import { WindowControls } from "./WindowControls"; export function AppFrameHeader({ children, @@ -7,13 +8,44 @@ export function AppFrameHeader({ children: ReactNode; className?: string; }) { + const [chrome, setChrome] = useState({ + customControls: false, + maximized: false, + }); + useEffect(() => { + let disposed = false; + const update = (state: { customControls?: boolean; maximized?: boolean }) => { + if (!disposed) + setChrome({ + customControls: state.customControls === true, + maximized: state.maximized === true, + }); + }; + void window.electronAPI + ?.getWindowChrome?.() + .then(update) + .catch(() => undefined); + const unsubscribe = window.electronAPI?.onWindowChromeChanged?.(update); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); return ( <>
{children} + {chrome.customControls && }
); diff --git a/src/components/video-editor/layout/WindowControls.test.tsx b/src/components/video-editor/layout/WindowControls.test.tsx new file mode 100644 index 000000000..355aef69b --- /dev/null +++ b/src/components/video-editor/layout/WindowControls.test.tsx @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { WindowControls } from "./WindowControls"; + +vi.mock("@/contexts/I18nContext", () => ({ + useI18n: () => ({ t: (_key: string, fallback: string) => fallback }), +})); + +afterEach(() => vi.unstubAllGlobals()); + +it("exposes accessible caption buttons and sends the corresponding native actions", () => { + const control = vi.fn(); + vi.stubGlobal("window", { electronAPI: { windowControl: control } }); + const buttons = WindowControls({ maximized: false }).props.children; + expect(buttons.map((button) => button.props["aria-label"])).toEqual([ + "Minimize", + "Maximize", + "Close", + ]); + for (const button of buttons) button.props.onClick(); + expect(control.mock.calls).toEqual([["minimize"], ["maximize"], ["close"]]); +}); + +it("labels the maximize button as restore when the native window is maximized", () => { + expect(WindowControls({ maximized: true }).props.children[1].props["aria-label"]).toBe( + "Restore window", + ); +}); diff --git a/src/components/video-editor/layout/WindowControls.tsx b/src/components/video-editor/layout/WindowControls.tsx new file mode 100644 index 000000000..c95531f81 --- /dev/null +++ b/src/components/video-editor/layout/WindowControls.tsx @@ -0,0 +1,58 @@ +import type { CSSProperties } from "react"; +import { useI18n } from "@/contexts/I18nContext"; + +export function WindowControls({ maximized }: { maximized: boolean }) { + const { t } = useI18n(); + const actions = [ + { + action: "minimize" as const, + label: t("common.actions.minimize", "Minimize"), + path: "M0 5.5h10", + }, + { + action: "maximize" as const, + label: maximized + ? t("common.actions.restoreWindow", "Restore window") + : t("common.actions.maximize", "Maximize"), + path: maximized ? "M2.5 2.5V.5h7v7h-2M.5 2.5h7v7h-7z" : "M.5.5h9v9h-9z", + }, + { + action: "close" as const, + label: t("common.actions.close", "Close"), + path: "m.5.5 9 9m0-9-9 9", + }, + ]; + return ( +
+ {actions.map(({ action, label, path }) => ( + + ))} +
+ ); +} diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 81837926a..02f316124 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -18,7 +18,10 @@ "delete": "Löschen", "done": "Fertig", "retry": "Erneut versuchen", - "apply": "Anwenden" + "apply": "Anwenden", + "minimize": "Minimieren", + "maximize": "Maximieren", + "restoreWindow": "Fenster wiederherstellen" }, "errors": { "invalidFileType": "Ungültiger Dateityp", @@ -83,5 +86,6 @@ "downloadingButton": "Wird heruntergeladen · {{progress}}" }, "width": "Breite", - "height": "Höhe" + "height": "Höhe", + "windowControls": "Fenstersteuerung" } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 0875d4066..01af732c4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -18,7 +18,10 @@ "delete": "Delete", "done": "Done", "retry": "Try again", - "apply": "Apply" + "apply": "Apply", + "minimize": "Minimize", + "maximize": "Maximize", + "restoreWindow": "Restore window" }, "errors": { "invalidFileType": "Invalid file type", @@ -83,5 +86,6 @@ "downloadingButton": "Downloading · {{progress}}" }, "width": "Width", - "height": "Height" + "height": "Height", + "windowControls": "Window controls" } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index a9c183aab..d11baf468 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -18,7 +18,10 @@ "delete": "Eliminar", "done": "Hecho", "retry": "Intentar de nuevo", - "apply": "Aplicar" + "apply": "Aplicar", + "minimize": "Minimizar", + "maximize": "Maximizar", + "restoreWindow": "Restaurar ventana" }, "errors": { "invalidFileType": "Tipo de archivo inválido", @@ -83,5 +86,6 @@ "downloadingButton": "Descargando · {{progress}}" }, "width": "Ancho", - "height": "Alto" + "height": "Alto", + "windowControls": "Controles de ventana" } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 250885d60..a54173587 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -18,7 +18,10 @@ "delete": "Supprimer", "done": "Terminé", "retry": "Réessayer", - "apply": "Appliquer" + "apply": "Appliquer", + "minimize": "Réduire", + "maximize": "Agrandir", + "restoreWindow": "Restaurer la fenêtre" }, "errors": { "invalidFileType": "Type de fichier invalide", @@ -83,5 +86,6 @@ "downloadingButton": "Téléchargement · {{progress}}" }, "width": "Largeur", - "height": "Hauteur" + "height": "Hauteur", + "windowControls": "Commandes de fenêtre" } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 77464a56e..2c1a388c7 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -18,7 +18,10 @@ "delete": "Elimina", "done": "Fatto", "retry": "Riprova", - "apply": "Applica" + "apply": "Applica", + "minimize": "Riduci a icona", + "maximize": "Ingrandisci", + "restoreWindow": "Ripristina finestra" }, "errors": { "invalidFileType": "Tipo di file non valido", @@ -83,5 +86,6 @@ "downloadingButton": "Download · {{progress}}" }, "width": "Larghezza", - "height": "Altezza" + "height": "Altezza", + "windowControls": "Controlli finestra" } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 87b1603e4..5f15fa75f 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -18,7 +18,10 @@ "delete": "削除", "done": "完了", "retry": "再試行", - "apply": "適用" + "apply": "適用", + "minimize": "最小化", + "maximize": "最大化", + "restoreWindow": "ウィンドウを元に戻す" }, "errors": { "invalidFileType": "対応していないファイル形式です", @@ -83,5 +86,6 @@ "downloadingButton": "ダウンロード中 · {{progress}}" }, "width": "幅", - "height": "高さ" + "height": "高さ", + "windowControls": "ウィンドウ操作" } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 674c1ea6b..8e5d323f0 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -18,7 +18,10 @@ "delete": "삭제", "done": "완료", "retry": "다시 시도", - "apply": "적용" + "apply": "적용", + "minimize": "최소화", + "maximize": "최대화", + "restoreWindow": "창 복원" }, "errors": { "invalidFileType": "잘못된 파일 형식입니다", @@ -83,5 +86,6 @@ "downloadingButton": "다운로드 중 · {{progress}}" }, "width": "너비", - "height": "높이" + "height": "높이", + "windowControls": "창 제어" } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index df9b2d553..129e8aba6 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -18,7 +18,10 @@ "delete": "Verwijderen", "done": "Klaar", "retry": "Opnieuw proberen", - "apply": "Toepassen" + "apply": "Toepassen", + "minimize": "Minimaliseren", + "maximize": "Maximaliseren", + "restoreWindow": "Venster herstellen" }, "errors": { "invalidFileType": "Ongeldig bestandstype", @@ -83,5 +86,6 @@ "downloadingButton": "Downloaden · {{progress}}" }, "width": "Breedte", - "height": "Hoogte" + "height": "Hoogte", + "windowControls": "Vensterbediening" } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 899b5dd6c..0568ec49e 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -18,7 +18,10 @@ "delete": "Excluir", "done": "Concluído", "retry": "Tentar novamente", - "apply": "Aplicar" + "apply": "Aplicar", + "minimize": "Minimizar", + "maximize": "Maximizar", + "restoreWindow": "Restaurar janela" }, "errors": { "invalidFileType": "Tipo de arquivo inválido", @@ -83,5 +86,6 @@ "downloadingButton": "Baixando · {{progress}}" }, "width": "Largura", - "height": "Altura" + "height": "Altura", + "windowControls": "Controles da janela" } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 3646f6f25..e4c3cdb61 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -16,7 +16,10 @@ "save": "Сохранить", "undo": "Отменить", "delete": "Удалить", - "done": "Готово" + "done": "Готово", + "minimize": "Свернуть", + "maximize": "Развернуть", + "restoreWindow": "Восстановить окно" }, "errors": { "invalidFileType": "Неверный формат файла", @@ -58,5 +61,6 @@ "loving": "В восторге", "neutral": "Нейтрально", "angry": "Раздражение" - } + }, + "windowControls": "Управление окном" } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 4ce861827..6f7ea156c 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -18,7 +18,10 @@ "delete": "删除", "done": "完成", "retry": "重试", - "apply": "应用" + "apply": "应用", + "minimize": "最小化", + "maximize": "最大化", + "restoreWindow": "还原窗口" }, "errors": { "invalidFileType": "无效的文件类型", @@ -83,5 +86,6 @@ "downloadingButton": "正在下载 · {{progress}}" }, "width": "宽度", - "height": "高度" + "height": "高度", + "windowControls": "窗口控件" } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 8e3308c1f..32b05906d 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -18,7 +18,10 @@ "delete": "刪除", "done": "完成", "retry": "重試", - "apply": "套用" + "apply": "套用", + "minimize": "最小化", + "maximize": "最大化", + "restoreWindow": "還原視窗" }, "errors": { "invalidFileType": "無效的檔案類型", @@ -83,5 +86,6 @@ "downloadingButton": "正在下載 · {{progress}}" }, "width": "寬度", - "height": "高度" + "height": "高度", + "windowControls": "視窗控制項" }