Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions electron/desktopWallpaper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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(
Expand Down
34 changes: 21 additions & 13 deletions electron/desktopWallpaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand Down Expand Up @@ -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");
}
Expand Down
21 changes: 18 additions & 3 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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<string>;
isWindowFullscreen: () => Promise<boolean>;
Expand Down
65 changes: 65 additions & 0 deletions electron/hudPointerRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
20 changes: 20 additions & 0 deletions electron/hudPointerRecovery.ts
Original file line number Diff line number Diff line change
@@ -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);
}
40 changes: 38 additions & 2 deletions electron/ipc/capturePickerSources.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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");
Expand All @@ -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 });
Expand All @@ -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([
Expand Down
32 changes: 22 additions & 10 deletions electron/ipc/capturePickerSources.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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<Entry>(); var seen = new HashSet<long>();
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();
}
Expand Down Expand Up @@ -76,17 +79,26 @@ export async function listCapturePickerWindows(): Promise<CapturePickerWindow[]>
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
Expand Down
33 changes: 33 additions & 0 deletions electron/ipc/recording/focus.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading