From 6f2c092135ba537d21ce6ac3c9a94bc26bd2091a Mon Sep 17 00:00:00 2001 From: adityarao3 Date: Fri, 4 Sep 2026 10:28:17 +0530 Subject: [PATCH 1/3] fix(macos): stop recording overlays stealing focus from the capture target Selecting a window for capture and starting a recording pulled focus back to Recordly, leaving the target window's controls unresponsive (#846, reported on v1.3.3 / ARM64 macOS). Two always-on-top windows appear as recording starts, and both were shown with show(), which activates the owning application: - the HUD overlay in createHudOverlayWindow() - the countdown window in createCountdownWindow() Both already had a `process.platform === "win32"` branch calling showInactive() instead, added in 92ee514f for the countdown and carrying the comment "must not steal focus when Recordly starts" for the HUD. The non-Windows path was never given the same treatment, so on macOS activating either window moved focus off the capture target right as recording began, which matches the reported behaviour: focus "stuck" on Recordly and buttons in the target window not responding. Neither window needs activation to be visible: both are alwaysOnTop, and the HUD is already presented without focus on Windows. Use showInactive() on every platform and keep the existing moveTop() for z-order. The tray "Show Controls" path in main.ts is deliberately left alone: that is an explicit user request to bring the HUD forward, so focusing there is correct. Adds a regression test covering both windows. It forces process.platform to "darwin", since Windows already took the showInactive() path and the test would otherwise pass against the unfixed code. --- electron/hudOverlayFocus.test.ts | 134 +++++++++++++++++++++++++++++++ electron/windows.ts | 23 +++--- 2 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 electron/hudOverlayFocus.test.ts diff --git a/electron/hudOverlayFocus.test.ts b/electron/hudOverlayFocus.test.ts new file mode 100644 index 000000000..326e534d9 --- /dev/null +++ b/electron/hudOverlayFocus.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const WORK_AREA = { x: 0, y: 0, width: 1920, height: 1080 }; + +const show = vi.fn(); +const showInactive = vi.fn(); +const loadFinishedHandlers: Array<() => void> = []; + +const electronScreen = { + getPrimaryDisplay: () => ({ + workArea: WORK_AREA, + workAreaSize: { width: WORK_AREA.width, height: WORK_AREA.height }, + size: { width: WORK_AREA.width, height: WORK_AREA.height }, + }), + getDisplayMatching: () => ({ + workArea: WORK_AREA, + workAreaSize: { width: WORK_AREA.width, height: WORK_AREA.height }, + size: { width: WORK_AREA.width, height: WORK_AREA.height }, + }), + getCursorScreenPoint: () => ({ x: 0, y: 0 }), + on: vi.fn(), + off: vi.fn(), + removeListener: vi.fn(), +}; + +// windows.ts resolves the screen module through createRequire("electron"), +// which bypasses vi.mock("electron"), so the CJS require is mocked too. +vi.mock("node:module", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: actual, + createRequire: () => (id: string) => { + if (id === "electron") { + return { screen: electronScreen }; + } + return actual.createRequire(import.meta.url)(id); + }, + }; +}); + +vi.mock("electron", () => { + class FakeBrowserWindow { + webContents = { + send: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: (event: string, handler: () => void) => { + if (event === "did-finish-load") { + loadFinishedHandlers.push(handler); + } + }, + }; + isDestroyed = () => false; + isVisible = () => true; + isMinimized = () => false; + getBounds = () => WORK_AREA; + setBounds = vi.fn(); + setIgnoreMouseEvents = vi.fn(); + setContentProtection = vi.fn(); + setAlwaysOnTop = vi.fn(); + setVisibleOnAllWorkspaces = vi.fn(); + showInactive = showInactive; + show = show; + moveTop = vi.fn(); + once = vi.fn(); + on = vi.fn(); + loadURL = vi.fn(); + loadFile = vi.fn(); + } + + return { + app: { + getPath: () => "/tmp/recordly-test", + isPackaged: false, + isReady: () => true, + whenReady: vi.fn(), + on: vi.fn(), + }, + BrowserWindow: FakeBrowserWindow, + ipcMain: { on: vi.fn(), handle: vi.fn() }, + screen: electronScreen, + }; +}); + +// Regression guard for #846. The HUD and the countdown are always-on-top +// windows that appear as recording starts. show() activates the owning app, so +// on macOS it pulled focus off the window the user picked for window capture, +// leaving the target unclickable. Both must be presented without activating. +describe("recording overlays do not steal focus", () => { + const realPlatform = process.platform; + + beforeEach(() => { + vi.resetModules(); + show.mockClear(); + showInactive.mockClear(); + loadFinishedHandlers.length = 0; + // The regression was macOS-only: Windows already took the showInactive() + // path, so the platform has to be forced for this to be a real guard. + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { + value: realPlatform, + configurable: true, + }); + }); + + it("shows the HUD overlay without activating it", async () => { + const windows = await import("./windows"); + windows.createHudOverlayWindow(); + + for (const handler of loadFinishedHandlers) { + handler(); + } + // The HUD defers its first show until the renderer signals readiness. + await vi.waitFor(() => expect(showInactive).toHaveBeenCalled(), { timeout: 3000 }); + + expect(show).not.toHaveBeenCalled(); + }); + + it("shows the countdown window without activating it", async () => { + const windows = await import("./windows"); + windows.createCountdownWindow(); + + expect(loadFinishedHandlers.length).toBeGreaterThan(0); + for (const handler of loadFinishedHandlers) { + handler(); + } + + expect(showInactive).toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/windows.ts b/electron/windows.ts index 47e90b8fc..46818c66f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -501,13 +501,12 @@ export function createHudOverlayWindow(): BrowserWindow { // Showing or changing native window state can recreate platform window // flags. Reassert capture protection on both sides of the transition. applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); - if (process.platform === "win32") { - // A focusable window is required for a Windows taskbar entry, but the - // always-on-top HUD must not steal focus when Recordly starts. - win.showInactive(); - } else { - win.show(); - } + // A focusable window is required for a Windows taskbar entry, but the + // always-on-top HUD must not steal focus when Recordly starts. show() + // activates the app, which on macOS pulls focus away from the window the + // user selected for capture, so present the HUD without activating it on + // every platform. + win.showInactive(); win.moveTop(); applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { @@ -1063,12 +1062,10 @@ export function createCountdownWindow(): BrowserWindow { win.webContents.on("did-finish-load", () => { if (!win.isDestroyed()) { - if (process.platform === "win32") { - win.showInactive(); - win.moveTop(); - } else { - win.show(); - } + // The countdown sits above the capture target, so activating it would + // pull focus off that window right as recording starts. + win.showInactive(); + win.moveTop(); } }); From 9600624c1041490ce1a5f998b530656748622c87 Mon Sep 17 00:00:00 2001 From: adityarao3 Date: Fri, 4 Sep 2026 22:15:40 +0530 Subject: [PATCH 2/3] fix(macos): leave the Linux show() path untouched Review feedback: showInactive() and moveTop() are documented as unsupported on Wayland, so routing Linux through them would have replaced a working show() with calls that silently do nothing there. The countdown was the real regression risk: on Linux it previously called show() with no moveTop() at all, and the first version of this change added moveTop() to that path. The HUD already called moveTop() unconditionally before this PR, so that part was unchanged either way. Scope the switch to darwin and win32 and keep Linux on show(), so this PR only changes the platform #846 is about. Adds a test asserting the Linux path still uses show() and never showInactive(). --- electron/hudOverlayFocus.test.ts | 17 +++++++++++++++++ electron/windows.ts | 23 +++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/electron/hudOverlayFocus.test.ts b/electron/hudOverlayFocus.test.ts index 326e534d9..7a43d8795 100644 --- a/electron/hudOverlayFocus.test.ts +++ b/electron/hudOverlayFocus.test.ts @@ -119,6 +119,23 @@ describe("recording overlays do not steal focus", () => { expect(show).not.toHaveBeenCalled(); }); + it("leaves the Linux show() path alone", async () => { + // showInactive() and moveTop() are unsupported on Wayland, so Linux keeps + // the pre-existing show() behaviour rather than gaining calls that would + // silently do nothing there. + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + + const windows = await import("./windows"); + windows.createCountdownWindow(); + + for (const handler of loadFinishedHandlers) { + handler(); + } + + expect(show).toHaveBeenCalled(); + expect(showInactive).not.toHaveBeenCalled(); + }); + it("shows the countdown window without activating it", async () => { const windows = await import("./windows"); windows.createCountdownWindow(); diff --git a/electron/windows.ts b/electron/windows.ts index 46818c66f..894201260 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -504,9 +504,14 @@ export function createHudOverlayWindow(): BrowserWindow { // A focusable window is required for a Windows taskbar entry, but the // always-on-top HUD must not steal focus when Recordly starts. show() // activates the app, which on macOS pulls focus away from the window the - // user selected for capture, so present the HUD without activating it on - // every platform. - win.showInactive(); + // user selected for capture, so present the HUD without activating it + // there too. Linux keeps show(): showInactive() is a no-op under Wayland, + // and the X11 overlay relies on the activation to appear reliably. + if (process.platform === "linux") { + win.show(); + } else { + win.showInactive(); + } win.moveTop(); applyHudOverlayCaptureProtectionToWindow(win, hudOverlayHiddenFromCapture); if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { @@ -1063,9 +1068,15 @@ export function createCountdownWindow(): BrowserWindow { win.webContents.on("did-finish-load", () => { if (!win.isDestroyed()) { // The countdown sits above the capture target, so activating it would - // pull focus off that window right as recording starts. - win.showInactive(); - win.moveTop(); + // pull focus off that window right as recording starts. Linux keeps + // the previous show() path: showInactive()/moveTop() are unsupported + // under Wayland, so switching would change nothing there. + if (process.platform === "linux") { + win.show(); + } else { + win.showInactive(); + win.moveTop(); + } } }); From 1ecba3f39f79d0f35f5baeeed4231606dab29323 Mon Sep 17 00:00:00 2001 From: adityarao3 Date: Fri, 4 Sep 2026 22:31:23 +0530 Subject: [PATCH 3/3] test: cover the Linux HUD show() path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the Linux guard only exercised createCountdownWindow, so a regression that put the Linux HUD back on showInactive() would still have passed. Confirmed the gap by restoring showInactive() in the Linux HUD branch — all three tests passed. Split the guard into one case per window; the new HUD case fails on that same regression. --- electron/hudOverlayFocus.test.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/electron/hudOverlayFocus.test.ts b/electron/hudOverlayFocus.test.ts index 7a43d8795..3f12fd1de 100644 --- a/electron/hudOverlayFocus.test.ts +++ b/electron/hudOverlayFocus.test.ts @@ -119,10 +119,11 @@ describe("recording overlays do not steal focus", () => { expect(show).not.toHaveBeenCalled(); }); - it("leaves the Linux show() path alone", async () => { - // showInactive() and moveTop() are unsupported on Wayland, so Linux keeps - // the pre-existing show() behaviour rather than gaining calls that would - // silently do nothing there. + // showInactive() and moveTop() are unsupported on Wayland, so Linux keeps the + // pre-existing show() behaviour rather than gaining calls that would silently + // do nothing there. Both windows are covered: a regression in either one + // alone would otherwise go unnoticed. + it("leaves the Linux countdown show() path alone", async () => { Object.defineProperty(process, "platform", { value: "linux", configurable: true }); const windows = await import("./windows"); @@ -136,6 +137,21 @@ describe("recording overlays do not steal focus", () => { expect(showInactive).not.toHaveBeenCalled(); }); + it("leaves the Linux HUD overlay show() path alone", async () => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + + const windows = await import("./windows"); + windows.createHudOverlayWindow(); + + for (const handler of loadFinishedHandlers) { + handler(); + } + // The HUD defers its first show until the renderer signals readiness. + await vi.waitFor(() => expect(show).toHaveBeenCalled(), { timeout: 3000 }); + + expect(showInactive).not.toHaveBeenCalled(); + }); + it("shows the countdown window without activating it", async () => { const windows = await import("./windows"); windows.createCountdownWindow();