From 16111d57c550532ee7c56de4422a10454dac35c3 Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:16:42 +0900 Subject: [PATCH 1/9] fix: prevent Windows HUD drag drift --- electron/electron-env.d.ts | 4 +- electron/hudOverlayDrag.test.ts | 46 ++++++++++++ electron/hudOverlayDrag.ts | 49 ++++++++++++ electron/preload.ts | 10 ++- electron/windows.ts | 52 ++++++++++--- src/components/launch/LaunchWindow.test.tsx | 62 +++++++++++++++- src/components/launch/LaunchWindow.tsx | 82 +++++++++++---------- 7 files changed, 252 insertions(+), 53 deletions(-) create mode 100644 electron/hudOverlayDrag.test.ts create mode 100644 electron/hudOverlayDrag.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f954fc99e..a8afcfa5d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -300,7 +300,9 @@ interface Window { hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; - moveHudOverlayBy: (deltaX: number, deltaY: number) => void; + startHudOverlayDrag: () => void; + moveHudOverlayDrag: () => void; + endHudOverlayDrag: () => void; setHudOverlaySize: (width: number, height: number) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; diff --git a/electron/hudOverlayDrag.test.ts b/electron/hudOverlayDrag.test.ts new file mode 100644 index 000000000..84221877c --- /dev/null +++ b/electron/hudOverlayDrag.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { clampHudOverlayPosition, resolveHudOverlayDragPosition } from "./hudOverlayDrag"; + +const workArea = { x: 0, y: 0, width: 1920, height: 1080 }; + +describe("resolveHudOverlayDragPosition", () => { + it("uses the cursor displacement from the fixed drag origin", () => { + expect( + resolveHudOverlayDragPosition( + { x: 600, y: 900, width: 600, height: 92 }, + { x: 900, y: 946 }, + { x: 1000, y: 946 }, + ), + ).toEqual({ x: 700, y: 900 }); + }); + + it("does not accumulate vertical drift when the cursor stays still", () => { + const startBounds = { x: 600, y: 900, width: 600, height: 92 }; + const startCursor = { x: 900, y: 946 }; + const currentCursor = { x: 900, y: 946 }; + + const first = resolveHudOverlayDragPosition(startBounds, startCursor, currentCursor); + const repeated = resolveHudOverlayDragPosition(startBounds, startCursor, currentCursor); + + expect(first).toEqual({ x: 600, y: 900 }); + expect(repeated).toEqual(first); + }); + + it("keeps the complete HUD inside the active display work area", () => { + expect( + clampHudOverlayPosition({ x: 2200, y: 1354 }, { width: 600, height: 92 }, workArea), + ).toEqual({ x: 1320, y: 988 }); + }); + + it("supports monitors positioned left of the primary display", () => { + const leftMonitorWorkArea = { x: -1920, y: -120, width: 1920, height: 1080 }; + + expect( + clampHudOverlayPosition( + { x: -2100, y: -200 }, + { width: 600, height: 92 }, + leftMonitorWorkArea, + ), + ).toEqual({ x: -1920, y: -120 }); + }); +}); diff --git a/electron/hudOverlayDrag.ts b/electron/hudOverlayDrag.ts new file mode 100644 index 000000000..f19bf759f --- /dev/null +++ b/electron/hudOverlayDrag.ts @@ -0,0 +1,49 @@ +export interface HudOverlayPoint { + x: number; + y: number; +} + +export interface HudOverlayBounds extends HudOverlayPoint { + width: number; + height: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Resolves a HUD position from one absolute drag origin. + * + * Electron's screen cursor and BrowserWindow bounds are both expressed in DIP. + * Keeping the calculation in that single coordinate space avoids the feedback + * drift caused by repeatedly adding renderer PointerEvent.screenX/screenY deltas. + */ +export function resolveHudOverlayDragPosition( + startBounds: HudOverlayBounds, + startCursor: HudOverlayPoint, + currentCursor: HudOverlayPoint, +): HudOverlayPoint { + const desiredX = startBounds.x + currentCursor.x - startCursor.x; + const desiredY = startBounds.y + currentCursor.y - startCursor.y; + + return { + x: Math.round(desiredX), + y: Math.round(desiredY), + }; +} + +/** Keeps the complete HUD inside a display's taskbar-aware work area after release. */ +export function clampHudOverlayPosition( + position: HudOverlayPoint, + hudSize: Pick, + workArea: HudOverlayBounds, +): HudOverlayPoint { + const maxX = workArea.x + Math.max(0, workArea.width - hudSize.width); + const maxY = workArea.y + Math.max(0, workArea.height - hudSize.height); + + return { + x: Math.round(clamp(position.x, workArea.x, maxX)), + y: Math.round(clamp(position.y, workArea.y, maxY)), + }; +} diff --git a/electron/preload.ts b/electron/preload.ts index f02a2ccd2..5ffba237f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -26,8 +26,14 @@ contextBridge.exposeInMainWorld("electronAPI", { setHudOverlayIgnoreMouseEvents: (ignore: boolean) => { ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore); }, - moveHudOverlayBy: (deltaX: number, deltaY: number) => { - ipcRenderer.send("hud-overlay-move-by", deltaX, deltaY); + startHudOverlayDrag: () => { + ipcRenderer.send("hud-overlay-drag-start"); + }, + moveHudOverlayDrag: () => { + ipcRenderer.send("hud-overlay-drag-move"); + }, + endHudOverlayDrag: () => { + ipcRenderer.send("hud-overlay-drag-end"); }, setHudOverlaySize: (width: number, height: number) => { ipcRenderer.send("hud-overlay-set-size", width, height); diff --git a/electron/windows.ts b/electron/windows.ts index 60dbc9df4..5786d241b 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; +import { clampHudOverlayPosition, resolveHudOverlayDragPosition } from "./hudOverlayDrag"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -17,9 +18,33 @@ const ASSET_BASE_DIR = process.defaultApp const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`; let hudOverlayWindow: BrowserWindow | null = null; +let hudOverlayDragState: { + startBounds: Electron.Rectangle; + startCursor: Electron.Point; +} | null = null; + +function moveHudOverlayToCurrentCursor(clampToDisplay: boolean) { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed() || !hudOverlayDragState) { + return; + } + + const currentCursor = screen.getCursorScreenPoint(); + let position = resolveHudOverlayDragPosition( + hudOverlayDragState.startBounds, + hudOverlayDragState.startCursor, + currentCursor, + ); + if (clampToDisplay) { + const { workArea } = screen.getDisplayNearestPoint(currentCursor); + position = clampHudOverlayPosition(position, hudOverlayDragState.startBounds, workArea); + } + + hudOverlayWindow.setPosition(position.x, position.y, false); +} ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayDragState = null; hudOverlayWindow.minimize(); } }); @@ -30,18 +55,26 @@ ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { } }); -ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => { - if ( - !hudOverlayWindow || - hudOverlayWindow.isDestroyed() || - !Number.isFinite(deltaX) || - !Number.isFinite(deltaY) - ) { +ipcMain.on("hud-overlay-drag-start", () => { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { return; } - const [x, y] = hudOverlayWindow.getPosition(); - hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false); + hudOverlayDragState = { + startBounds: hudOverlayWindow.getBounds(), + startCursor: screen.getCursorScreenPoint(), + }; +}); + +ipcMain.on("hud-overlay-drag-move", () => { + // Do not clamp mid-drag: that would make the HUD jump at mixed-DPI monitor boundaries. + moveHudOverlayToCurrentCursor(false); +}); + +ipcMain.on("hud-overlay-drag-end", () => { + // Capture the release point and keep the HUD inside the target monitor's work area. + moveHudOverlayToCurrentCursor(true); + hudOverlayDragState = null; }); // Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it @@ -147,6 +180,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.on("closed", () => { if (hudOverlayWindow === win) { hudOverlayWindow = null; + hudOverlayDragState = null; } }); diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 154feef73..d173f795f 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -62,6 +62,7 @@ const recorderState = vi.hoisted(() => ({ let selectedSourceChangedListeners: SelectedSourceChangedListener[] = []; let sourceSelectorClosedListeners: Array<() => void> = []; +let dragFrameCallbacks: FrameRequestCallback[] = []; vi.mock("../../hooks/useScreenRecorder", () => ({ useScreenRecorder: () => recorderState.value, @@ -178,7 +179,9 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo getPlatform: vi.fn(async () => "darwin"), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), - moveHudOverlayBy: vi.fn(), + startHudOverlayDrag: vi.fn(), + moveHudOverlayDrag: vi.fn(), + endHudOverlayDrag: vi.fn(), hudOverlayHide: vi.fn(), hudOverlayClose: vi.fn(), switchToEditor: vi.fn(async () => undefined), @@ -378,6 +381,63 @@ describe("LaunchWindow record button", () => { }); }); +describe("LaunchWindow HUD dragging", () => { + beforeEach(() => { + platformState.value = "win32"; + resetLaunchMocks(); + dragFrameCallbacks = []; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + dragFrameCallbacks.push(callback); + return dragFrameCallbacks.length; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("delegates drag coordinates to Electron instead of accumulating renderer deltas", () => { + renderLaunchWindow(); + + const handle = screen.getByTestId("launch-drag-handle"); + Object.assign(handle, { + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => true), + releasePointerCapture: vi.fn(), + }); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 14, clientY: 16 }); + fireEvent.pointerMove(handle, { pointerId: 1, screenX: 1000, screenY: 1000 }); + fireEvent.pointerMove(handle, { pointerId: 1, screenX: 1000, screenY: 1400 }); + act(() => dragFrameCallbacks.shift()?.(0)); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 14, clientY: 16 }); + + expect(window.electronAPI.startHudOverlayDrag).toHaveBeenCalledTimes(1); + expect(window.electronAPI.moveHudOverlayDrag).toHaveBeenCalledTimes(1); + expect(window.electronAPI.moveHudOverlayDrag).toHaveBeenCalledWith(); + expect(window.electronAPI.endHudOverlayDrag).toHaveBeenCalledTimes(1); + }); + + it("does not make the HUD click-through while a drag is active", () => { + const { container } = renderLaunchWindow(); + + const handle = screen.getByTestId("launch-drag-handle"); + Object.assign(handle, { + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn(() => false), + }); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 1 }); + vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents).mockClear(); + fireEvent.pointerLeave(container.firstElementChild as Element); + + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).not.toHaveBeenCalledWith(true); + }); +}); + describe("LaunchWindow system language prompt", () => { beforeEach(() => { platformState.value = "darwin"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 1136d8537..5427c4407 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -588,68 +588,65 @@ export function LaunchWindow() { setMicrophoneEnabled(!microphoneEnabled); } }; - const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); + const hudDraggingRef = useRef(false); const dragAnimationFrameRef = useRef(null); - const pendingDragDeltaRef = useRef({ x: 0, y: 0 }); const flushHudDragMove = useCallback(() => { dragAnimationFrameRef.current = null; - const { x, y } = pendingDragDeltaRef.current; - pendingDragDeltaRef.current = { x: 0, y: 0 }; - if (x === 0 && y === 0) return; - window.electronAPI?.moveHudOverlayBy?.(x, y); + if (!hudDraggingRef.current) return; + window.electronAPI?.moveHudOverlayDrag?.(); }, []); - const scheduleHudDragMove = useCallback( - (deltaX: number, deltaY: number) => { - pendingDragDeltaRef.current = { - x: pendingDragDeltaRef.current.x + deltaX, - y: pendingDragDeltaRef.current.y + deltaY, - }; - - if (dragAnimationFrameRef.current === null) { - dragAnimationFrameRef.current = window.requestAnimationFrame(flushHudDragMove); - } - }, - [flushHudDragMove], - ); - const flushPendingHudDragMove = useCallback(() => { + const scheduleHudDragMove = useCallback(() => { + if (dragAnimationFrameRef.current === null) { + dragAnimationFrameRef.current = window.requestAnimationFrame(flushHudDragMove); + } + }, [flushHudDragMove]); + const cancelPendingHudDragMove = useCallback(() => { if (dragAnimationFrameRef.current !== null) { window.cancelAnimationFrame(dragAnimationFrameRef.current); dragAnimationFrameRef.current = null; } - const { x, y } = pendingDragDeltaRef.current; - pendingDragDeltaRef.current = { x: 0, y: 0 }; - if (x === 0 && y === 0) return; - window.electronAPI?.moveHudOverlayBy?.(x, y); }, []); useEffect(() => { return () => { - if (dragAnimationFrameRef.current !== null) { - window.cancelAnimationFrame(dragAnimationFrameRef.current); + cancelPendingHudDragMove(); + if (hudDraggingRef.current) { + window.electronAPI?.endHudOverlayDrag?.(); + hudDraggingRef.current = false; } }; - }, []); + }, [cancelPendingHudDragMove]); const handleHudDragPointerDown = (event: React.PointerEvent) => { + if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); + hudDraggingRef.current = true; setHudMouseEventsEnabled(true); event.currentTarget.setPointerCapture(event.pointerId); - dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; + window.electronAPI?.startHudOverlayDrag?.(); }; - const handleHudDragPointerMove = (event: React.PointerEvent) => { - const lastPosition = dragLastPositionRef.current; - if (!lastPosition) return; - const deltaX = event.screenX - lastPosition.x; - const deltaY = event.screenY - lastPosition.y; - dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; - scheduleHudDragMove(deltaX, deltaY); + const handleHudDragPointerMove = () => { + if (!hudDraggingRef.current) return; + setHudMouseEventsEnabled(true); + scheduleHudDragMove(); }; const handleHudDragPointerEnd = (event: React.PointerEvent) => { - dragLastPositionRef.current = null; - flushPendingHudDragMove(); + if (!hudDraggingRef.current) return; + cancelPendingHudDragMove(); + window.electronAPI?.endHudOverlayDrag?.(); + hudDraggingRef.current = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } - setHudMouseEventsEnabled(false); + + const hudBounds = hudBarRef.current?.getBoundingClientRect(); + const isPointerOverHud = Boolean( + hudBounds && + event.clientX >= hudBounds.left && + event.clientX <= hudBounds.right && + event.clientY >= hudBounds.top && + event.clientY <= hudBounds.bottom, + ); + setHudMouseEventsEnabled(isPointerOverHud || isLanguageMenuOpen); }; return ( @@ -658,13 +655,17 @@ export function LaunchWindow() {
{ + if (hudDraggingRef.current) { + setHudMouseEventsEnabled(true); + return; + } const target = event.target as HTMLElement | null; const shouldCapture = isLanguageMenuOpen || Boolean(target?.closest("[data-hud-interactive='true']")); setHudMouseEventsEnabled(shouldCapture); }} onPointerLeave={() => { - if (!isLanguageMenuOpen) { + if (!isLanguageMenuOpen && !hudDraggingRef.current) { setHudMouseEventsEnabled(false); } }} @@ -909,13 +910,14 @@ export function LaunchWindow() { onPointerDown={() => setHudMouseEventsEnabled(true)} onMouseEnter={() => setHudMouseEventsEnabled(true)} onMouseLeave={() => { - if (!isLanguageMenuOpen) { + if (!isLanguageMenuOpen && !hudDraggingRef.current) { setHudMouseEventsEnabled(false); } }} > {/* Drag handle */}
Date: Fri, 17 Jul 2026 22:07:52 +0900 Subject: [PATCH 2/9] fix: make Windows capture and HUD drag DPI-safe --- electron/electron-env.d.ts | 3 - electron/hudOverlayDrag.test.ts | 46 ---------- electron/hudOverlayDrag.ts | 49 ---------- electron/ipc/handlers.ts | 16 +++- .../native-bridge/cursor/recording/factory.ts | 2 + .../windowsCursorCoordinates.test.ts | 41 +++++++++ .../recording/windowsCursorCoordinates.ts | 37 ++++++++ .../windowsNativeRecordingSession.ts | 37 +++++--- .../windowsNativeRecordingSession.types.ts | 1 + electron/native/wgc-capture/CMakeLists.txt | 5 + .../native/wgc-capture/src/cursor-sampler.cpp | 91 +++++++++++++++++-- .../native/wgc-capture/src/dpi_awareness.h | 20 ++++ electron/native/wgc-capture/src/main.cpp | 6 ++ electron/preload.ts | 9 -- electron/windows.ts | 48 ---------- src/components/launch/LaunchWindow.module.css | 1 + src/components/launch/LaunchWindow.test.tsx | 46 +--------- src/components/launch/LaunchWindow.tsx | 77 +--------------- 18 files changed, 240 insertions(+), 295 deletions(-) delete mode 100644 electron/hudOverlayDrag.test.ts delete mode 100644 electron/hudOverlayDrag.ts create mode 100644 electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts create mode 100644 electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts create mode 100644 electron/native/wgc-capture/src/dpi_awareness.h diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a8afcfa5d..91e7092a4 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -300,9 +300,6 @@ interface Window { hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; - startHudOverlayDrag: () => void; - moveHudOverlayDrag: () => void; - endHudOverlayDrag: () => void; setHudOverlaySize: (width: number, height: number) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; diff --git a/electron/hudOverlayDrag.test.ts b/electron/hudOverlayDrag.test.ts deleted file mode 100644 index 84221877c..000000000 --- a/electron/hudOverlayDrag.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { clampHudOverlayPosition, resolveHudOverlayDragPosition } from "./hudOverlayDrag"; - -const workArea = { x: 0, y: 0, width: 1920, height: 1080 }; - -describe("resolveHudOverlayDragPosition", () => { - it("uses the cursor displacement from the fixed drag origin", () => { - expect( - resolveHudOverlayDragPosition( - { x: 600, y: 900, width: 600, height: 92 }, - { x: 900, y: 946 }, - { x: 1000, y: 946 }, - ), - ).toEqual({ x: 700, y: 900 }); - }); - - it("does not accumulate vertical drift when the cursor stays still", () => { - const startBounds = { x: 600, y: 900, width: 600, height: 92 }; - const startCursor = { x: 900, y: 946 }; - const currentCursor = { x: 900, y: 946 }; - - const first = resolveHudOverlayDragPosition(startBounds, startCursor, currentCursor); - const repeated = resolveHudOverlayDragPosition(startBounds, startCursor, currentCursor); - - expect(first).toEqual({ x: 600, y: 900 }); - expect(repeated).toEqual(first); - }); - - it("keeps the complete HUD inside the active display work area", () => { - expect( - clampHudOverlayPosition({ x: 2200, y: 1354 }, { width: 600, height: 92 }, workArea), - ).toEqual({ x: 1320, y: 988 }); - }); - - it("supports monitors positioned left of the primary display", () => { - const leftMonitorWorkArea = { x: -1920, y: -120, width: 1920, height: 1080 }; - - expect( - clampHudOverlayPosition( - { x: -2100, y: -200 }, - { width: 600, height: 92 }, - leftMonitorWorkArea, - ), - ).toEqual({ x: -1920, y: -120 }); - }); -}); diff --git a/electron/hudOverlayDrag.ts b/electron/hudOverlayDrag.ts deleted file mode 100644 index f19bf759f..000000000 --- a/electron/hudOverlayDrag.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface HudOverlayPoint { - x: number; - y: number; -} - -export interface HudOverlayBounds extends HudOverlayPoint { - width: number; - height: number; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); -} - -/** - * Resolves a HUD position from one absolute drag origin. - * - * Electron's screen cursor and BrowserWindow bounds are both expressed in DIP. - * Keeping the calculation in that single coordinate space avoids the feedback - * drift caused by repeatedly adding renderer PointerEvent.screenX/screenY deltas. - */ -export function resolveHudOverlayDragPosition( - startBounds: HudOverlayBounds, - startCursor: HudOverlayPoint, - currentCursor: HudOverlayPoint, -): HudOverlayPoint { - const desiredX = startBounds.x + currentCursor.x - startCursor.x; - const desiredY = startBounds.y + currentCursor.y - startCursor.y; - - return { - x: Math.round(desiredX), - y: Math.round(desiredY), - }; -} - -/** Keeps the complete HUD inside a display's taskbar-aware work area after release. */ -export function clampHudOverlayPosition( - position: HudOverlayPoint, - hudSize: Pick, - workArea: HudOverlayBounds, -): HudOverlayPoint { - const maxX = workArea.x + Math.max(0, workArea.width - hudSize.width); - const maxY = workArea.y + Math.max(0, workArea.height - hudSize.height); - - return { - x: Math.round(clamp(position.x, workArea.x, maxX)), - y: Math.round(clamp(position.y, workArea.y, maxY)), - }; -} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 852250ec4..1ccb3a236 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -800,6 +800,7 @@ async function startCursorRecording(recordingId?: number) { pendingCursorRecordingData = null; cursorRecordingSession = createCursorRecordingSession({ + displayId: getSelectedDisplay()?.id ?? null, getDisplayBounds: getSelectedSourceBounds, maxSamples: MAX_CURSOR_SAMPLES, platform: process.platform, @@ -1631,6 +1632,12 @@ export function registerIpcHandlers( null) : getSelectedDisplay(); const bounds = sourceDisplay?.bounds ?? getSelectedSourceBounds(); + // Electron display bounds are DIPs. The native WGC helper is + // Per-Monitor-V2 aware and enumerates physical monitor rectangles, so + // give both native helpers the same physical selection hint. Each then + // resolves the exact Win32 monitor rectangle independently. + const physicalDisplayBounds = + request.source.type === "display" ? screen.dipToScreenRect(null, bounds) : bounds; const displayId = typeof request.source.displayId === "number" && Number.isFinite(request.source.displayId) ? request.source.displayId @@ -1659,10 +1666,10 @@ export function registerIpcHandlers( fps: request.video.fps, videoWidth: request.video.width, videoHeight: request.video.height, - displayX: bounds.x, - displayY: bounds.y, - displayW: bounds.width, - displayH: bounds.height, + displayX: physicalDisplayBounds.x, + displayY: physicalDisplayBounds.y, + displayW: physicalDisplayBounds.width, + displayH: physicalDisplayBounds.height, hasDisplayBounds: true, captureSystemAudio: request.audio.system.enabled, captureMic: request.audio.microphone.enabled, @@ -1705,6 +1712,7 @@ export function registerIpcHandlers( encoder: { preferSoftwareEncoder }, cursor: { mode: cursorCaptureMode }, bounds, + physicalDisplayBounds, sourceId: selectedSource?.id ?? null, usedDisplayMatch: Boolean(sourceDisplay), outputPath, diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts index 9a82ffd55..e94d24494 100644 --- a/electron/native-bridge/cursor/recording/factory.ts +++ b/electron/native-bridge/cursor/recording/factory.ts @@ -5,6 +5,7 @@ import { TelemetryRecordingSession } from "./telemetryRecordingSession"; import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession"; interface CreateCursorRecordingSessionOptions { + displayId?: number | null; getDisplayBounds: () => Rectangle | null; maxSamples: number; platform: NodeJS.Platform; @@ -18,6 +19,7 @@ export function createCursorRecordingSession( ): CursorRecordingSession { if (options.platform === "win32") { return new WindowsNativeRecordingSession({ + displayId: options.displayId, getDisplayBounds: options.getDisplayBounds, maxSamples: options.maxSamples, sampleIntervalMs: options.sampleIntervalMs, diff --git a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts new file mode 100644 index 000000000..3a1e599e3 --- /dev/null +++ b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { normalizePhysicalPoint } from "./windowsCursorCoordinates"; + +describe("normalizePhysicalPoint", () => { + it.each([ + 1, 1.25, 1.5, 1.75, 2, + ])("normalizes the same position at a %sx Windows scale", (scaleFactor) => { + const dipBounds = { x: -1536, y: 864, width: 1536, height: 864 }; + const physicalBounds = { + x: dipBounds.x * scaleFactor, + y: dipBounds.y * scaleFactor, + width: dipBounds.width * scaleFactor, + height: dipBounds.height * scaleFactor, + }; + const point = { + x: physicalBounds.x + physicalBounds.width * 0.375, + y: physicalBounds.y + physicalBounds.height * 0.625, + }; + + expect(normalizePhysicalPoint(point, physicalBounds)).toEqual({ + x: 0.375, + y: 0.625, + withinBounds: true, + }); + }); + + it("supports rotated monitors with a negative virtual-screen origin", () => { + expect( + normalizePhysicalPoint( + { x: 4380, y: -1057 }, + { x: 3840, y: -2017, width: 2160, height: 3840 }, + ), + ).toEqual({ x: 0.25, y: 0.25, withinBounds: true }); + }); + + it("marks points on another monitor as outside the capture", () => { + expect( + normalizePhysicalPoint({ x: -1, y: 400 }, { x: 0, y: 0, width: 3840, height: 2160 }), + ).toMatchObject({ withinBounds: false }); + }); +}); diff --git a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts new file mode 100644 index 000000000..5f77ff204 --- /dev/null +++ b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts @@ -0,0 +1,37 @@ +export interface PhysicalPoint { + x: number; + y: number; +} + +export interface PhysicalBounds extends PhysicalPoint { + width: number; + height: number; +} + +export interface NormalizedPhysicalPoint { + x: number; + y: number; + withinBounds: boolean; +} + +/** + * Normalizes one physical screen-pixel point against a physical capture rect. + * + * Keeping both values in the same native coordinate space makes the result + * independent of Windows' configured scale and of the virtual-screen origin. + */ +export function normalizePhysicalPoint( + point: PhysicalPoint, + bounds: PhysicalBounds, +): NormalizedPhysicalPoint { + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + const x = (point.x - bounds.x) / width; + const y = (point.y - bounds.y) / height; + + return { + x, + y, + withinBounds: x >= 0 && x <= 1 && y >= 0 && y <= 1, + }; +} diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index c7a057fea..73de3398d 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -10,6 +10,7 @@ import type { NativeCursorAsset, } from "../../../../src/native/contracts"; import type { CursorRecordingSession } from "./session"; +import { normalizePhysicalPoint } from "./windowsCursorCoordinates"; import type { WindowsCursorEvent, WindowsNativeRecordingSessionOptions, @@ -78,7 +79,16 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId); const args = [String(this.options.sampleIntervalMs)]; - if (windowHandle) args.push(windowHandle); + const dipBounds = this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds; + const physicalDisplayBounds = screen.dipToScreenRect(null, dipBounds); + args.push( + windowHandle ?? "null", + String(this.options.displayId ?? 0), + String(physicalDisplayBounds.x), + String(physicalDisplayBounds.y), + String(physicalDisplayBounds.width), + String(physicalDisplayBounds.height), + ); const child = spawn(helperPath, args, { stdio: ["ignore", "pipe", "pipe"], @@ -186,7 +196,10 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { } if (payload.asset?.id && !this.assets.has(payload.asset.id)) { - const assetDisplay = screen.getDisplayNearestPoint({ x: payload.x, y: payload.y }); + // The helper reports physical pixels. Electron's display lookup expects + // DIPs, so convert the point before resolving the per-monitor scale. + const assetPoint = screen.screenToDipPoint({ x: payload.x, y: payload.y }); + const assetDisplay = screen.getDisplayNearestPoint(assetPoint); this.assets.set(payload.asset.id, { id: payload.asset.id, platform: "win32", @@ -226,23 +239,19 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { ): NormalizedSample { const bounds = payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds; - // The cursor-sampler reports raw x/y in physical screen pixels (Win32 - // GetCursorInfo). `payload.bounds` from the sampler's GetWindowRect is also - // physical, so use it as-is. Bounds from Electron's `screen` API (or the - // fallback for display captures) are in DIPs — convert to physical screen + // The cursor-sampler reports x/y in physical screen pixels via Win32's + // GetPhysicalCursorPos. `payload.bounds` from the native sampler is in the + // same physical space, so use it as-is. Bounds from Electron's `screen` API + // (the fallback path only) are in DIPs, so convert them to physical screen // coordinates via `dipToScreenRect`, which correctly handles the virtual-screen // origin across multi-monitor and mixed-DPI setups (a naive // `bounds.x * scaleFactor` would misplace the origin on non-primary // displays). const physicalBounds = payload.bounds != null ? bounds : screen.dipToScreenRect(null, bounds); - const physicalX = physicalBounds.x; - const physicalY = physicalBounds.y; - const width = Math.max(1, physicalBounds.width); - const height = Math.max(1, physicalBounds.height); - const normalizedX = (payload.x - physicalX) / width; - const normalizedY = (payload.y - physicalY) / height; - const withinBounds = - normalizedX >= 0 && normalizedX <= 1 && normalizedY >= 0 && normalizedY <= 1; + const normalized = normalizePhysicalPoint(payload, physicalBounds); + const normalizedX = normalized.x; + const normalizedY = normalized.y; + const withinBounds = normalized.withinBounds; const leftButtonDown = payload.leftButtonDown === true; const leftButtonPressed = payload.leftButtonPressed === true; const leftButtonReleased = payload.leftButtonReleased === true; diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts index f3b69da0f..375b7f114 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts @@ -48,6 +48,7 @@ export type WindowsCursorEvent = | WindowsCursorErrorEvent; export interface WindowsNativeRecordingSessionOptions { + displayId?: number | null; getDisplayBounds: () => Rectangle | null; maxSamples: number; sampleIntervalMs: number; diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 32c5d6ef5..7cbfb3664 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -16,6 +16,7 @@ set(CMAKE_CXX_EXTENSIONS OFF) add_executable(wgc-capture src/audio_sample_utils.cpp src/audio_sample_utils.h + src/dpi_awareness.h src/dshow_webcam_capture.cpp src/dshow_webcam_capture.h src/main.cpp @@ -52,6 +53,9 @@ target_link_libraries(wgc-capture PRIVATE add_executable(cursor-sampler src/cursor-sampler.cpp + src/dpi_awareness.h + src/monitor_utils.cpp + src/monitor_utils.h ) target_compile_definitions(cursor-sampler PRIVATE @@ -62,6 +66,7 @@ target_compile_definitions(cursor-sampler PRIVATE target_compile_options(cursor-sampler PRIVATE /EHsc /W4 /utf-8) target_link_libraries(cursor-sampler PRIVATE + dwmapi gdi32 gdiplus ) diff --git a/electron/native/wgc-capture/src/cursor-sampler.cpp b/electron/native/wgc-capture/src/cursor-sampler.cpp index 21558c79a..fe0312e16 100644 --- a/electron/native/wgc-capture/src/cursor-sampler.cpp +++ b/electron/native/wgc-capture/src/cursor-sampler.cpp @@ -1,4 +1,8 @@ +#include "dpi_awareness.h" +#include "monitor_utils.h" + #include +#include #include #include @@ -11,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -312,7 +317,28 @@ static std::string buildAssetJson( // ───────────────────────────────────────────────────────────────────────────── // Sampling loop (background thread) // ───────────────────────────────────────────────────────────────────────────── -static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngClsid) { +static bool getPhysicalWindowBounds(HWND window, RECT& bounds) { + if (SUCCEEDED(DwmGetWindowAttribute( + window, + DWMWA_EXTENDED_FRAME_BOUNDS, + &bounds, + sizeof(bounds))) && + bounds.right > bounds.left && bounds.bottom > bounds.top) { + return true; + } + + // PMv2 awareness keeps this fallback in physical pixels. DWM is preferred + // because GetWindowRect can include invisible resize borders. + return GetWindowRect(window, &bounds) && + bounds.right > bounds.left && bounds.bottom > bounds.top; +} + +static void runSamplingLoop( + int intervalMs, + HWND targetWindow, + const std::optional& targetDisplayBounds, + const CLSID& pngClsid) +{ HCURSOR lastCursor = nullptr; while (!g_stop.load(std::memory_order_relaxed)) { @@ -331,6 +357,17 @@ static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngC continue; } + POINT physicalCursor{}; + if (!GetPhysicalCursorPos(&physicalCursor)) { + char buf[176]; + std::snprintf(buf, sizeof(buf), + "{\"type\":\"error\",\"timestampMs\":%" PRId64 ",\"message\":\"GetPhysicalCursorPos failed\"}", + nowMs()); + writeJsonLine(buf); + std::this_thread::sleep_for(std::chrono::milliseconds(intervalMs)); + continue; + } + const bool visible = (ci.flags & CURSOR_SHOWING) != 0; const HCURSOR hc = ci.hCursor; @@ -364,7 +401,7 @@ static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngC std::string boundsJson = "null"; if (targetWindow && IsWindow(targetWindow)) { RECT r{}; - if (GetWindowRect(targetWindow, &r)) { + if (getPhysicalWindowBounds(targetWindow, r)) { const int bw = r.right - r.left; const int bh = r.bottom - r.top; if (bw > 0 && bh > 0) { @@ -375,6 +412,15 @@ static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngC boundsJson = buf; } } + } else if (targetDisplayBounds.has_value()) { + const RECT& r = *targetDisplayBounds; + const int bw = r.right - r.left; + const int bh = r.bottom - r.top; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}", + r.left, r.top, bw, bh); + boundsJson = buf; } // Emit sample JSON @@ -382,8 +428,8 @@ static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngC out.reserve(256); out += "{\"type\":\"sample\""; out += ",\"timestampMs\":"; out += std::to_string(nowMs()); - out += ",\"x\":"; out += std::to_string(ci.ptScreenPos.x); - out += ",\"y\":"; out += std::to_string(ci.ptScreenPos.y); + out += ",\"x\":"; out += std::to_string(physicalCursor.x); + out += ",\"y\":"; out += std::to_string(physicalCursor.y); out += ",\"visible\":"; out += visible ? "true" : "false"; out += ",\"handle\":"; out += hc ? ("\"" + handleStr + "\"") : "null"; out += ",\"cursorType\":"; out += cursorType ? ("\"" + std::string(cursorType) + "\"") : "null"; @@ -410,8 +456,13 @@ static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngC // main // ───────────────────────────────────────────────────────────────────────────── int main(int argc, char* argv[]) { + if (!enablePerMonitorV2DpiAwareness()) { + std::cerr << "Failed to enable Per-Monitor-V2 DPI awareness" << std::endl; + return 1; + } + if (argc < 2) { - std::cerr << "Usage: cursor-sampler [windowHandle]" << std::endl; + std::cerr << "Usage: cursor-sampler [windowHandle] [displayId displayX displayY displayW displayH]" << std::endl; return 1; } @@ -429,6 +480,29 @@ int main(int argc, char* argv[]) { } } + std::optional targetDisplayBounds; + if (!targetWindow && argc >= 8) { + try { + const int64_t displayId = std::stoll(argv[3]); + MonitorBounds bounds{}; + bounds.x = std::stoi(argv[4]); + bounds.y = std::stoi(argv[5]); + bounds.width = std::stoi(argv[6]); + bounds.height = std::stoi(argv[7]); + const HMONITOR monitor = findMonitorForCapture(displayId, &bounds); + MONITORINFO info{}; + info.cbSize = sizeof(info); + if (monitor && GetMonitorInfo(monitor, &info)) { + targetDisplayBounds = info.rcMonitor; + } + } catch (...) {} + } + + if (!targetWindow && !targetDisplayBounds.has_value()) { + std::cerr << "Could not resolve physical display bounds" << std::endl; + return 1; + } + // Initialize GDI+ Gdiplus::GdiplusStartupInput gdipInput{}; ULONG_PTR gdipToken = 0; @@ -465,7 +539,12 @@ int main(int argc, char* argv[]) { } // Start sampling on a background thread - std::thread sampler(runSamplingLoop, intervalMs, targetWindow, std::cref(pngClsid)); + std::thread sampler( + runSamplingLoop, + intervalMs, + targetWindow, + std::cref(targetDisplayBounds), + std::cref(pngClsid)); // Run the message pump on the main thread — required for WH_MOUSE_LL callbacks MSG msg; diff --git a/electron/native/wgc-capture/src/dpi_awareness.h b/electron/native/wgc-capture/src/dpi_awareness.h new file mode 100644 index 000000000..7f26a2321 --- /dev/null +++ b/electron/native/wgc-capture/src/dpi_awareness.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +// Native capture coordinates must never be DPI-virtualized. Call this before +// WinRT, GDI, monitor enumeration, or any window-coordinate API is initialized. +inline bool enablePerMonitorV2DpiAwareness() { + if (SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) { + return true; + } + + // ERROR_ACCESS_DENIED means the process default was already selected (for + // example by a manifest). Only continue if the active context is actually + // PMv2; accepting a different context would silently reintroduce DPI + // virtualization into the capture coordinates. + return GetLastError() == ERROR_ACCESS_DENIED && + AreDpiAwarenessContextsEqual( + GetThreadDpiAwarenessContext(), + DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); +} diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 30a3069d6..a439e6e69 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -1,4 +1,5 @@ #include "audio_sample_utils.h" +#include "dpi_awareness.h" #include "mf_encoder.h" #include "monitor_utils.h" #include "wasapi_loopback_capture.h" @@ -383,6 +384,11 @@ void readCaptureCommands(CaptureControl& control, const std::function { ipcRenderer.send("hud-overlay-ignore-mouse-events", ignore); }, - startHudOverlayDrag: () => { - ipcRenderer.send("hud-overlay-drag-start"); - }, - moveHudOverlayDrag: () => { - ipcRenderer.send("hud-overlay-drag-move"); - }, - endHudOverlayDrag: () => { - ipcRenderer.send("hud-overlay-drag-end"); - }, setHudOverlaySize: (width: number, height: number) => { ipcRenderer.send("hud-overlay-set-size", width, height); }, diff --git a/electron/windows.ts b/electron/windows.ts index 5786d241b..3c71fb3bf 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,7 +1,6 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; -import { clampHudOverlayPosition, resolveHudOverlayDragPosition } from "./hudOverlayDrag"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -18,33 +17,9 @@ const ASSET_BASE_DIR = process.defaultApp const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`; let hudOverlayWindow: BrowserWindow | null = null; -let hudOverlayDragState: { - startBounds: Electron.Rectangle; - startCursor: Electron.Point; -} | null = null; - -function moveHudOverlayToCurrentCursor(clampToDisplay: boolean) { - if (!hudOverlayWindow || hudOverlayWindow.isDestroyed() || !hudOverlayDragState) { - return; - } - - const currentCursor = screen.getCursorScreenPoint(); - let position = resolveHudOverlayDragPosition( - hudOverlayDragState.startBounds, - hudOverlayDragState.startCursor, - currentCursor, - ); - if (clampToDisplay) { - const { workArea } = screen.getDisplayNearestPoint(currentCursor); - position = clampHudOverlayPosition(position, hudOverlayDragState.startBounds, workArea); - } - - hudOverlayWindow.setPosition(position.x, position.y, false); -} ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayDragState = null; hudOverlayWindow.minimize(); } }); @@ -55,28 +30,6 @@ ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { } }); -ipcMain.on("hud-overlay-drag-start", () => { - if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { - return; - } - - hudOverlayDragState = { - startBounds: hudOverlayWindow.getBounds(), - startCursor: screen.getCursorScreenPoint(), - }; -}); - -ipcMain.on("hud-overlay-drag-move", () => { - // Do not clamp mid-drag: that would make the HUD jump at mixed-DPI monitor boundaries. - moveHudOverlayToCurrentCursor(false); -}); - -ipcMain.on("hud-overlay-drag-end", () => { - // Capture the release point and keep the HUD inside the target monitor's work area. - moveHudOverlayToCurrentCursor(true); - hudOverlayDragState = null; -}); - // Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it // stays where the user dragged it while only growing/shrinking, which lets the // vertical tray layout grow tall instead of scrolling inside a fixed window. @@ -180,7 +133,6 @@ export function createHudOverlayWindow(): BrowserWindow { win.on("closed", () => { if (hudOverlayWindow === win) { hudOverlayWindow = null; - hudOverlayDragState = null; } }); diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 20b871887..aaa7fea41 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -1,6 +1,7 @@ /* Electron-specific: no Tailwind equivalent for -webkit-app-region */ .electronDrag { -webkit-app-region: drag; + user-select: none; } .electronNoDrag { diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index d173f795f..f2e75fa78 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -62,7 +62,6 @@ const recorderState = vi.hoisted(() => ({ let selectedSourceChangedListeners: SelectedSourceChangedListener[] = []; let sourceSelectorClosedListeners: Array<() => void> = []; -let dragFrameCallbacks: FrameRequestCallback[] = []; vi.mock("../../hooks/useScreenRecorder", () => ({ useScreenRecorder: () => recorderState.value, @@ -179,9 +178,6 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo getPlatform: vi.fn(async () => "darwin"), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), - startHudOverlayDrag: vi.fn(), - moveHudOverlayDrag: vi.fn(), - endHudOverlayDrag: vi.fn(), hudOverlayHide: vi.fn(), hudOverlayClose: vi.fn(), switchToEditor: vi.fn(async () => undefined), @@ -385,12 +381,6 @@ describe("LaunchWindow HUD dragging", () => { beforeEach(() => { platformState.value = "win32"; resetLaunchMocks(); - dragFrameCallbacks = []; - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - dragFrameCallbacks.push(callback); - return dragFrameCallbacks.length; - }); - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); }); afterEach(() => { @@ -399,42 +389,12 @@ describe("LaunchWindow HUD dragging", () => { vi.unstubAllGlobals(); }); - it("delegates drag coordinates to Electron instead of accumulating renderer deltas", () => { - renderLaunchWindow(); - - const handle = screen.getByTestId("launch-drag-handle"); - Object.assign(handle, { - setPointerCapture: vi.fn(), - hasPointerCapture: vi.fn(() => true), - releasePointerCapture: vi.fn(), - }); - - fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 14, clientY: 16 }); - fireEvent.pointerMove(handle, { pointerId: 1, screenX: 1000, screenY: 1000 }); - fireEvent.pointerMove(handle, { pointerId: 1, screenX: 1000, screenY: 1400 }); - act(() => dragFrameCallbacks.shift()?.(0)); - fireEvent.pointerUp(handle, { pointerId: 1, clientX: 14, clientY: 16 }); - - expect(window.electronAPI.startHudOverlayDrag).toHaveBeenCalledTimes(1); - expect(window.electronAPI.moveHudOverlayDrag).toHaveBeenCalledTimes(1); - expect(window.electronAPI.moveHudOverlayDrag).toHaveBeenCalledWith(); - expect(window.electronAPI.endHudOverlayDrag).toHaveBeenCalledTimes(1); - }); - - it("does not make the HUD click-through while a drag is active", () => { + it("uses Electron's native OS drag region only on the HUD handle", () => { const { container } = renderLaunchWindow(); const handle = screen.getByTestId("launch-drag-handle"); - Object.assign(handle, { - setPointerCapture: vi.fn(), - hasPointerCapture: vi.fn(() => false), - }); - - fireEvent.pointerDown(handle, { button: 0, pointerId: 1 }); - vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents).mockClear(); - fireEvent.pointerLeave(container.firstElementChild as Element); - - expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).not.toHaveBeenCalledWith(true); + expect(handle.className).toMatch(/electronDrag/); + expect((container.firstElementChild as HTMLElement).className).not.toMatch(/electronDrag/); }); }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 5427c4407..7863d834a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -588,84 +588,19 @@ export function LaunchWindow() { setMicrophoneEnabled(!microphoneEnabled); } }; - const hudDraggingRef = useRef(false); - const dragAnimationFrameRef = useRef(null); - const flushHudDragMove = useCallback(() => { - dragAnimationFrameRef.current = null; - if (!hudDraggingRef.current) return; - window.electronAPI?.moveHudOverlayDrag?.(); - }, []); - const scheduleHudDragMove = useCallback(() => { - if (dragAnimationFrameRef.current === null) { - dragAnimationFrameRef.current = window.requestAnimationFrame(flushHudDragMove); - } - }, [flushHudDragMove]); - const cancelPendingHudDragMove = useCallback(() => { - if (dragAnimationFrameRef.current !== null) { - window.cancelAnimationFrame(dragAnimationFrameRef.current); - dragAnimationFrameRef.current = null; - } - }, []); - useEffect(() => { - return () => { - cancelPendingHudDragMove(); - if (hudDraggingRef.current) { - window.electronAPI?.endHudOverlayDrag?.(); - hudDraggingRef.current = false; - } - }; - }, [cancelPendingHudDragMove]); - const handleHudDragPointerDown = (event: React.PointerEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - event.stopPropagation(); - hudDraggingRef.current = true; - setHudMouseEventsEnabled(true); - event.currentTarget.setPointerCapture(event.pointerId); - window.electronAPI?.startHudOverlayDrag?.(); - }; - const handleHudDragPointerMove = () => { - if (!hudDraggingRef.current) return; - setHudMouseEventsEnabled(true); - scheduleHudDragMove(); - }; - const handleHudDragPointerEnd = (event: React.PointerEvent) => { - if (!hudDraggingRef.current) return; - cancelPendingHudDragMove(); - window.electronAPI?.endHudOverlayDrag?.(); - hudDraggingRef.current = false; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - - const hudBounds = hudBarRef.current?.getBoundingClientRect(); - const isPointerOverHud = Boolean( - hudBounds && - event.clientX >= hudBounds.left && - event.clientX <= hudBounds.right && - event.clientY >= hudBounds.top && - event.clientY <= hudBounds.bottom, - ); - setHudMouseEventsEnabled(isPointerOverHud || isLanguageMenuOpen); - }; - return ( // Avoid w-screen/h-screen: 100vw can exceed the inner layout width when scrollbars // affect the viewport (Windows), causing a horizontal scrollbar (issue #305).
{ - if (hudDraggingRef.current) { - setHudMouseEventsEnabled(true); - return; - } const target = event.target as HTMLElement | null; const shouldCapture = isLanguageMenuOpen || Boolean(target?.closest("[data-hud-interactive='true']")); setHudMouseEventsEnabled(shouldCapture); }} onPointerLeave={() => { - if (!isLanguageMenuOpen && !hudDraggingRef.current) { + if (!isLanguageMenuOpen) { setHudMouseEventsEnabled(false); } }} @@ -910,7 +845,7 @@ export function LaunchWindow() { onPointerDown={() => setHudMouseEventsEnabled(true)} onMouseEnter={() => setHudMouseEventsEnabled(true)} onMouseLeave={() => { - if (!isLanguageMenuOpen && !hudDraggingRef.current) { + if (!isLanguageMenuOpen) { setHudMouseEventsEnabled(false); } }} @@ -918,11 +853,7 @@ export function LaunchWindow() { {/* Drag handle */}
{getIcon("drag", "text-white/30")}
From 1c1cbb11b9907e8067ef061304c23d2a3886f06b Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:10:15 +0900 Subject: [PATCH 3/9] test: cover custom Windows scale factors --- .../windowsCursorCoordinates.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts index 3a1e599e3..5b93a7b8f 100644 --- a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts +++ b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts @@ -3,7 +3,15 @@ import { normalizePhysicalPoint } from "./windowsCursorCoordinates"; describe("normalizePhysicalPoint", () => { it.each([ - 1, 1.25, 1.5, 1.75, 2, + 1, + 1.1, + 1.25, + 4 / 3, + 1.5, + 1.75, + 2, + 2.25, + 3, ])("normalizes the same position at a %sx Windows scale", (scaleFactor) => { const dipBounds = { x: -1536, y: 864, width: 1536, height: 864 }; const physicalBounds = { @@ -17,11 +25,10 @@ describe("normalizePhysicalPoint", () => { y: physicalBounds.y + physicalBounds.height * 0.625, }; - expect(normalizePhysicalPoint(point, physicalBounds)).toEqual({ - x: 0.375, - y: 0.625, - withinBounds: true, - }); + const normalized = normalizePhysicalPoint(point, physicalBounds); + expect(normalized.x).toBeCloseTo(0.375, 12); + expect(normalized.y).toBeCloseTo(0.625, 12); + expect(normalized.withinBounds).toBe(true); }); it("supports rotated monitors with a negative virtual-screen origin", () => { From b218082d9417eeb427262c2219989e7424fb1920 Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:53:20 +0900 Subject: [PATCH 4/9] fix: restore Windows HUD drag handle hit testing --- src/components/launch/LaunchWindow.test.tsx | 57 +++++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 18 ++++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index f2e75fa78..f7a9fbf7b 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -396,6 +396,63 @@ describe("LaunchWindow HUD dragging", () => { expect(handle.className).toMatch(/electronDrag/); expect((container.firstElementChild as HTMLElement).className).not.toMatch(/electronDrag/); }); + + it("enables mouse input from forwarded root movement over the native drag handle", async () => { + const { container } = renderLaunchWindow(); + const root = container.firstElementChild as HTMLElement; + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + + vi.spyOn(hudBar, "getBoundingClientRect").mockReturnValue({ + left: 100, + right: 300, + top: 700, + bottom: 760, + width: 200, + height: 60, + x: 100, + y: 700, + toJSON: () => ({}), + }); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(true); + }); + vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents).mockClear(); + + // Electron's click-through forwarding can target the transparent root even + // though the pointer is visually over a -webkit-app-region: drag element. + fireEvent.pointerMove(root, { clientX: 110, clientY: 730 }); + + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(false); + }); + + it("returns the transparent overlay to click-through outside the HUD bounds", async () => { + const { container } = renderLaunchWindow(); + const root = container.firstElementChild as HTMLElement; + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + + vi.spyOn(hudBar, "getBoundingClientRect").mockReturnValue({ + left: 100, + right: 300, + top: 700, + bottom: 760, + width: 200, + height: 60, + x: 100, + y: 700, + toJSON: () => ({}), + }); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(true); + }); + fireEvent.pointerMove(root, { clientX: 110, clientY: 730 }); + vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents).mockClear(); + + fireEvent.pointerMove(root, { clientX: 50, clientY: 500 }); + + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(true); + }); }); describe("LaunchWindow system language prompt", () => { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 7863d834a..1361c1812 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -595,8 +595,24 @@ export function LaunchWindow() { className="h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent" onPointerMove={(event) => { const target = event.target as HTMLElement | null; + const hudBarBounds = hudBarRef.current?.getBoundingClientRect(); + // While the overlay is click-through, Electron forwards mouse movement but + // Chromium may target the transparent root instead of a native drag region. + // Compare client coordinates with the HUD bounds so the drag handle can turn + // mouse input back on before Windows starts the native window drag. + const isInsideHudBar = Boolean( + hudBarBounds && + hudBarBounds.right > hudBarBounds.left && + hudBarBounds.bottom > hudBarBounds.top && + event.clientX >= hudBarBounds.left && + event.clientX <= hudBarBounds.right && + event.clientY >= hudBarBounds.top && + event.clientY <= hudBarBounds.bottom, + ); const shouldCapture = - isLanguageMenuOpen || Boolean(target?.closest("[data-hud-interactive='true']")); + isLanguageMenuOpen || + isInsideHudBar || + Boolean(target?.closest("[data-hud-interactive='true']")); setHudMouseEventsEnabled(shouldCapture); }} onPointerLeave={() => { From 4292678d0013b0be9e2f9f75e0c1dde873b282ac Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:45:40 +0900 Subject: [PATCH 5/9] fix: make Windows HUD hover detection native --- electron/hudOverlayMousePolicy.test.ts | 39 ++++++++++++++++ electron/hudOverlayMousePolicy.ts | 40 ++++++++++++++++ electron/windows.ts | 65 ++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 electron/hudOverlayMousePolicy.test.ts create mode 100644 electron/hudOverlayMousePolicy.ts diff --git a/electron/hudOverlayMousePolicy.test.ts b/electron/hudOverlayMousePolicy.test.ts new file mode 100644 index 000000000..dd669f103 --- /dev/null +++ b/electron/hudOverlayMousePolicy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + isPointInsideHudOverlayBounds, + shouldIgnoreHudOverlayMouseEvents, +} from "./hudOverlayMousePolicy"; + +describe("HUD overlay mouse policy", () => { + const bounds = { x: 1244, y: 1634, width: 587, height: 92 }; + + it("turns click-through off while the cursor is inside the HUD window", () => { + expect(shouldIgnoreHudOverlayMouseEvents(true, { x: 1278, y: 1680 }, bounds)).toBe(false); + }); + + it("restores click-through outside the HUD window", () => { + expect(shouldIgnoreHudOverlayMouseEvents(true, { x: 3370, y: -1057 }, bounds)).toBe(true); + }); + + it("keeps the HUD interactive when the renderer has an open control", () => { + expect(shouldIgnoreHudOverlayMouseEvents(false, { x: 3370, y: -1057 }, bounds)).toBe(false); + }); + + it("supports negative monitor origins without scale conversion", () => { + const negativeBounds = { x: -1920, y: -600, width: 640, height: 100 }; + expect(isPointInsideHudOverlayBounds({ x: -1600, y: -550 }, negativeBounds)).toBe(true); + expect(isPointInsideHudOverlayBounds({ x: -1279, y: -550 }, negativeBounds)).toBe(false); + }); + + it("uses an exclusive right and bottom edge", () => { + expect(isPointInsideHudOverlayBounds({ x: 1244, y: 1634 }, bounds)).toBe(true); + expect(isPointInsideHudOverlayBounds({ x: 1831, y: 1725 }, bounds)).toBe(false); + expect(isPointInsideHudOverlayBounds({ x: 1830, y: 1726 }, bounds)).toBe(false); + }); + + it("rejects empty bounds", () => { + expect( + isPointInsideHudOverlayBounds({ x: 0, y: 0 }, { x: 0, y: 0, width: 0, height: 92 }), + ).toBe(false); + }); +}); diff --git a/electron/hudOverlayMousePolicy.ts b/electron/hudOverlayMousePolicy.ts new file mode 100644 index 000000000..341801db2 --- /dev/null +++ b/electron/hudOverlayMousePolicy.ts @@ -0,0 +1,40 @@ +export type HudOverlayPoint = { + x: number; + y: number; +}; + +export type HudOverlayBounds = HudOverlayPoint & { + width: number; + height: number; +}; + +export function isPointInsideHudOverlayBounds( + point: HudOverlayPoint, + bounds: HudOverlayBounds, +): boolean { + return ( + bounds.width > 0 && + bounds.height > 0 && + point.x >= bounds.x && + point.x < bounds.x + bounds.width && + point.y >= bounds.y && + point.y < bounds.y + bounds.height + ); +} + +/** + * Renderer hover events are only a fast path. A native Electron drag region + * consumes pointer events, so the main process must also make the HUD interactive + * whenever the OS cursor is inside the BrowserWindow bounds. + * + * Electron reports both cursor points and BrowserWindow bounds in DIP, so this + * comparison stays valid across arbitrary Windows scaling and negative monitor + * origins without multiplying by a scale factor. + */ +export function shouldIgnoreHudOverlayMouseEvents( + rendererRequestedIgnore: boolean, + cursor: HudOverlayPoint, + bounds: HudOverlayBounds, +): boolean { + return rendererRequestedIgnore && !isPointInsideHudOverlayBounds(cursor, bounds); +} diff --git a/electron/windows.ts b/electron/windows.ts index 3c71fb3bf..ebb1a8dbf 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; +import { shouldIgnoreHudOverlayMouseEvents } from "./hudOverlayMousePolicy"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -17,6 +18,56 @@ const ASSET_BASE_DIR = process.defaultApp const ASSET_BASE_URL_ARG = `--asset-base-url=${pathToFileURL(`${ASSET_BASE_DIR}${path.sep}`).toString()}`; let hudOverlayWindow: BrowserWindow | null = null; +let hudOverlayRendererRequestedMouseIgnore = true; +let hudOverlayMouseEventsIgnored: boolean | undefined; +let hudOverlayMousePoll: NodeJS.Timeout | null = null; + +function setHudOverlayMouseEventsIgnored(ignore: boolean): void { + if ( + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + hudOverlayMouseEventsIgnored === ignore + ) { + return; + } + + hudOverlayMouseEventsIgnored = ignore; + hudOverlayWindow.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined); +} + +function applyHudOverlayMousePolicy(): void { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + if (!hudOverlayWindow.isVisible()) { + setHudOverlayMouseEventsIgnored(true); + return; + } + + setHudOverlayMouseEventsIgnored( + shouldIgnoreHudOverlayMouseEvents( + hudOverlayRendererRequestedMouseIgnore, + screen.getCursorScreenPoint(), + hudOverlayWindow.getBounds(), + ), + ); +} + +function stopHudOverlayMousePoll(): void { + if (hudOverlayMousePoll) { + clearInterval(hudOverlayMousePoll); + hudOverlayMousePoll = null; + } +} + +function startHudOverlayMousePoll(): void { + stopHudOverlayMousePoll(); + // Forwarded renderer hover is unreliable over `app-region: drag` because the + // OS consumes pointer events there. Polling at display cadence closes that gap. + hudOverlayMousePoll = setInterval(applyHudOverlayMousePolicy, 16); + hudOverlayMousePoll.unref(); +} ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { @@ -25,9 +76,8 @@ ipcMain.on("hud-overlay-hide", () => { }); ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.setIgnoreMouseEvents(ignore, { forward: true }); - } + hudOverlayRendererRequestedMouseIgnore = ignore === true; + applyHudOverlayMousePolicy(); }); // Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it @@ -98,6 +148,7 @@ export function createHudOverlayWindow(): BrowserWindow { // its own rounding and the window itself must be invisible. roundedCorners: false, resizable: false, + movable: true, alwaysOnTop: true, skipTaskbar: true, hasShadow: false, @@ -110,7 +161,6 @@ export function createHudOverlayWindow(): BrowserWindow { backgroundThrottling: false, }, }); - win.setIgnoreMouseEvents(true, { forward: true }); // Follow the user across macOS Spaces, else the HUD stays pinned to the Space // it was first opened on. @@ -128,11 +178,18 @@ export function createHudOverlayWindow(): BrowserWindow { win?.webContents.send("main-process-message", new Date().toLocaleString()); }); + stopHudOverlayMousePoll(); hudOverlayWindow = win; + hudOverlayRendererRequestedMouseIgnore = true; + hudOverlayMouseEventsIgnored = undefined; + setHudOverlayMouseEventsIgnored(true); + startHudOverlayMousePoll(); win.on("closed", () => { if (hudOverlayWindow === win) { + stopHudOverlayMousePoll(); hudOverlayWindow = null; + hudOverlayMouseEventsIgnored = undefined; } }); From db47e36274c36751e8753f146129bf4c2464e6a0 Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:13:43 +0900 Subject: [PATCH 6/9] fix: stabilize Windows HUD dragging at fractional DPI --- electron/electron-env.d.ts | 4 + electron/hudOverlayDrag.test.ts | 42 +++++ electron/hudOverlayDrag.ts | 27 +++ electron/hudOverlayMousePolicy.test.ts | 7 + electron/hudOverlayMousePolicy.ts | 11 ++ electron/preload.ts | 10 ++ electron/windows.ts | 117 +++++++++++- src/components/launch/LaunchWindow.test.tsx | 110 +++++++++++- src/components/launch/LaunchWindow.tsx | 169 +++++++++++++++--- .../launch/hudViewportCompensation.test.ts | 34 ++++ .../launch/hudViewportCompensation.ts | 24 +++ 11 files changed, 524 insertions(+), 31 deletions(-) create mode 100644 electron/hudOverlayDrag.test.ts create mode 100644 electron/hudOverlayDrag.ts create mode 100644 src/components/launch/hudViewportCompensation.test.ts create mode 100644 src/components/launch/hudViewportCompensation.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 91e7092a4..e94e16506 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -24,6 +24,7 @@ declare namespace NodeJS { // Used in Renderer process, expose in `preload.ts` interface Window { electronAPI: { + platform: string; invokeNativeBridge: ( request: import("../src/native/contracts").NativeBridgeRequest, ) => Promise>; @@ -301,6 +302,9 @@ interface Window { hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; setHudOverlaySize: (width: number, height: number) => void; + beginHudOverlayDrag: (screenX: number, screenY: number) => void; + updateHudOverlayDrag: (screenX: number, screenY: number) => void; + endHudOverlayDrag: (screenX?: number, screenY?: number) => void; showCountdownOverlay: (value: number, runId: number) => Promise; setCountdownOverlayValue: (value: number, runId: number) => Promise; hideCountdownOverlay: (runId: number) => Promise; diff --git a/electron/hudOverlayDrag.test.ts b/electron/hudOverlayDrag.test.ts new file mode 100644 index 000000000..495d9d38a --- /dev/null +++ b/electron/hudOverlayDrag.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { getHudOverlayDragPosition, parseHudOverlayDragPoint } from "./hudOverlayDrag"; + +describe("HUD overlay anchored dragging", () => { + it("moves by the OS cursor delta without a scale multiplier", () => { + expect( + getHudOverlayDragPosition({ x: 1244, y: 1634 }, { x: 1272, y: 1671 }, { x: 1372, y: 1671 }), + ).toEqual({ x: 1344, y: 1634 }); + }); + + it("supports negative mixed-monitor origins", () => { + expect( + getHudOverlayDragPosition({ x: -1800, y: -560 }, { x: -1760, y: -520 }, { x: -1300, y: 80 }), + ).toEqual({ x: -1340, y: 40 }); + }); + + it("always resolves from the start instead of accumulating prior frames", () => { + const startWindow = { x: 1244, y: 1634 }; + const startCursor = { x: 1272, y: 1671 }; + expect(getHudOverlayDragPosition(startWindow, startCursor, { x: 1273, y: 1672 })).toEqual({ + x: 1245, + y: 1635, + }); + expect(getHudOverlayDragPosition(startWindow, startCursor, { x: 1274, y: 1673 })).toEqual({ + x: 1246, + y: 1636, + }); + }); + + it("rounds only the final DIP position", () => { + expect( + getHudOverlayDragPosition({ x: 10.25, y: 20.25 }, { x: 5.5, y: 8.5 }, { x: 7, y: 10 }), + ).toEqual({ x: 12, y: 22 }); + }); + + it("accepts only finite renderer screen coordinates", () => { + expect(parseHudOverlayDragPoint(1278.7, 1681.2)).toEqual({ x: 1278.7, y: 1681.2 }); + expect(parseHudOverlayDragPoint(Number.NaN, 1)).toBeNull(); + expect(parseHudOverlayDragPoint(1, Number.POSITIVE_INFINITY)).toBeNull(); + expect(parseHudOverlayDragPoint("1278.7", 1681.2)).toBeNull(); + }); +}); diff --git a/electron/hudOverlayDrag.ts b/electron/hudOverlayDrag.ts new file mode 100644 index 000000000..573ac3420 --- /dev/null +++ b/electron/hudOverlayDrag.ts @@ -0,0 +1,27 @@ +export type HudOverlayDragPoint = { + x: number; + y: number; +}; + +export function parseHudOverlayDragPoint(x: unknown, y: unknown): HudOverlayDragPoint | null { + return typeof x === "number" && Number.isFinite(x) && typeof y === "number" && Number.isFinite(y) + ? { x, y } + : null; +} + +/** + * Resolve each drag frame from the immutable start state. Electron exposes both + * BrowserWindow bounds and cursor screen points in DIP, so no display scale + * conversion is needed at 125%, arbitrary custom scaling, or across monitors. + * Re-anchoring to the start also prevents rounding error from accumulating. + */ +export function getHudOverlayDragPosition( + startWindow: HudOverlayDragPoint, + startCursor: HudOverlayDragPoint, + currentCursor: HudOverlayDragPoint, +): HudOverlayDragPoint { + return { + x: Math.round(startWindow.x + currentCursor.x - startCursor.x), + y: Math.round(startWindow.y + currentCursor.y - startCursor.y), + }; +} diff --git a/electron/hudOverlayMousePolicy.test.ts b/electron/hudOverlayMousePolicy.test.ts index dd669f103..491ce5257 100644 --- a/electron/hudOverlayMousePolicy.test.ts +++ b/electron/hudOverlayMousePolicy.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { isPointInsideHudOverlayBounds, shouldIgnoreHudOverlayMouseEvents, + supportsHudOverlayHoverClickThrough, } from "./hudOverlayMousePolicy"; describe("HUD overlay mouse policy", () => { @@ -36,4 +37,10 @@ describe("HUD overlay mouse policy", () => { isPointInsideHudOverlayBounds({ x: 0, y: 0 }, { x: 0, y: 0, width: 0, height: 92 }), ).toBe(false); }); + + it("keeps the Windows HUD interactive before the first mouse-down", () => { + expect(supportsHudOverlayHoverClickThrough("win32")).toBe(false); + expect(supportsHudOverlayHoverClickThrough("darwin")).toBe(true); + expect(supportsHudOverlayHoverClickThrough("linux")).toBe(true); + }); }); diff --git a/electron/hudOverlayMousePolicy.ts b/electron/hudOverlayMousePolicy.ts index 341801db2..aec614523 100644 --- a/electron/hudOverlayMousePolicy.ts +++ b/electron/hudOverlayMousePolicy.ts @@ -8,6 +8,17 @@ export type HudOverlayBounds = HudOverlayPoint & { height: number; }; +/** + * Windows native drag regions only become draggable when the BrowserWindow is + * already accepting mouse input before the initial button-down. Toggling a + * whole-window click-through flag on hover therefore has an unavoidable race + * at the first contact with the HUD. The Windows HUD is content-sized, so keep + * it interactive and reserve hover-based click-through for other platforms. + */ +export function supportsHudOverlayHoverClickThrough(platform: string): boolean { + return platform !== "win32"; +} + export function isPointInsideHudOverlayBounds( point: HudOverlayPoint, bounds: HudOverlayBounds, diff --git a/electron/preload.ts b/electron/preload.ts index 83726e60f..4a2b33799 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -13,6 +13,7 @@ const assetBaseUrlArg = process.argv.find((arg) => arg.startsWith(ASSET_BASE_URL const assetBaseUrl = assetBaseUrlArg ? assetBaseUrlArg.slice(ASSET_BASE_URL_ARG_PREFIX.length) : ""; contextBridge.exposeInMainWorld("electronAPI", { + platform: process.platform, assetBaseUrl, invokeNativeBridge: (request: NativeBridgeRequest) => { return ipcRenderer.invoke(NATIVE_BRIDGE_CHANNEL, request) as Promise; @@ -29,6 +30,15 @@ contextBridge.exposeInMainWorld("electronAPI", { setHudOverlaySize: (width: number, height: number) => { ipcRenderer.send("hud-overlay-set-size", width, height); }, + beginHudOverlayDrag: (screenX: number, screenY: number) => { + ipcRenderer.send("hud-overlay-drag-start", screenX, screenY); + }, + updateHudOverlayDrag: (screenX: number, screenY: number) => { + ipcRenderer.send("hud-overlay-drag-move", screenX, screenY); + }, + endHudOverlayDrag: (screenX?: number, screenY?: number) => { + ipcRenderer.send("hud-overlay-drag-end", screenX, screenY); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, diff --git a/electron/windows.ts b/electron/windows.ts index ebb1a8dbf..7ed200d2f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,7 +1,15 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; -import { shouldIgnoreHudOverlayMouseEvents } from "./hudOverlayMousePolicy"; +import { + getHudOverlayDragPosition, + type HudOverlayDragPoint, + parseHudOverlayDragPoint, +} from "./hudOverlayDrag"; +import { + shouldIgnoreHudOverlayMouseEvents, + supportsHudOverlayHoverClickThrough, +} from "./hudOverlayMousePolicy"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -21,18 +29,35 @@ let hudOverlayWindow: BrowserWindow | null = null; let hudOverlayRendererRequestedMouseIgnore = true; let hudOverlayMouseEventsIgnored: boolean | undefined; let hudOverlayMousePoll: NodeJS.Timeout | null = null; +let hudOverlayDragTimeout: NodeJS.Timeout | null = null; +let hudOverlayDrag: + | { + windowId: number; + webContentsId: number; + moved: boolean; + startWindow: HudOverlayDragPoint; + startCursor: HudOverlayDragPoint; + } + | undefined; function setHudOverlayMouseEventsIgnored(ignore: boolean): void { + // A Windows `app-region: drag` is handled by the OS before Chromium receives + // hover or pointer events. Keeping the content-sized HUD interactive removes + // the first-contact race that otherwise makes its drag handle ungrabbable. + const effectiveIgnore = supportsHudOverlayHoverClickThrough(process.platform) && ignore; if ( !hudOverlayWindow || hudOverlayWindow.isDestroyed() || - hudOverlayMouseEventsIgnored === ignore + hudOverlayMouseEventsIgnored === effectiveIgnore ) { return; } - hudOverlayMouseEventsIgnored = ignore; - hudOverlayWindow.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined); + hudOverlayMouseEventsIgnored = effectiveIgnore; + hudOverlayWindow.setIgnoreMouseEvents( + effectiveIgnore, + effectiveIgnore ? { forward: true } : undefined, + ); } function applyHudOverlayMousePolicy(): void { @@ -63,12 +88,42 @@ function stopHudOverlayMousePoll(): void { function startHudOverlayMousePoll(): void { stopHudOverlayMousePoll(); + if (!supportsHudOverlayHoverClickThrough(process.platform)) { + return; + } // Forwarded renderer hover is unreliable over `app-region: drag` because the // OS consumes pointer events there. Polling at display cadence closes that gap. hudOverlayMousePoll = setInterval(applyHudOverlayMousePolicy, 16); hudOverlayMousePoll.unref(); } +function stopHudOverlayDrag(): void { + if (hudOverlayDragTimeout) { + clearTimeout(hudOverlayDragTimeout); + hudOverlayDragTimeout = null; + } + hudOverlayDrag = undefined; +} + +function updateHudOverlayDragPosition(currentCursor: HudOverlayDragPoint): void { + if ( + !hudOverlayDrag || + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + hudOverlayWindow.id !== hudOverlayDrag.windowId + ) { + stopHudOverlayDrag(); + return; + } + + const nextPosition = getHudOverlayDragPosition( + hudOverlayDrag.startWindow, + hudOverlayDrag.startCursor, + currentCursor, + ); + hudOverlayWindow.setPosition(nextPosition.x, nextPosition.y); +} + ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { hudOverlayWindow.minimize(); @@ -80,6 +135,59 @@ ipcMain.on("hud-overlay-ignore-mouse-events", (_event, ignore: boolean) => { applyHudOverlayMousePolicy(); }); +ipcMain.on("hud-overlay-drag-start", (event, screenX: unknown, screenY: unknown) => { + if ( + process.platform !== "win32" || + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + event.sender.id !== hudOverlayWindow.webContents.id + ) { + return; + } + + setHudOverlayMouseEventsIgnored(false); + stopHudOverlayDrag(); + const bounds = hudOverlayWindow.getBounds(); + hudOverlayDrag = { + windowId: hudOverlayWindow.id, + webContentsId: event.sender.id, + moved: false, + startWindow: { x: bounds.x, y: bounds.y }, + startCursor: parseHudOverlayDragPoint(screenX, screenY) ?? screen.getCursorScreenPoint(), + }; + // A lost pointer-up must not leave a drag session active indefinitely. + hudOverlayDragTimeout = setTimeout(stopHudOverlayDrag, 30_000); + hudOverlayDragTimeout.unref(); +}); + +ipcMain.on("hud-overlay-drag-move", (event, screenX: unknown, screenY: unknown) => { + if ( + process.platform !== "win32" || + !hudOverlayDrag || + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + hudOverlayWindow.id !== hudOverlayDrag.windowId || + event.sender.id !== hudOverlayDrag.webContentsId + ) { + return; + } + + const currentCursor = parseHudOverlayDragPoint(screenX, screenY); + if (!currentCursor) return; + hudOverlayDrag.moved = true; + updateHudOverlayDragPosition(currentCursor); +}); + +ipcMain.on("hud-overlay-drag-end", (event, screenX: unknown, screenY: unknown) => { + if (hudOverlayDrag?.webContentsId === event.sender.id) { + if (!hudOverlayDrag.moved) { + const finalCursor = parseHudOverlayDragPoint(screenX, screenY); + if (finalCursor) updateHudOverlayDragPosition(finalCursor); + } + stopHudOverlayDrag(); + } +}); + // Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it // stays where the user dragged it while only growing/shrinking, which lets the // vertical tray layout grow tall instead of scrolling inside a fixed window. @@ -188,6 +296,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.on("closed", () => { if (hudOverlayWindow === win) { stopHudOverlayMousePoll(); + stopHudOverlayDrag(); hudOverlayWindow = null; hudOverlayMouseEventsIgnored = undefined; } diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index f7a9fbf7b..22cb98d74 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -168,6 +168,7 @@ function renderLaunchWindow() { function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSource"]) { window.electronAPI = { ...window.electronAPI, + platform: platformState.value, getSelectedSource, openSourceSelector: vi.fn(async () => ({ opened: true })), requestScreenAccess: vi.fn(async () => ({ @@ -178,6 +179,9 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo getPlatform: vi.fn(async () => "darwin"), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), + beginHudOverlayDrag: vi.fn(), + updateHudOverlayDrag: vi.fn(), + endHudOverlayDrag: vi.fn(), hudOverlayHide: vi.fn(), hudOverlayClose: vi.fn(), switchToEditor: vi.fn(async () => undefined), @@ -389,12 +393,70 @@ describe("LaunchWindow HUD dragging", () => { vi.unstubAllGlobals(); }); - it("uses Electron's native OS drag region only on the HUD handle", () => { + it("uses anchored pointer dragging on the Windows HUD handle", async () => { const { container } = renderLaunchWindow(); const handle = screen.getByTestId("launch-drag-handle"); - expect(handle.className).toMatch(/electronDrag/); + await waitFor(() => expect(handle.className).toMatch(/electronNoDrag/)); expect((container.firstElementChild as HTMLElement).className).not.toMatch(/electronDrag/); + + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 7, screenX: 100, screenY: 200 }); + fireEvent.pointerMove(handle, { pointerId: 7, screenX: 110, screenY: 210 }); + fireEvent.pointerUp(handle, { button: 0, pointerId: 7, screenX: 120, screenY: 220 }); + + expect(window.electronAPI.beginHudOverlayDrag).toHaveBeenCalledWith(100, 200); + expect(window.electronAPI.updateHudOverlayDrag).toHaveBeenCalledWith(110, 210); + expect(window.electronAPI.endHudOverlayDrag).toHaveBeenCalledWith(120, 220); + }); + + it("keeps the visible HUD anchored when Windows enlarges its viewport at 125%", async () => { + let innerWidth = 588; + let innerHeight = 95; + vi.spyOn(window, "innerWidth", "get").mockImplementation(() => innerWidth); + vi.spyOn(window, "innerHeight", "get").mockImplementation(() => innerHeight); + const { container } = renderLaunchWindow(); + const handle = screen.getByTestId("launch-drag-handle"); + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 7, screenX: 100, screenY: 200 }); + innerWidth = 594; + innerHeight = 99; + fireEvent.resize(window); + + await waitFor(() => { + expect(hudBar.style.left).toBe("calc(50% - 3px)"); + expect(hudBar.style.bottom).toBe("24px"); + }); + + fireEvent.pointerUp(handle, { button: 0, pointerId: 7, screenX: 110, screenY: 210 }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 8, screenX: 110, screenY: 210 }); + innerWidth = 596; + innerHeight = 100; + fireEvent.pointerMove(handle, { pointerId: 8, screenX: 120, screenY: 220 }); + + await waitFor(() => { + expect(hudBar.style.left).toBe("calc(50% - 4px)"); + expect(hudBar.style.bottom).toBe("25px"); + }); + }); + + it("keeps Electron's native drag region on non-Windows platforms", async () => { + window.electronAPI.platform = "darwin"; + renderLaunchWindow(); + + const handle = screen.getByTestId("launch-drag-handle"); + await waitFor(() => expect(handle.className).toMatch(/electronDrag/)); }); it("enables mouse input from forwarded root movement over the native drag handle", async () => { @@ -453,6 +515,42 @@ describe("LaunchWindow HUD dragging", () => { expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(true); }); + + it("does not grow the HUD when 125% rounding changes only its viewport position", async () => { + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + const { container } = renderLaunchWindow(); + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + let roundedTop = 22.4; + vi.spyOn(hudBar, "getBoundingClientRect").mockImplementation(() => ({ + left: 11.9, + right: 576.1, + top: roundedTop, + bottom: roundedTop + 49.6, + width: 564.2, + height: 49.6, + x: 11.9, + y: roundedTop, + toJSON: () => ({}), + })); + Object.defineProperty(hudBar, "scrollHeight", { value: 48, configurable: true }); + Object.defineProperty(hudBar, "scrollWidth", { value: 564, configurable: true }); + vi.mocked(window.electronAPI.setHudOverlaySize).mockClear(); + + await act(async () => { + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }); + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalledTimes(1); + expect(window.electronAPI.setHudOverlaySize).toHaveBeenLastCalledWith(588, 92); + + // Moving the physical window can change fractional viewport top/bottom values, + // but must not feed those values back into the requested window height. + roundedTop = 25.6; + await act(async () => { + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }); + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalledTimes(1); + }); }); describe("LaunchWindow system language prompt", () => { @@ -502,6 +600,14 @@ describe("LaunchWindow system language prompt", () => { const promptBox = { width: 480, height: 130 }; const promptPanel = prompt.parentElement as HTMLElement; + Object.defineProperty(promptPanel, "scrollHeight", { + value: promptBox.height, + configurable: true, + }); + Object.defineProperty(promptPanel, "offsetHeight", { + value: promptBox.height, + configurable: true, + }); vi.spyOn(promptPanel, "getBoundingClientRect").mockReturnValue({ top: 32, left: 60, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 1361c1812..f7bfce957 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -41,6 +41,11 @@ import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; +import { + getHudViewportCompensation, + type HudViewportCompensation, + type HudViewportSize, +} from "./hudViewportCompensation"; import styles from "./LaunchWindow.module.css"; import { openSourceSelectorWithPermissionRetry } from "./openSourceSelectorFlow"; @@ -156,6 +161,7 @@ export function LaunchWindow() { ); const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const [isLinuxHud, setIsLinuxHud] = useState(false); + const isWindowsHud = window.electronAPI?.platform === "win32"; const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); const hudBarRef = useRef(null); @@ -330,17 +336,20 @@ export function LaunchWindow() { // gap = 20px) so the window stays tall enough that the cap never engages and adds a scrollbar. const SIDE_MARGIN = 24; const TOP_MARGIN = 24; + const BOTTOM_MARGIN = 20; // matches the HUD bar's `bottom-5` + const FIXED_NOTICE_TOP = 32; // matches the notice column's `top-8` // Wide enough that the language menu (11rem) never clips, even when the bar is narrow. const MIN_WIDTH = 220; - const viewportHeight = window.innerHeight; const centerX = window.innerWidth / 2; - // Use natural (scroll) size, not the clipped box: vertical mode's max-h cap is a - // small-screen fallback, and reading clipped height would pin the window to it. - // scrollHeight gives full content height; the cap only engages when the main process clamps to screen. - let topFromBottom = viewportHeight - barEl.getBoundingClientRect().bottom + barEl.scrollHeight; - let halfWidth = barEl.scrollWidth / 2; + // Use the natural size, not viewport-relative top/bottom coordinates. At fractional + // Windows scaling those coordinates can round differently after every move, causing + // a resize feedback loop that walks the visible bar down the screen. + const naturalBarHeight = Math.max(barEl.scrollHeight, barEl.offsetHeight); + const naturalBarWidth = Math.max(barEl.scrollWidth, barEl.offsetWidth); + let contentHeight = BOTTOM_MARGIN + naturalBarHeight; + let halfWidth = naturalBarWidth / 2; // Popups drive both dimensions too. Their vertical anchor depends on bar height, // which is fed back through React state and lags by a frame, so derive their top @@ -351,9 +360,13 @@ export function LaunchWindow() { if (rect.width !== 0 || rect.height !== 0) { const popupBottomOffset = trayLayout === "vertical" - ? barEl.scrollHeight + HUD_DEVICE_POPUP_GAP + ? naturalBarHeight + HUD_DEVICE_POPUP_GAP : HUD_DEVICE_POPUP_HORIZONTAL_BOTTOM; - topFromBottom = Math.max(topFromBottom, popupBottomOffset + rect.height); + const popupHeight = Math.max( + deviceSelectorRef.current.scrollHeight, + deviceSelectorRef.current.offsetHeight, + ); + contentHeight = Math.max(contentHeight, popupBottomOffset + popupHeight); halfWidth = Math.max(halfWidth, rect.width / 2); } } @@ -365,12 +378,16 @@ export function LaunchWindow() { halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } - // Prompt sits at `fixed top-8`; grow the window to fit it so its buttons don't clip (issue #30). + // Prompts sit at fixed `top-8`; use that CSS constant rather than their rounded + // viewport coordinate so repeated measurements remain invariant at custom scaling. if (systemLocalePromptRef.current) { const rect = systemLocalePromptRef.current.getBoundingClientRect(); - const promptHeight = rect.height || systemLocalePromptRef.current.scrollHeight; + const promptHeight = Math.max( + systemLocalePromptRef.current.scrollHeight, + systemLocalePromptRef.current.offsetHeight, + ); if (promptHeight > 0) { - topFromBottom = Math.max(topFromBottom, rect.top + promptHeight); + contentHeight = Math.max(contentHeight, FIXED_NOTICE_TOP + promptHeight); } halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } @@ -379,20 +396,23 @@ export function LaunchWindow() { // the same treatment so its buttons stay clickable. if (softwareFallbackNoticeRef.current) { const rect = softwareFallbackNoticeRef.current.getBoundingClientRect(); - const noticeHeight = rect.height || softwareFallbackNoticeRef.current.scrollHeight; + const noticeHeight = Math.max( + softwareFallbackNoticeRef.current.scrollHeight, + softwareFallbackNoticeRef.current.offsetHeight, + ); if (noticeHeight > 0) { - topFromBottom = Math.max(topFromBottom, rect.top + noticeHeight); + contentHeight = Math.max(contentHeight, FIXED_NOTICE_TOP + noticeHeight); } halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } setHudBarHeight((prev) => { - const next = Math.round(barEl.scrollHeight); + const next = naturalBarHeight; return Math.abs(prev - next) > 1 ? next : prev; }); const width = Math.max(MIN_WIDTH, Math.ceil(halfWidth * 2) + SIDE_MARGIN); - const height = Math.ceil(topFromBottom) + TOP_MARGIN; + const height = Math.ceil(contentHeight) + TOP_MARGIN; if (width === lastHudSizeRef.current.width && height === lastHudSizeRef.current.height) { return; } @@ -474,6 +494,68 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isLanguageMenuOpen); }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); + const [hudViewportCompensation, setHudViewportCompensation] = useState({ + x: 0, + y: 0, + }); + const hudViewportCompensationRef = useRef({ x: 0, y: 0 }); + const hudDragViewportAnchorRef = useRef< + | { + viewport: HudViewportSize; + compensation: HudViewportCompensation; + } + | undefined + >(undefined); + + const updateHudDragViewportCompensation = useCallback(() => { + const anchor = hudDragViewportAnchorRef.current; + if (!anchor) return; + + const next = getHudViewportCompensation(anchor.compensation, anchor.viewport, { + width: window.innerWidth, + height: window.innerHeight, + }); + const previous = hudViewportCompensationRef.current; + if (Math.abs(previous.x - next.x) < 0.01 && Math.abs(previous.y - next.y) < 0.01) { + return; + } + + hudViewportCompensationRef.current = next; + setHudViewportCompensation(next); + }, []); + + useEffect(() => { + if (!isWindowsHud) return; + window.addEventListener("resize", updateHudDragViewportCompensation); + return () => window.removeEventListener("resize", updateHudDragViewportCompensation); + }, [isWindowsHud, updateHudDragViewportCompensation]); + + const hudDragPointerIdRef = useRef(null); + const endWindowsHudDrag = useCallback( + (pointerId: number, target?: HTMLDivElement, screenX?: number, screenY?: number) => { + if (hudDragPointerIdRef.current !== pointerId) return; + updateHudDragViewportCompensation(); + hudDragPointerIdRef.current = null; + hudDragViewportAnchorRef.current = undefined; + if (target?.hasPointerCapture?.(pointerId)) { + target.releasePointerCapture(pointerId); + } + window.electronAPI?.endHudOverlayDrag?.(screenX, screenY); + }, + [updateHudDragViewportCompensation], + ); + + useEffect( + () => () => { + if (hudDragPointerIdRef.current !== null) { + hudDragPointerIdRef.current = null; + hudDragViewportAnchorRef.current = undefined; + window.electronAPI?.endHudOverlayDrag?.(); + } + }, + [], + ); + const defaultSourceName = t("sourceSelector.defaultSourceName"); const [selectedSource, setSelectedSource] = useState(defaultSourceName); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -623,7 +705,10 @@ export function LaunchWindow() { > {/* Top-center notices share one fixed column so they stack instead of overlapping */} {(systemLocaleSuggestion || softwareEncoderFallbackNoticeVisible) && ( -
+
{systemLocaleSuggestion && (
{/* Mic selector */} {showMicControls && ( @@ -857,6 +944,10 @@ export function LaunchWindow() { ? "max-h-[calc(100vh-2.5rem)] flex-col items-center gap-1 overflow-y-auto px-1 py-1.5" : "items-center gap-1.5 px-2 py-1.5" }`} + style={{ + left: `calc(50% - ${hudViewportCompensation.x}px)`, + bottom: 20 + hudViewportCompensation.y, + }} onPointerEnter={() => setHudMouseEventsEnabled(true)} onPointerDown={() => setHudMouseEventsEnabled(true)} onMouseEnter={() => setHudMouseEventsEnabled(true)} @@ -866,10 +957,38 @@ export function LaunchWindow() { } }} > - {/* Drag handle */} + {/* Windows uses anchored OS-cursor dragging; other platforms use the native region. */}
{ + if (!isWindowsHud || event.button !== 0) return; + event.preventDefault(); + hudDragPointerIdRef.current = event.pointerId; + hudDragViewportAnchorRef.current = { + viewport: { width: window.innerWidth, height: window.innerHeight }, + compensation: hudViewportCompensationRef.current, + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + window.electronAPI?.beginHudOverlayDrag?.(event.screenX, event.screenY); + }} + onPointerMove={(event) => { + if (!isWindowsHud || hudDragPointerIdRef.current !== event.pointerId) return; + event.preventDefault(); + updateHudDragViewportCompensation(); + window.electronAPI?.updateHudOverlayDrag?.(event.screenX, event.screenY); + }} + onPointerUp={(event) => + endWindowsHudDrag(event.pointerId, event.currentTarget, event.screenX, event.screenY) + } + onPointerCancel={(event) => + endWindowsHudDrag(event.pointerId, event.currentTarget, event.screenX, event.screenY) + } + onLostPointerCapture={(event) => + endWindowsHudDrag(event.pointerId, undefined, event.screenX, event.screenY) + } > {getIcon("drag", "text-white/30")}
diff --git a/src/components/launch/hudViewportCompensation.test.ts b/src/components/launch/hudViewportCompensation.test.ts new file mode 100644 index 000000000..e6d9919b5 --- /dev/null +++ b/src/components/launch/hudViewportCompensation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { getHudViewportCompensation } from "./hudViewportCompensation"; + +describe("getHudViewportCompensation", () => { + it("counteracts fractional-DPI viewport enlargement around the bottom centre", () => { + expect( + getHudViewportCompensation( + { x: 0, y: 0 }, + { width: 588, height: 95 }, + { width: 594, height: 99 }, + ), + ).toEqual({ x: 3, y: 4 }); + }); + + it("accumulates only the new rounding delta across consecutive drags", () => { + expect( + getHudViewportCompensation( + { x: 3, y: 4 }, + { width: 594, height: 99 }, + { width: 596, height: 100 }, + ), + ).toEqual({ x: 4, y: 5 }); + }); + + it("removes compensation when Chromium shrinks the viewport again", () => { + expect( + getHudViewportCompensation( + { x: 3, y: 4 }, + { width: 594, height: 99 }, + { width: 588, height: 95 }, + ), + ).toEqual({ x: 0, y: 0 }); + }); +}); diff --git a/src/components/launch/hudViewportCompensation.ts b/src/components/launch/hudViewportCompensation.ts new file mode 100644 index 000000000..f6478bc97 --- /dev/null +++ b/src/components/launch/hudViewportCompensation.ts @@ -0,0 +1,24 @@ +export type HudViewportSize = { + width: number; + height: number; +}; + +export type HudViewportCompensation = { + x: number; + y: number; +}; + +/** + * Keeps viewport-centred, bottom-anchored HUD content fixed to the pointer while + * Chromium rounds a transparent Windows HWND outward at fractional DPI scales. + */ +export function getHudViewportCompensation( + startCompensation: HudViewportCompensation, + startViewport: HudViewportSize, + currentViewport: HudViewportSize, +): HudViewportCompensation { + return { + x: startCompensation.x + (currentViewport.width - startViewport.width) / 2, + y: startCompensation.y + currentViewport.height - startViewport.height, + }; +} From 97525ab16a3268c56cfd2128be928bb690cf2b1a Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:12:23 +0900 Subject: [PATCH 7/9] fix: close Windows HUD drag and capture races --- electron/hudOverlayBounds.test.ts | 48 ++++++++++ electron/hudOverlayBounds.ts | 40 +++++++++ electron/ipc/handlers.ts | 21 ++++- .../recording/cursorRecordingTarget.test.ts | 42 +++++++++ .../cursor/recording/cursorRecordingTarget.ts | 19 ++++ electron/windows.ts | 48 +++++----- src/components/launch/LaunchWindow.test.tsx | 88 +++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 56 ++++++++---- 8 files changed, 321 insertions(+), 41 deletions(-) create mode 100644 electron/hudOverlayBounds.test.ts create mode 100644 electron/hudOverlayBounds.ts create mode 100644 electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts create mode 100644 electron/native-bridge/cursor/recording/cursorRecordingTarget.ts diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts new file mode 100644 index 000000000..64a8db376 --- /dev/null +++ b/electron/hudOverlayBounds.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { getHudOverlayResizedBounds } from "./hudOverlayBounds"; + +describe("HUD overlay content resizing", () => { + it("preserves the bottom-centre anchor away from monitor edges", () => { + expect( + getHudOverlayResizedBounds( + { x: 1244, y: 1634, width: 587, height: 92 }, + { x: 0, y: 0, width: 3072, height: 1728 }, + 594, + 99, + ), + ).toEqual({ x: 1241, y: 1627, width: 594, height: 99 }); + }); + + it("keeps an expanding popup inside the right edge of its display", () => { + expect( + getHudOverlayResizedBounds( + { x: 2800, y: 1600, width: 220, height: 100 }, + { x: 0, y: 0, width: 3072, height: 1728 }, + 600, + 200, + ), + ).toEqual({ x: 2472, y: 1500, width: 600, height: 200 }); + }); + + it("supports work areas with negative mixed-monitor origins", () => { + expect( + getHudOverlayResizedBounds( + { x: -1910, y: -180, width: 220, height: 100 }, + { x: -1920, y: -1040, width: 1920, height: 1040 }, + 600, + 200, + ), + ).toEqual({ x: -1920, y: -280, width: 600, height: 200 }); + }); + + it("clamps an oversized HUD to the display work area", () => { + expect( + getHudOverlayResizedBounds( + { x: 3200, y: -1500, width: 400, height: 120 }, + { x: 3072, y: -1920, width: 1080, height: 1920 }, + 1400, + 2200, + ), + ).toEqual({ x: 3072, y: -1920, width: 1080, height: 1920 }); + }); +}); diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts new file mode 100644 index 000000000..d6760ef92 --- /dev/null +++ b/electron/hudOverlayBounds.ts @@ -0,0 +1,40 @@ +export type HudOverlayBounds = { + x: number; + y: number; + width: number; + height: number; +}; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * Resize around the HUD's bottom-centre anchor, then keep the full transparent + * window inside the selected display's work area. The second step matters when + * a popup opens after the user parked the compact bar near a monitor edge. + */ +export function getHudOverlayResizedBounds( + currentBounds: HudOverlayBounds, + workArea: HudOverlayBounds, + requestedWidth: number, + requestedHeight: number, +): HudOverlayBounds { + const workWidth = Math.max(1, Math.round(workArea.width)); + const workHeight = Math.max(1, Math.round(workArea.height)); + const width = Math.min(workWidth, Math.max(1, Math.round(requestedWidth))); + const height = Math.min(workHeight, Math.max(1, Math.round(requestedHeight))); + const centerX = currentBounds.x + currentBounds.width / 2; + const bottomY = currentBounds.y + currentBounds.height; + const minX = Math.round(workArea.x); + const minY = Math.round(workArea.y); + const maxX = minX + workWidth - width; + const maxY = minY + workHeight - height; + + return { + x: clamp(Math.round(centerX - width / 2), minX, maxX), + y: clamp(Math.round(bottomY - height), minY, maxY), + width, + height, + }; +} diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1ccb3a236..dd5b563fb 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -38,6 +38,10 @@ import type { import { mainLogBuffer } from "../diagnostics/main-log-buffer"; import { mainT } from "../i18n"; import { RECORDINGS_DIR } from "../main"; +import { + type CursorRecordingTarget, + resolveCursorRecordingTarget, +} from "../native-bridge/cursor/recording/cursorRecordingTarget"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; @@ -792,20 +796,25 @@ async function resolveDirectShowWebcamClsid(deviceName?: string) { return best.clsid; } -async function startCursorRecording(recordingId?: number) { +async function startCursorRecording(recordingId?: number, explicitTarget?: CursorRecordingTarget) { if (cursorRecordingSession) { pendingCursorRecordingData = await cursorRecordingSession.stop(); cursorRecordingSession = null; } pendingCursorRecordingData = null; - cursorRecordingSession = createCursorRecordingSession({ + const target = resolveCursorRecordingTarget(explicitTarget, { displayId: getSelectedDisplay()?.id ?? null, getDisplayBounds: getSelectedSourceBounds, + sourceId: getSelectedSourceId(), + }); + cursorRecordingSession = createCursorRecordingSession({ + displayId: target.displayId, + getDisplayBounds: target.getDisplayBounds, maxSamples: MAX_CURSOR_SAMPLES, platform: process.platform, sampleIntervalMs: CURSOR_SAMPLE_INTERVAL_MS, - sourceId: getSelectedSourceId(), + sourceId: target.sourceId, startTimeMs: typeof recordingId === "number" && Number.isFinite(recordingId) ? recordingId : undefined, }); @@ -1733,7 +1742,11 @@ export function registerIpcHandlers( const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { nativeWindowsCursorRecordingStartMs = cursorStartTimeMs; - await startCursorRecording(cursorStartTimeMs); + await startCursorRecording(cursorStartTimeMs, { + displayId: Number.isFinite(displayId) ? displayId : null, + getDisplayBounds: () => bounds, + sourceId: request.source.sourceId, + }); console.info("[native-wgc] cursor sampler ready", { cursorStartTimeMs, warmupMs: Date.now() - cursorStartTimeMs, diff --git a/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts b/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts new file mode 100644 index 000000000..fe6ae20a5 --- /dev/null +++ b/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveCursorRecordingTarget } from "./cursorRecordingTarget"; + +describe("cursor recording target resolution", () => { + it("uses the recording request snapshot instead of stale selected-source fields", () => { + const selectedBounds = vi.fn(() => ({ x: 0, y: 0, width: 3072, height: 1728 })); + const requestedBounds = vi.fn(() => ({ x: -1920, y: -1080, width: 1920, height: 1080 })); + const selected = { + displayId: 1, + getDisplayBounds: selectedBounds, + sourceId: "screen:1:0", + }; + const requested = { + displayId: 2, + getDisplayBounds: requestedBounds, + sourceId: "window:424242:0", + }; + + const resolved = resolveCursorRecordingTarget(requested, selected); + + expect(resolved).toBe(requested); + expect(resolved.displayId).toBe(2); + expect(resolved.sourceId).toBe("window:424242:0"); + expect(resolved.getDisplayBounds()).toEqual({ + x: -1920, + y: -1080, + width: 1920, + height: 1080, + }); + expect(selectedBounds).not.toHaveBeenCalled(); + }); + + it("retains the selected-source path for non-native fallback capture", () => { + const selected = { + displayId: 1, + getDisplayBounds: vi.fn(() => ({ x: 0, y: 0, width: 3072, height: 1728 })), + sourceId: "screen:1:0", + }; + + expect(resolveCursorRecordingTarget(undefined, selected)).toBe(selected); + }); +}); diff --git a/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts b/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts new file mode 100644 index 000000000..c177628cd --- /dev/null +++ b/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts @@ -0,0 +1,19 @@ +import type { Rectangle } from "electron"; + +export interface CursorRecordingTarget { + displayId: number | null; + getDisplayBounds: () => Rectangle | null; + sourceId: string | null; +} + +/** + * Keep one source snapshot authoritative for the whole cursor session. Mixing + * fields from a newly requested source with the previously selected source can + * normalize cursor telemetry against the wrong monitor or window. + */ +export function resolveCursorRecordingTarget( + explicitTarget: CursorRecordingTarget | undefined, + selectedTarget: CursorRecordingTarget, +): CursorRecordingTarget { + return explicitTarget ?? selectedTarget; +} diff --git a/electron/windows.ts b/electron/windows.ts index 7ed200d2f..35268eb1e 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; +import { getHudOverlayResizedBounds } from "./hudOverlayBounds"; import { getHudOverlayDragPosition, type HudOverlayDragPoint, @@ -30,11 +31,11 @@ let hudOverlayRendererRequestedMouseIgnore = true; let hudOverlayMouseEventsIgnored: boolean | undefined; let hudOverlayMousePoll: NodeJS.Timeout | null = null; let hudOverlayDragTimeout: NodeJS.Timeout | null = null; +const HUD_OVERLAY_DRAG_INACTIVITY_TIMEOUT_MS = 30_000; let hudOverlayDrag: | { windowId: number; webContentsId: number; - moved: boolean; startWindow: HudOverlayDragPoint; startCursor: HudOverlayDragPoint; } @@ -105,6 +106,14 @@ function stopHudOverlayDrag(): void { hudOverlayDrag = undefined; } +function armHudOverlayDragTimeout(): void { + if (hudOverlayDragTimeout) { + clearTimeout(hudOverlayDragTimeout); + } + hudOverlayDragTimeout = setTimeout(stopHudOverlayDrag, HUD_OVERLAY_DRAG_INACTIVITY_TIMEOUT_MS); + hudOverlayDragTimeout.unref(); +} + function updateHudOverlayDragPosition(currentCursor: HudOverlayDragPoint): void { if ( !hudOverlayDrag || @@ -151,13 +160,11 @@ ipcMain.on("hud-overlay-drag-start", (event, screenX: unknown, screenY: unknown) hudOverlayDrag = { windowId: hudOverlayWindow.id, webContentsId: event.sender.id, - moved: false, startWindow: { x: bounds.x, y: bounds.y }, startCursor: parseHudOverlayDragPoint(screenX, screenY) ?? screen.getCursorScreenPoint(), }; - // A lost pointer-up must not leave a drag session active indefinitely. - hudOverlayDragTimeout = setTimeout(stopHudOverlayDrag, 30_000); - hudOverlayDragTimeout.unref(); + // A lost pointer-up must not leave an inactive drag session alive indefinitely. + armHudOverlayDragTimeout(); }); ipcMain.on("hud-overlay-drag-move", (event, screenX: unknown, screenY: unknown) => { @@ -174,16 +181,17 @@ ipcMain.on("hud-overlay-drag-move", (event, screenX: unknown, screenY: unknown) const currentCursor = parseHudOverlayDragPoint(screenX, screenY); if (!currentCursor) return; - hudOverlayDrag.moved = true; updateHudOverlayDragPosition(currentCursor); + armHudOverlayDragTimeout(); }); ipcMain.on("hud-overlay-drag-end", (event, screenX: unknown, screenY: unknown) => { if (hudOverlayDrag?.webContentsId === event.sender.id) { - if (!hudOverlayDrag.moved) { - const finalCursor = parseHudOverlayDragPoint(screenX, screenY); - if (finalCursor) updateHudOverlayDragPosition(finalCursor); - } + // Pointer-up may be the only event containing the final few pixels. For + // cancel/lost-capture paths the renderer omits coordinates, so use the + // authoritative Electron cursor point instead of accepting a synthetic 0,0. + const finalCursor = parseHudOverlayDragPoint(screenX, screenY) ?? screen.getCursorScreenPoint(); + updateHudOverlayDragPosition(finalCursor); stopHudOverlayDrag(); } }); @@ -206,22 +214,18 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number) => { // Clamp to the work area of the display the HUD sits on; on a short screen the // vertical layout can exceed the display, where the bar's own overflow scroll takes over. const { workArea } = screen.getDisplayMatching(bounds); - const nextWidth = Math.min(workArea.width, Math.max(1, Math.round(width))); - const nextHeight = Math.min(workArea.height, Math.max(1, Math.round(height))); + const nextBounds = getHudOverlayResizedBounds(bounds, workArea, width, height); - if (bounds.width === nextWidth && bounds.height === nextHeight) { + if ( + bounds.x === nextBounds.x && + bounds.y === nextBounds.y && + bounds.width === nextBounds.width && + bounds.height === nextBounds.height + ) { return; } - const centerX = bounds.x + bounds.width / 2; - const bottomY = bounds.y + bounds.height; - - hudOverlayWindow.setBounds({ - x: Math.round(centerX - nextWidth / 2), - y: Math.round(bottomY - nextHeight), - width: nextWidth, - height: nextHeight, - }); + hudOverlayWindow.setBounds(nextBounds); }); /** diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 22cb98d74..4f56dc5ac 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -389,6 +389,7 @@ describe("LaunchWindow HUD dragging", () => { afterEach(() => { cleanup(); + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -414,6 +415,22 @@ describe("LaunchWindow HUD dragging", () => { expect(window.electronAPI.endHudOverlayDrag).toHaveBeenCalledWith(120, 220); }); + it("lets the main process resolve the OS cursor point after pointer cancellation", () => { + renderLaunchWindow(); + const handle = screen.getByTestId("launch-drag-handle"); + + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 11, screenX: 100, screenY: 200 }); + fireEvent.pointerCancel(handle, { pointerId: 11, screenX: 0, screenY: 0 }); + + expect(window.electronAPI.endHudOverlayDrag).toHaveBeenCalledWith(undefined, undefined); + }); + it("keeps the visible HUD anchored when Windows enlarges its viewport at 125%", async () => { let innerWidth = 588; let innerHeight = 95; @@ -451,6 +468,77 @@ describe("LaunchWindow HUD dragging", () => { }); }); + it("applies a fractional-DPI viewport resize delivered after pointer-up", async () => { + let innerWidth = 588; + let innerHeight = 95; + vi.spyOn(window, "innerWidth", "get").mockImplementation(() => innerWidth); + vi.spyOn(window, "innerHeight", "get").mockImplementation(() => innerHeight); + const { container } = renderLaunchWindow(); + const handle = screen.getByTestId("launch-drag-handle"); + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 9, screenX: 100, screenY: 200 }); + fireEvent.pointerUp(handle, { button: 0, pointerId: 9, screenX: 120, screenY: 220 }); + + // Chromium can publish the transparent-HWND size change on the next task, + // after pointer-up has already completed. + innerWidth = 594; + innerHeight = 99; + fireEvent.resize(window); + + await waitFor(() => { + expect(hudBar.style.left).toBe("calc(50% - 3px)"); + expect(hudBar.style.bottom).toBe("24px"); + }); + }); + + it("waits for successive mixed-DPI viewport resizes to settle", () => { + vi.useFakeTimers(); + let innerWidth = 588; + let innerHeight = 95; + vi.spyOn(window, "innerWidth", "get").mockImplementation(() => innerWidth); + vi.spyOn(window, "innerHeight", "get").mockImplementation(() => innerHeight); + const { container } = renderLaunchWindow(); + const handle = screen.getByTestId("launch-drag-handle"); + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 12, screenX: 100, screenY: 200 }); + fireEvent.pointerUp(handle, { button: 0, pointerId: 12, screenX: 120, screenY: 220 }); + + innerWidth = 594; + innerHeight = 99; + fireEvent.resize(window); + expect(hudBar.style.left).toBe("calc(50% - 3px)"); + expect(hudBar.style.bottom).toBe("24px"); + + act(() => vi.advanceTimersByTime(200)); + innerWidth = 596; + innerHeight = 100; + fireEvent.resize(window); + expect(hudBar.style.left).toBe("calc(50% - 4px)"); + expect(hudBar.style.bottom).toBe("25px"); + + act(() => vi.advanceTimersByTime(251)); + innerWidth = 600; + innerHeight = 104; + fireEvent.resize(window); + // The quiet period expired, so an unrelated later content resize is ignored. + expect(hudBar.style.left).toBe("calc(50% - 4px)"); + expect(hudBar.style.bottom).toBe("25px"); + }); + it("keeps Electron's native drag region on non-Windows platforms", async () => { window.electronAPI.platform = "darwin"; renderLaunchWindow(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f7bfce957..947bb615a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -524,36 +524,64 @@ export function LaunchWindow() { setHudViewportCompensation(next); }, []); + const hudDragPointerIdRef = useRef(null); + const hudDragViewportSettleTimerRef = useRef(undefined); + const cancelHudDragViewportSettle = useCallback(() => { + if (hudDragViewportSettleTimerRef.current !== undefined) { + window.clearTimeout(hudDragViewportSettleTimerRef.current); + hudDragViewportSettleTimerRef.current = undefined; + } + }, []); + const scheduleHudDragViewportSettle = useCallback(() => { + cancelHudDragViewportSettle(); + // Transparent HWND resize notifications may arrive on the next task after + // pointer-up. Keep the immutable drag anchor briefly so that final rounding + // can still be compensated, then release it before unrelated content resizes. + hudDragViewportSettleTimerRef.current = window.setTimeout(() => { + updateHudDragViewportCompensation(); + hudDragViewportAnchorRef.current = undefined; + hudDragViewportSettleTimerRef.current = undefined; + }, 250); + }, [cancelHudDragViewportSettle, updateHudDragViewportCompensation]); + const handleWindowsHudViewportResize = useCallback(() => { + updateHudDragViewportCompensation(); + if (hudDragPointerIdRef.current === null && hudDragViewportAnchorRef.current) { + // Release only after a quiet period. Windows can publish several rounded + // viewport sizes while a mixed-DPI move settles. + scheduleHudDragViewportSettle(); + } + }, [scheduleHudDragViewportSettle, updateHudDragViewportCompensation]); + useEffect(() => { if (!isWindowsHud) return; - window.addEventListener("resize", updateHudDragViewportCompensation); - return () => window.removeEventListener("resize", updateHudDragViewportCompensation); - }, [isWindowsHud, updateHudDragViewportCompensation]); + window.addEventListener("resize", handleWindowsHudViewportResize); + return () => window.removeEventListener("resize", handleWindowsHudViewportResize); + }, [handleWindowsHudViewportResize, isWindowsHud]); - const hudDragPointerIdRef = useRef(null); const endWindowsHudDrag = useCallback( (pointerId: number, target?: HTMLDivElement, screenX?: number, screenY?: number) => { if (hudDragPointerIdRef.current !== pointerId) return; updateHudDragViewportCompensation(); hudDragPointerIdRef.current = null; - hudDragViewportAnchorRef.current = undefined; if (target?.hasPointerCapture?.(pointerId)) { target.releasePointerCapture(pointerId); } window.electronAPI?.endHudOverlayDrag?.(screenX, screenY); + scheduleHudDragViewportSettle(); }, - [updateHudDragViewportCompensation], + [scheduleHudDragViewportSettle, updateHudDragViewportCompensation], ); useEffect( () => () => { + cancelHudDragViewportSettle(); if (hudDragPointerIdRef.current !== null) { - hudDragPointerIdRef.current = null; - hudDragViewportAnchorRef.current = undefined; window.electronAPI?.endHudOverlayDrag?.(); } + hudDragPointerIdRef.current = null; + hudDragViewportAnchorRef.current = undefined; }, - [], + [cancelHudDragViewportSettle], ); const defaultSourceName = t("sourceSelector.defaultSourceName"); @@ -966,6 +994,8 @@ export function LaunchWindow() { onPointerDown={(event) => { if (!isWindowsHud || event.button !== 0) return; event.preventDefault(); + updateHudDragViewportCompensation(); + cancelHudDragViewportSettle(); hudDragPointerIdRef.current = event.pointerId; hudDragViewportAnchorRef.current = { viewport: { width: window.innerWidth, height: window.innerHeight }, @@ -983,12 +1013,8 @@ export function LaunchWindow() { onPointerUp={(event) => endWindowsHudDrag(event.pointerId, event.currentTarget, event.screenX, event.screenY) } - onPointerCancel={(event) => - endWindowsHudDrag(event.pointerId, event.currentTarget, event.screenX, event.screenY) - } - onLostPointerCapture={(event) => - endWindowsHudDrag(event.pointerId, undefined, event.screenX, event.screenY) - } + onPointerCancel={(event) => endWindowsHudDrag(event.pointerId, event.currentTarget)} + onLostPointerCapture={(event) => endWindowsHudDrag(event.pointerId)} > {getIcon("drag", "text-white/30")}
From 350cbf3dd8a8b29c81ef686af6ecc965560c9450 Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:03:22 +0900 Subject: [PATCH 8/9] fix: stabilize HUD layout drag and prefer hardware encoding --- electron/hudOverlayDrag.test.ts | 16 ++- electron/hudOverlayDrag.ts | 22 ++++ electron/native/README.md | 4 +- .../native/wgc-capture/src/mf_encoder.cpp | 99 +++++++++++++--- electron/native/wgc-capture/src/mf_encoder.h | 2 + electron/windows.ts | 11 +- scripts/test-windows-wgc-helper.mjs | 12 +- src/components/launch/LaunchWindow.test.tsx | 106 ++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 64 +++++++---- .../launch/hudViewportCompensation.test.ts | 10 ++ .../launch/hudViewportCompensation.ts | 17 ++- src/hooks/useScreenRecorder.ts | 6 +- src/lib/nativeWindowsRecording.ts | 2 +- 13 files changed, 312 insertions(+), 59 deletions(-) diff --git a/electron/hudOverlayDrag.test.ts b/electron/hudOverlayDrag.test.ts index 495d9d38a..aa773601a 100644 --- a/electron/hudOverlayDrag.test.ts +++ b/electron/hudOverlayDrag.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getHudOverlayDragPosition, parseHudOverlayDragPoint } from "./hudOverlayDrag"; +import { + getHudOverlayDragBounds, + getHudOverlayDragPosition, + parseHudOverlayDragPoint, +} from "./hudOverlayDrag"; describe("HUD overlay anchored dragging", () => { it("moves by the OS cursor delta without a scale multiplier", () => { @@ -33,6 +37,16 @@ describe("HUD overlay anchored dragging", () => { ).toEqual({ x: 12, y: 22 }); }); + it("keeps the full BrowserWindow size immutable while moving", () => { + expect( + getHudOverlayDragBounds( + { x: 1244, y: 1202, width: 220, height: 526 }, + { x: 1272, y: 1220 }, + { x: 1372, y: 1320 }, + ), + ).toEqual({ x: 1344, y: 1302, width: 220, height: 526 }); + }); + it("accepts only finite renderer screen coordinates", () => { expect(parseHudOverlayDragPoint(1278.7, 1681.2)).toEqual({ x: 1278.7, y: 1681.2 }); expect(parseHudOverlayDragPoint(Number.NaN, 1)).toBeNull(); diff --git a/electron/hudOverlayDrag.ts b/electron/hudOverlayDrag.ts index 573ac3420..026ff7fc2 100644 --- a/electron/hudOverlayDrag.ts +++ b/electron/hudOverlayDrag.ts @@ -3,6 +3,11 @@ export type HudOverlayDragPoint = { y: number; }; +export type HudOverlayDragBounds = HudOverlayDragPoint & { + width: number; + height: number; +}; + export function parseHudOverlayDragPoint(x: unknown, y: unknown): HudOverlayDragPoint | null { return typeof x === "number" && Number.isFinite(x) && typeof y === "number" && Number.isFinite(y) ? { x, y } @@ -25,3 +30,20 @@ export function getHudOverlayDragPosition( y: Math.round(startWindow.y + currentCursor.y - startCursor.y), }; } + +/** + * Keep the BrowserWindow's logical size immutable for the complete drag. On + * fractional-DPI Windows displays, repeatedly moving only the HWND position can + * otherwise let Chromium publish slightly different viewport dimensions. + */ +export function getHudOverlayDragBounds( + startWindow: HudOverlayDragBounds, + startCursor: HudOverlayDragPoint, + currentCursor: HudOverlayDragPoint, +): HudOverlayDragBounds { + return { + ...getHudOverlayDragPosition(startWindow, startCursor, currentCursor), + width: startWindow.width, + height: startWindow.height, + }; +} diff --git a/electron/native/README.md b/electron/native/README.md index 79399d46b..2bb7ee1fc 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -84,9 +84,9 @@ Current V2 JSON shape: The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation. -Encoder selection: by default the helper keeps the existing sink-writer path first. If that path fails while setting up H.264, it retries with the Microsoft software H.264 encoder (`mfh264enc.dll`). The key of this retry is registering that encoder locally in the helper process via `MFTRegisterLocalByCLSID`, which makes a software H.264 encoder available even when the machine's hardware encoders are missing or broken; hardware transforms are disabled for the retry only as a secondary guard so the sink writer prefers the locally registered software encoder, not as the fallback mechanism itself. Set `preferSoftwareEncoder: true` in the helper JSON, or set `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` before launching Electron, to force the software path from the first attempt. +Encoder selection: by default the helper explicitly enables Media Foundation hardware transforms, allowing the sink writer to select a GPU H.264 encoder. If that path fails while setting up H.264, it retries with the Microsoft software H.264 encoder (`mfh264enc.dll`). The key of this retry is registering that encoder locally in the helper process via `MFTRegisterLocalByCLSID`, which makes a software H.264 encoder available even when the machine's hardware encoders are missing or broken; hardware transforms are disabled for the retry only as a secondary guard so the sink writer prefers the locally registered software encoder, not as the fallback mechanism itself. Set `preferSoftwareEncoder: true` in the helper JSON, or set `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` before launching Electron, to force the software path from the first attempt. -The helper reports the outcome through the `encoder-selection` stdout event (`video` is `default`, `software-preferred`, or `software-fallback`). When the app sees `software-fallback` — the default encoder failed and the helper switched on its own — it shows a small dismissible notice in the recording HUD with a "Don't show again" option, because software encoding can raise CPU usage. An explicit `software-preferred` selection shows no notice, and the event stays available for diagnostics either way. +The helper reports the outcome through the `encoder-selection` stdout event. `video` is `hardware` when the selected H.264 transform exposes the Media Foundation hardware URL attribute, `software-default` when the hardware-enabled path selected a software encoder, `software-preferred` or `software-fallback` for the explicit software paths, and `default` only when the sink writer does not expose enough information to classify the transform. The app shows its dismissible software-encoder notice for `software-default` and `software-fallback`, because CPU usage can be higher. An explicit `software-preferred` selection shows no notice, and the event stays available for diagnostics either way. Encoder diagnostic on final sink-writer failure: when the final `MFCreateSinkWriterFromURL` attempt fails, the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. There is still no fail-fast pre-flight gate because `MFTEnumEx` and `MFCreateSinkWriterFromURL` can disagree about which H.264 encoders are available in non-interactive / Session 0 contexts. diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 42b708910..59fa71c19 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -133,7 +134,7 @@ void logMissingH264EncoderError() { enum class SinkWriterCreateStage { SoftwareEncoderRegistration, CreateAttributes, - DisableHardwareTransforms, + ConfigureHardwareTransforms, CreateSinkWriter, }; @@ -204,20 +205,27 @@ HRESULT createSinkWriterFromUrl( return registerHr; } - HRESULT hr = MFCreateAttributes(&attributes, 1); - if (FAILED(hr)) { - std::cerr << "ERROR: MFCreateAttributes(sink writer) failed (hr=0x" - << std::hex << hr << std::dec << ")" << std::endl; - failedStage = SinkWriterCreateStage::CreateAttributes; - return hr; - } - hr = attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, FALSE); - if (FAILED(hr)) { - std::cerr << "ERROR: Set MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS failed (hr=0x" - << std::hex << hr << std::dec << ")" << std::endl; - failedStage = SinkWriterCreateStage::DisableHardwareTransforms; - return hr; - } + } + + HRESULT hr = MFCreateAttributes(&attributes, 1); + if (FAILED(hr)) { + std::cerr << "ERROR: MFCreateAttributes(sink writer) failed (hr=0x" + << std::hex << hr << std::dec << ")" << std::endl; + failedStage = SinkWriterCreateStage::CreateAttributes; + return hr; + } + + // Sink writers do not use hardware encoders by default. Make the normal + // path explicitly hardware-enabled and the software path explicitly + // hardware-disabled. + hr = attributes->SetUINT32( + MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, + forceSoftwareEncoder ? FALSE : TRUE); + if (FAILED(hr)) { + std::cerr << "ERROR: Set MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS failed (hr=0x" + << std::hex << hr << std::dec << ")" << std::endl; + failedStage = SinkWriterCreateStage::ConfigureHardwareTransforms; + return hr; } failedStage = SinkWriterCreateStage::CreateSinkWriter; @@ -242,6 +250,63 @@ HRESULT createSinkWriterFromUrl( return sinkWriterHr; } +const char* detectDefaultVideoEncoderSelection( + IMFSinkWriter* sinkWriter, + DWORD videoStreamIndex) { + if (sinkWriter == nullptr) { + return kVideoEncoderSelectionDefault; + } + + Microsoft::WRL::ComPtr sinkWriterEx; + const HRESULT queryHr = sinkWriter->QueryInterface(IID_PPV_ARGS(&sinkWriterEx)); + if (FAILED(queryHr)) { + std::cerr + << "WARNING: IMFSinkWriterEx is unavailable; the active H.264 encoder type is unknown " + << "(hr=0x" << std::hex << queryHr << std::dec << ")." + << std::endl; + return kVideoEncoderSelectionDefault; + } + + for (DWORD transformIndex = 0;; transformIndex += 1) { + GUID category = GUID_NULL; + Microsoft::WRL::ComPtr transform; + const HRESULT transformHr = sinkWriterEx->GetTransformForStream( + videoStreamIndex, + transformIndex, + &category, + &transform); + if (FAILED(transformHr)) { + break; + } + if (!IsEqualGUID(category, MFT_CATEGORY_VIDEO_ENCODER) || !transform) { + continue; + } + + Microsoft::WRL::ComPtr transformAttributes; + if (SUCCEEDED(transform->GetAttributes(&transformAttributes)) && transformAttributes) { + UINT32 hardwareUrlLength = 0; + if (SUCCEEDED(transformAttributes->GetStringLength( + MFT_ENUM_HARDWARE_URL_Attribute, + &hardwareUrlLength))) { + std::cerr << "INFO: Media Foundation selected a hardware H.264 encoder." + << std::endl; + return kVideoEncoderSelectionHardware; + } + } + + std::cerr << "WARNING: Media Foundation selected a software H.264 encoder " + << "even though hardware transforms were enabled." + << std::endl; + return kVideoEncoderSelectionSoftwareDefault; + } + + std::cerr + << "WARNING: The sink writer did not expose its H.264 encoder transform; " + << "the active encoder type is unknown." + << std::endl; + return kVideoEncoderSelectionDefault; +} + void logSinkWriterCreateFailure(HRESULT sinkWriterHr, const AudioInputFormat* audioFormat) { const UINT32 h264EncoderCount = countRegisteredH264VideoEncoders(); const UINT32 aacEncoderCount = (audioFormat != nullptr) @@ -434,7 +499,9 @@ bool MFEncoder::initialize( return false; } - videoEncoderSelection_ = selection; + videoEncoderSelection_ = forceSoftwareEncoder + ? selection + : detectDefaultVideoEncoderSelection(sinkWriter_.Get(), videoStreamIndex_); return true; }; diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 8f5787481..8021c03bf 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -32,6 +32,8 @@ struct MFEncoderOptions { }; constexpr const char* kVideoEncoderSelectionDefault = "default"; +constexpr const char* kVideoEncoderSelectionHardware = "hardware"; +constexpr const char* kVideoEncoderSelectionSoftwareDefault = "software-default"; constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; diff --git a/electron/windows.ts b/electron/windows.ts index 35268eb1e..ccb32a92d 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -3,7 +3,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; import { getHudOverlayResizedBounds } from "./hudOverlayBounds"; import { - getHudOverlayDragPosition, + getHudOverlayDragBounds, + type HudOverlayDragBounds, type HudOverlayDragPoint, parseHudOverlayDragPoint, } from "./hudOverlayDrag"; @@ -36,7 +37,7 @@ let hudOverlayDrag: | { windowId: number; webContentsId: number; - startWindow: HudOverlayDragPoint; + startWindow: HudOverlayDragBounds; startCursor: HudOverlayDragPoint; } | undefined; @@ -125,12 +126,12 @@ function updateHudOverlayDragPosition(currentCursor: HudOverlayDragPoint): void return; } - const nextPosition = getHudOverlayDragPosition( + const nextBounds = getHudOverlayDragBounds( hudOverlayDrag.startWindow, hudOverlayDrag.startCursor, currentCursor, ); - hudOverlayWindow.setPosition(nextPosition.x, nextPosition.y); + hudOverlayWindow.setBounds(nextBounds); } ipcMain.on("hud-overlay-hide", () => { @@ -160,7 +161,7 @@ ipcMain.on("hud-overlay-drag-start", (event, screenX: unknown, screenY: unknown) hudOverlayDrag = { windowId: hudOverlayWindow.id, webContentsId: event.sender.id, - startWindow: { x: bounds.x, y: bounds.y }, + startWindow: bounds, startCursor: parseHudOverlayDragPoint(screenX, screenY) ?? screen.getCursorScreenPoint(), }; // A lost pointer-up must not leave an inactive drag session alive indefinitely. diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index c6c69441e..775d63f17 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -386,17 +386,17 @@ if ( `WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`, ); } -const expectedEncoderSelection = WITH_SOFTWARE_FALLBACK - ? "software-fallback" +const expectedEncoderSelections = WITH_SOFTWARE_FALLBACK + ? ["software-fallback"] : WITH_SOFTWARE_ENCODER - ? "software-preferred" - : "default"; + ? ["software-preferred"] + : ["hardware", "software-default", "default"]; if ( - encoderSelection?.video !== expectedEncoderSelection || + !expectedEncoderSelections.includes(encoderSelection?.video) || encoderSelection.preferSoftwareEncoder !== WITH_SOFTWARE_ENCODER ) { throw new Error( - `WGC helper encoder selection was ${JSON.stringify(encoderSelection)}, expected ${expectedEncoderSelection} with preferSoftwareEncoder=${WITH_SOFTWARE_ENCODER}: ${result.stdout}`, + `WGC helper encoder selection was ${JSON.stringify(encoderSelection)}, expected one of ${expectedEncoderSelections.join(", ")} with preferSoftwareEncoder=${WITH_SOFTWARE_ENCODER}: ${result.stdout}`, ); } diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 4f56dc5ac..d4f100f6c 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -539,6 +539,112 @@ describe("LaunchWindow HUD dragging", () => { expect(hudBar.style.bottom).toBe("25px"); }); + it("does not treat an intentional vertical tray resize as delayed DPI drag rounding", () => { + vi.useFakeTimers(); + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + let innerWidth = 588; + let innerHeight = 95; + vi.spyOn(window, "innerWidth", "get").mockImplementation(() => innerWidth); + vi.spyOn(window, "innerHeight", "get").mockImplementation(() => innerHeight); + const { container } = renderLaunchWindow(); + const handle = screen.getByTestId("launch-drag-handle"); + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 13, screenX: 100, screenY: 200 }); + fireEvent.pointerUp(handle, { button: 0, pointerId: 13, screenX: 120, screenY: 220 }); + + fireEvent.click(screen.getByTestId("launch-tray-layout-button")); + expect(hudBar).toHaveAttribute("data-tray-layout", "vertical"); + + // The main process now resizes the intentionally tall/narrow content window. + // This is not the few-pixel Chromium rounding that the drag anchor compensates. + innerWidth = 220; + innerHeight = 526; + fireEvent.resize(window); + + expect(hudBar.style.left).toBe("calc(50% - 0px)"); + expect(hudBar.style.bottom).toBe("20px"); + }); + + it("drops vertical drag compensation when switching back to the horizontal tray", () => { + vi.useFakeTimers(); + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ trayLayout: "horizontal" }), + ); + let innerWidth = 220; + let innerHeight = 526; + vi.spyOn(window, "innerWidth", "get").mockImplementation(() => innerWidth); + vi.spyOn(window, "innerHeight", "get").mockImplementation(() => innerHeight); + const { container } = renderLaunchWindow(); + const handle = screen.getByTestId("launch-drag-handle"); + const layoutButton = screen.getByTestId("launch-tray-layout-button"); + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + + fireEvent.click(layoutButton); + expect(hudBar).toHaveAttribute("data-tray-layout", "vertical"); + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 14, screenX: 100, screenY: 200 }); + innerWidth = 218; + innerHeight = 524; + fireEvent.resize(window); + expect(hudBar.style.left).toBe("calc(50% + 1px)"); + expect(hudBar.style.bottom).toBe("18px"); + fireEvent.pointerUp(handle, { button: 0, pointerId: 14, screenX: 110, screenY: 210 }); + + fireEvent.click(layoutButton); + expect(hudBar).toHaveAttribute("data-tray-layout", "horizontal"); + expect(hudBar.style.left).toBe("calc(50% - 0px)"); + expect(hudBar.style.bottom).toBe("20px"); + }); + + it("defers vertical content sizing until the active drag ends", () => { + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + const { container } = renderLaunchWindow(); + const handle = screen.getByTestId("launch-drag-handle"); + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + let naturalWidth = 564; + let naturalHeight = 48; + Object.defineProperties(hudBar, { + scrollWidth: { get: () => naturalWidth, configurable: true }, + scrollHeight: { get: () => naturalHeight, configurable: true }, + }); + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + + act(() => { + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }); + expect(window.electronAPI.setHudOverlaySize).toHaveBeenLastCalledWith(588, 92); + vi.mocked(window.electronAPI.setHudOverlaySize).mockClear(); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 15, screenX: 100, screenY: 200 }); + naturalWidth = 40; + naturalHeight = 480; + act(() => { + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }); + expect(window.electronAPI.setHudOverlaySize).not.toHaveBeenCalled(); + + fireEvent.pointerUp(handle, { button: 0, pointerId: 15, screenX: 110, screenY: 210 }); + expect(window.electronAPI.setHudOverlaySize).toHaveBeenLastCalledWith(220, 524); + }); + it("keeps Electron's native drag region on non-Windows platforms", async () => { window.electronAPI.platform = "darwin"; renderLaunchWindow(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 947bb615a..672902c2a 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -323,6 +323,37 @@ export function LaunchWindow() { return () => cancelAnimationFrame(id); }, [isLanguageMenuOpen]); + const [hudViewportCompensation, setHudViewportCompensation] = useState({ + x: 0, + y: 0, + }); + const hudViewportCompensationRef = useRef({ x: 0, y: 0 }); + const hudDragPointerIdRef = useRef(null); + const hudDragViewportAnchorRef = useRef< + | { + viewport: HudViewportSize; + compensation: HudViewportCompensation; + } + | undefined + >(undefined); + const hudDragViewportSettleTimerRef = useRef(undefined); + const cancelHudDragViewportSettle = useCallback(() => { + if (hudDragViewportSettleTimerRef.current !== undefined) { + window.clearTimeout(hudDragViewportSettleTimerRef.current); + hudDragViewportSettleTimerRef.current = undefined; + } + }, []); + const resetDragViewportCompensationForContentResize = useCallback(() => { + if (hudDragPointerIdRef.current !== null) return; + cancelHudDragViewportSettle(); + hudDragViewportAnchorRef.current = undefined; + const previous = hudViewportCompensationRef.current; + if (Math.abs(previous.x) < 0.01 && Math.abs(previous.y) < 0.01) return; + const reset = { x: 0, y: 0 }; + hudViewportCompensationRef.current = reset; + setHudViewportCompensation(reset); + }, [cancelHudDragViewportSettle]); + // Resize the overlay window to fit content, else the taller vertical tray gets clipped // and scrolls. Measure from the window's bottom-centre (the anchor the main process // preserves) so fixed bottom/centre offsets keep this stable and it doesn't oscillate. @@ -330,6 +361,9 @@ export function LaunchWindow() { const measureHudSize = useCallback(() => { const barEl = hudBarRef.current; if (!barEl || !window.electronAPI?.setHudOverlaySize) return; + // Window movement must never feed viewport-constrained vertical layout back + // into BrowserWindow sizing. Flush any real content change once drag ends. + if (hudDragPointerIdRef.current !== null) return; // Breathing room so the drop shadow isn't clipped. TOP_MARGIN must also exceed the // slack in the bar's `max-h: calc(100vh - 2.5rem)` cap (40px reserved - 20px bottom @@ -416,9 +450,12 @@ export function LaunchWindow() { if (width === lastHudSizeRef.current.width && height === lastHudSizeRef.current.height) { return; } + // A large, intentional content resize (most visibly horizontal -> vertical) + // must not be interpreted as one of Chromium's few-pixel post-drag DPI resizes. + resetDragViewportCompensationForContentResize(); lastHudSizeRef.current = { width, height }; window.electronAPI.setHudOverlaySize(width, height); - }, [trayLayout]); + }, [resetDragViewportCompensationForContentResize, trayLayout]); // One persistent observer; elements wire themselves up via callback refs as they // mount/unmount so measurement re-runs without recreating it or threading mount state through deps. @@ -494,19 +531,6 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isLanguageMenuOpen); }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); - const [hudViewportCompensation, setHudViewportCompensation] = useState({ - x: 0, - y: 0, - }); - const hudViewportCompensationRef = useRef({ x: 0, y: 0 }); - const hudDragViewportAnchorRef = useRef< - | { - viewport: HudViewportSize; - compensation: HudViewportCompensation; - } - | undefined - >(undefined); - const updateHudDragViewportCompensation = useCallback(() => { const anchor = hudDragViewportAnchorRef.current; if (!anchor) return; @@ -524,14 +548,6 @@ export function LaunchWindow() { setHudViewportCompensation(next); }, []); - const hudDragPointerIdRef = useRef(null); - const hudDragViewportSettleTimerRef = useRef(undefined); - const cancelHudDragViewportSettle = useCallback(() => { - if (hudDragViewportSettleTimerRef.current !== undefined) { - window.clearTimeout(hudDragViewportSettleTimerRef.current); - hudDragViewportSettleTimerRef.current = undefined; - } - }, []); const scheduleHudDragViewportSettle = useCallback(() => { cancelHudDragViewportSettle(); // Transparent HWND resize notifications may arrive on the next task after @@ -568,8 +584,9 @@ export function LaunchWindow() { } window.electronAPI?.endHudOverlayDrag?.(screenX, screenY); scheduleHudDragViewportSettle(); + measureHudSize(); }, - [scheduleHudDragViewportSettle, updateHudDragViewportCompensation], + [measureHudSize, scheduleHudDragViewportSettle, updateHudDragViewportCompensation], ); useEffect( @@ -689,6 +706,7 @@ export function LaunchWindow() { /** Switches the HUD between horizontal and vertical tray layouts. */ const toggleTrayLayout = () => { const nextLayout = trayLayout === "horizontal" ? "vertical" : "horizontal"; + resetDragViewportCompensationForContentResize(); setTrayLayout(nextLayout); saveUserPreferences({ trayLayout: nextLayout }); }; diff --git a/src/components/launch/hudViewportCompensation.test.ts b/src/components/launch/hudViewportCompensation.test.ts index e6d9919b5..1826a1b7f 100644 --- a/src/components/launch/hudViewportCompensation.test.ts +++ b/src/components/launch/hudViewportCompensation.test.ts @@ -31,4 +31,14 @@ describe("getHudViewportCompensation", () => { ), ).toEqual({ x: 0, y: 0 }); }); + + it("ignores a large intentional horizontal-to-vertical content resize", () => { + expect( + getHudViewportCompensation( + { x: 0, y: 0 }, + { width: 588, height: 95 }, + { width: 220, height: 526 }, + ), + ).toEqual({ x: 0, y: 0 }); + }); }); diff --git a/src/components/launch/hudViewportCompensation.ts b/src/components/launch/hudViewportCompensation.ts index f6478bc97..da6382258 100644 --- a/src/components/launch/hudViewportCompensation.ts +++ b/src/components/launch/hudViewportCompensation.ts @@ -8,6 +8,10 @@ export type HudViewportCompensation = { y: number; }; +// Transparent HWND rounding is a small, bounded correction. Larger changes are +// intentional content/window resizes and must never move fixed HUD content. +const MAX_VIEWPORT_ROUNDING_DELTA = 16; + /** * Keeps viewport-centred, bottom-anchored HUD content fixed to the pointer while * Chromium rounds a transparent Windows HWND outward at fractional DPI scales. @@ -17,8 +21,17 @@ export function getHudViewportCompensation( startViewport: HudViewportSize, currentViewport: HudViewportSize, ): HudViewportCompensation { + const widthDelta = currentViewport.width - startViewport.width; + const heightDelta = currentViewport.height - startViewport.height; + if ( + Math.abs(widthDelta) > MAX_VIEWPORT_ROUNDING_DELTA || + Math.abs(heightDelta) > MAX_VIEWPORT_ROUNDING_DELTA + ) { + return startCompensation; + } + return { - x: startCompensation.x + (currentViewport.width - startViewport.width) / 2, - y: startCompensation.y + currentViewport.height - startViewport.height, + x: startCompensation.x + widthDelta / 2, + y: startCompensation.y + heightDelta, }; } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index af95b4722..f03ca24f8 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -895,10 +895,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { throw new Error(result.error ?? "Native Windows capture failed."); } - // Tell the user when the helper silently switched away from the default - // GPU encoder; an explicit software-preferred selection needs no notice. + // Tell the user when the hardware-enabled path either selected software + // directly or had to retry it. An explicit software preference needs no notice. setSoftwareEncoderFallbackNoticeVisible( - result.videoEncoderSelection === "software-fallback" && + ["software-default", "software-fallback"].includes(result.videoEncoderSelection ?? "") && !loadUserPreferences().hideSoftwareEncoderFallbackNotice, ); diff --git a/src/lib/nativeWindowsRecording.ts b/src/lib/nativeWindowsRecording.ts index 313eff9b5..50410b226 100644 --- a/src/lib/nativeWindowsRecording.ts +++ b/src/lib/nativeWindowsRecording.ts @@ -45,7 +45,7 @@ export type NativeWindowsRecordingStartResult = { path?: string; helperPath?: string; error?: string; - /** Helper-reported encoder selection: "default", "software-preferred", or "software-fallback". */ + /** Helper-reported encoder selection: hardware, software-default/preferred/fallback, or default when detection is unavailable. */ videoEncoderSelection?: string | null; }; From f756739f123a498c0d30c75727bd34247a47796a Mon Sep 17 00:00:00 2001 From: YoneRai12 <183966348+YoneRai12@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:17:57 +0900 Subject: [PATCH 9/9] fix: address Windows capture review findings --- electron/ipc/handlers.ts | 3 - .../recording/cursorRecordingTarget.test.ts | 4 -- .../cursor/recording/cursorRecordingTarget.ts | 1 - .../native-bridge/cursor/recording/factory.ts | 2 - .../windowsCursorCoordinates.test.ts | 34 +++++++++- .../recording/windowsCursorCoordinates.ts | 9 +++ .../windowsCursorSamplerArgs.test.ts | 15 +++++ .../recording/windowsCursorSamplerArgs.ts | 21 ++++++ .../windowsNativeRecordingSession.ts | 46 ++++++------- .../windowsNativeRecordingSession.types.ts | 2 +- .../native/wgc-capture/src/cursor-sampler.cpp | 64 +++++++++--------- src/components/launch/LaunchWindow.test.tsx | 65 ++++++++++++++++++- src/components/launch/LaunchWindow.tsx | 23 +++++-- 13 files changed, 216 insertions(+), 73 deletions(-) create mode 100644 electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.test.ts create mode 100644 electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.ts diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index dd5b563fb..39fca8b54 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -804,12 +804,10 @@ async function startCursorRecording(recordingId?: number, explicitTarget?: Curso pendingCursorRecordingData = null; const target = resolveCursorRecordingTarget(explicitTarget, { - displayId: getSelectedDisplay()?.id ?? null, getDisplayBounds: getSelectedSourceBounds, sourceId: getSelectedSourceId(), }); cursorRecordingSession = createCursorRecordingSession({ - displayId: target.displayId, getDisplayBounds: target.getDisplayBounds, maxSamples: MAX_CURSOR_SAMPLES, platform: process.platform, @@ -1743,7 +1741,6 @@ export function registerIpcHandlers( if (cursorCaptureMode === "editable-overlay") { nativeWindowsCursorRecordingStartMs = cursorStartTimeMs; await startCursorRecording(cursorStartTimeMs, { - displayId: Number.isFinite(displayId) ? displayId : null, getDisplayBounds: () => bounds, sourceId: request.source.sourceId, }); diff --git a/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts b/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts index fe6ae20a5..bb5cb94d9 100644 --- a/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts +++ b/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts @@ -6,12 +6,10 @@ describe("cursor recording target resolution", () => { const selectedBounds = vi.fn(() => ({ x: 0, y: 0, width: 3072, height: 1728 })); const requestedBounds = vi.fn(() => ({ x: -1920, y: -1080, width: 1920, height: 1080 })); const selected = { - displayId: 1, getDisplayBounds: selectedBounds, sourceId: "screen:1:0", }; const requested = { - displayId: 2, getDisplayBounds: requestedBounds, sourceId: "window:424242:0", }; @@ -19,7 +17,6 @@ describe("cursor recording target resolution", () => { const resolved = resolveCursorRecordingTarget(requested, selected); expect(resolved).toBe(requested); - expect(resolved.displayId).toBe(2); expect(resolved.sourceId).toBe("window:424242:0"); expect(resolved.getDisplayBounds()).toEqual({ x: -1920, @@ -32,7 +29,6 @@ describe("cursor recording target resolution", () => { it("retains the selected-source path for non-native fallback capture", () => { const selected = { - displayId: 1, getDisplayBounds: vi.fn(() => ({ x: 0, y: 0, width: 3072, height: 1728 })), sourceId: "screen:1:0", }; diff --git a/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts b/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts index c177628cd..1aad3279e 100644 --- a/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts +++ b/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts @@ -1,7 +1,6 @@ import type { Rectangle } from "electron"; export interface CursorRecordingTarget { - displayId: number | null; getDisplayBounds: () => Rectangle | null; sourceId: string | null; } diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts index e94d24494..9a82ffd55 100644 --- a/electron/native-bridge/cursor/recording/factory.ts +++ b/electron/native-bridge/cursor/recording/factory.ts @@ -5,7 +5,6 @@ import { TelemetryRecordingSession } from "./telemetryRecordingSession"; import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession"; interface CreateCursorRecordingSessionOptions { - displayId?: number | null; getDisplayBounds: () => Rectangle | null; maxSamples: number; platform: NodeJS.Platform; @@ -19,7 +18,6 @@ export function createCursorRecordingSession( ): CursorRecordingSession { if (options.platform === "win32") { return new WindowsNativeRecordingSession({ - displayId: options.displayId, getDisplayBounds: options.getDisplayBounds, maxSamples: options.maxSamples, sampleIntervalMs: options.sampleIntervalMs, diff --git a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts index 5b93a7b8f..b34d65b29 100644 --- a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts +++ b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { normalizePhysicalPoint } from "./windowsCursorCoordinates"; +import { describe, expect, it, vi } from "vitest"; +import { + normalizePhysicalPoint, + resolveWindowsCursorPhysicalBounds, +} from "./windowsCursorCoordinates"; describe("normalizePhysicalPoint", () => { it.each([ @@ -45,4 +48,31 @@ describe("normalizePhysicalPoint", () => { normalizePhysicalPoint({ x: -1, y: 400 }, { x: 0, y: 0, width: 3840, height: 2160 }), ).toMatchObject({ withinBounds: false }); }); + + it("reuses display bounds reported once in the ready event", () => { + const readyBounds = { x: -2400, y: 0, width: 2400, height: 1350 }; + const convertDipToPhysical = vi.fn(); + + expect( + resolveWindowsCursorPhysicalBounds( + undefined, + readyBounds, + { x: -1920, y: 0, width: 1920, height: 1080 }, + convertDipToPhysical, + ), + ).toBe(readyBounds); + expect(convertDipToPhysical).not.toHaveBeenCalled(); + }); + + it("lets a moving window sample override the ready bounds", () => { + const sampleBounds = { x: 150, y: 200, width: 1200, height: 800 }; + expect( + resolveWindowsCursorPhysicalBounds( + sampleBounds, + { x: 0, y: 0, width: 1920, height: 1080 }, + { x: 0, y: 0, width: 1920, height: 1080 }, + () => ({ x: 0, y: 0, width: 3840, height: 2160 }), + ), + ).toBe(sampleBounds); + }); }); diff --git a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts index 5f77ff204..82e8566da 100644 --- a/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts +++ b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts @@ -14,6 +14,15 @@ export interface NormalizedPhysicalPoint { withinBounds: boolean; } +export function resolveWindowsCursorPhysicalBounds( + sampleBounds: PhysicalBounds | null | undefined, + readyBounds: PhysicalBounds | null | undefined, + fallbackDipBounds: PhysicalBounds, + convertDipToPhysical: (bounds: PhysicalBounds) => PhysicalBounds, +): PhysicalBounds { + return sampleBounds ?? readyBounds ?? convertDipToPhysical(fallbackDipBounds); +} + /** * Normalizes one physical screen-pixel point against a physical capture rect. * diff --git a/electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.test.ts b/electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.test.ts new file mode 100644 index 000000000..b44c8a2af --- /dev/null +++ b/electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { buildWindowsCursorSamplerArgs } from "./windowsCursorSamplerArgs"; + +describe("Windows cursor sampler arguments", () => { + it("passes one physical bounds snapshot without the unused Electron display id", () => { + expect( + buildWindowsCursorSamplerArgs(33, null, { + x: -2400, + y: -1350, + width: 2400, + height: 1350, + }), + ).toEqual(["33", "null", "-2400", "-1350", "2400", "1350"]); + }); +}); diff --git a/electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.ts b/electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.ts new file mode 100644 index 000000000..ddcf6fe68 --- /dev/null +++ b/electron/native-bridge/cursor/recording/windowsCursorSamplerArgs.ts @@ -0,0 +1,21 @@ +export interface WindowsCursorSamplerBounds { + x: number; + y: number; + width: number; + height: number; +} + +export function buildWindowsCursorSamplerArgs( + sampleIntervalMs: number, + windowHandle: string | null, + physicalDisplayBounds: WindowsCursorSamplerBounds, +): string[] { + return [ + String(sampleIntervalMs), + windowHandle ?? "null", + String(physicalDisplayBounds.x), + String(physicalDisplayBounds.y), + String(physicalDisplayBounds.width), + String(physicalDisplayBounds.height), + ]; +} diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts index 73de3398d..d3a45269e 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -10,7 +10,11 @@ import type { NativeCursorAsset, } from "../../../../src/native/contracts"; import type { CursorRecordingSession } from "./session"; -import { normalizePhysicalPoint } from "./windowsCursorCoordinates"; +import { + normalizePhysicalPoint, + resolveWindowsCursorPhysicalBounds, +} from "./windowsCursorCoordinates"; +import { buildWindowsCursorSamplerArgs } from "./windowsCursorSamplerArgs"; import type { WindowsCursorEvent, WindowsNativeRecordingSessionOptions, @@ -60,6 +64,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { private sampleCount = 0; private outOfBoundsSampleCount = 0; private previousLeftButtonDown = false; + private helperBounds: Electron.Rectangle | null = null; constructor(private readonly options: WindowsNativeRecordingSessionOptions) {} @@ -71,6 +76,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { this.sampleCount = 0; this.outOfBoundsSampleCount = 0; this.previousLeftButtonDown = false; + this.helperBounds = null; const helperPath = findCursorSamplerPath(); if (!helperPath) { @@ -78,16 +84,12 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { } const windowHandle = parseWindowHandleFromSourceId(this.options.sourceId); - const args = [String(this.options.sampleIntervalMs)]; const dipBounds = this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds; const physicalDisplayBounds = screen.dipToScreenRect(null, dipBounds); - args.push( - windowHandle ?? "null", - String(this.options.displayId ?? 0), - String(physicalDisplayBounds.x), - String(physicalDisplayBounds.y), - String(physicalDisplayBounds.width), - String(physicalDisplayBounds.height), + const args = buildWindowsCursorSamplerArgs( + this.options.sampleIntervalMs, + windowHandle, + physicalDisplayBounds, ); const child = spawn(helperPath, args, { @@ -190,7 +192,11 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { } if (payload.type === "ready") { - this.logDiagnostic("ready", { timestampMs: payload.timestampMs }); + this.helperBounds = payload.bounds ?? null; + this.logDiagnostic("ready", { + timestampMs: payload.timestampMs, + bounds: this.helperBounds, + }); this.resolveReady(); return; } @@ -237,17 +243,13 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { private normalizeSample( payload: Extract, ): NormalizedSample { - const bounds = - payload.bounds ?? this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds; - // The cursor-sampler reports x/y in physical screen pixels via Win32's - // GetPhysicalCursorPos. `payload.bounds` from the native sampler is in the - // same physical space, so use it as-is. Bounds from Electron's `screen` API - // (the fallback path only) are in DIPs, so convert them to physical screen - // coordinates via `dipToScreenRect`, which correctly handles the virtual-screen - // origin across multi-monitor and mixed-DPI setups (a naive - // `bounds.x * scaleFactor` would misplace the origin on non-primary - // displays). - const physicalBounds = payload.bounds != null ? bounds : screen.dipToScreenRect(null, bounds); + const fallbackDipBounds = this.options.getDisplayBounds() ?? screen.getPrimaryDisplay().bounds; + const physicalBounds = resolveWindowsCursorPhysicalBounds( + payload.bounds, + this.helperBounds, + fallbackDipBounds, + (bounds) => screen.dipToScreenRect(null, bounds), + ); const normalized = normalizePhysicalPoint(payload, physicalBounds); const normalizedX = normalized.x; const normalizedY = normalized.y; @@ -271,7 +273,7 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { normalizedY, visible: payload.visible, withinBounds, - bounds, + bounds: physicalBounds, handle: payload.handle, }); } diff --git a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts index 375b7f114..fb36e2334 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.types.ts @@ -24,6 +24,7 @@ export interface WindowsCursorSampleEvent { export interface WindowsCursorReadyEvent { type: "ready"; timestampMs: number; + bounds?: WindowsCursorSampleEvent["bounds"]; } export interface WindowsCursorErrorEvent { @@ -48,7 +49,6 @@ export type WindowsCursorEvent = | WindowsCursorErrorEvent; export interface WindowsNativeRecordingSessionOptions { - displayId?: number | null; getDisplayBounds: () => Rectangle | null; maxSamples: number; sampleIntervalMs: number; diff --git a/electron/native/wgc-capture/src/cursor-sampler.cpp b/electron/native/wgc-capture/src/cursor-sampler.cpp index fe0312e16..ca0e21d01 100644 --- a/electron/native/wgc-capture/src/cursor-sampler.cpp +++ b/electron/native/wgc-capture/src/cursor-sampler.cpp @@ -333,10 +333,24 @@ static bool getPhysicalWindowBounds(HWND window, RECT& bounds) { bounds.right > bounds.left && bounds.bottom > bounds.top; } +static std::string boundsToJson(const RECT& bounds) { + const int width = bounds.right - bounds.left; + const int height = bounds.bottom - bounds.top; + char buffer[128]; + std::snprintf( + buffer, + sizeof(buffer), + "{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}", + bounds.left, + bounds.top, + width, + height); + return buffer; +} + static void runSamplingLoop( int intervalMs, HWND targetWindow, - const std::optional& targetDisplayBounds, const CLSID& pngClsid) { HCURSOR lastCursor = nullptr; @@ -397,7 +411,6 @@ static void runSamplingLoop( lastCursor = hc; } - // Window bounds std::string boundsJson = "null"; if (targetWindow && IsWindow(targetWindow)) { RECT r{}; @@ -405,22 +418,9 @@ static void runSamplingLoop( const int bw = r.right - r.left; const int bh = r.bottom - r.top; if (bw > 0 && bh > 0) { - char buf[128]; - std::snprintf(buf, sizeof(buf), - "{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}", - r.left, r.top, bw, bh); - boundsJson = buf; + boundsJson = boundsToJson(r); } } - } else if (targetDisplayBounds.has_value()) { - const RECT& r = *targetDisplayBounds; - const int bw = r.right - r.left; - const int bh = r.bottom - r.top; - char buf[128]; - std::snprintf(buf, sizeof(buf), - "{\"x\":%ld,\"y\":%ld,\"width\":%d,\"height\":%d}", - r.left, r.top, bw, bh); - boundsJson = buf; } // Emit sample JSON @@ -436,7 +436,9 @@ static void runSamplingLoop( out += ",\"leftButtonDown\":"; out += leftDown ? "true" : "false"; out += ",\"leftButtonPressed\":"; out += leftPressed ? "true" : "false"; out += ",\"leftButtonReleased\":"; out += leftReleased ? "true" : "false"; - out += ",\"bounds\":"; out += boundsJson; + if (targetWindow) { + out += ",\"bounds\":"; out += boundsJson; + } out += ",\"asset\":"; out += assetJson.empty() ? "null" : assetJson; out += "}"; @@ -462,7 +464,7 @@ int main(int argc, char* argv[]) { } if (argc < 2) { - std::cerr << "Usage: cursor-sampler [windowHandle] [displayId displayX displayY displayW displayH]" << std::endl; + std::cerr << "Usage: cursor-sampler [windowHandle] [displayX displayY displayW displayH]" << std::endl; return 1; } @@ -481,15 +483,14 @@ int main(int argc, char* argv[]) { } std::optional targetDisplayBounds; - if (!targetWindow && argc >= 8) { + if (!targetWindow && argc >= 7) { try { - const int64_t displayId = std::stoll(argv[3]); MonitorBounds bounds{}; - bounds.x = std::stoi(argv[4]); - bounds.y = std::stoi(argv[5]); - bounds.width = std::stoi(argv[6]); - bounds.height = std::stoi(argv[7]); - const HMONITOR monitor = findMonitorForCapture(displayId, &bounds); + bounds.x = std::stoi(argv[3]); + bounds.y = std::stoi(argv[4]); + bounds.width = std::stoi(argv[5]); + bounds.height = std::stoi(argv[6]); + const HMONITOR monitor = findMonitorForCapture(0, &bounds); MONITORINFO info{}; info.cbSize = sizeof(info); if (monitor && GetMonitorInfo(monitor, &info)) { @@ -531,19 +532,20 @@ int main(int argc, char* argv[]) { // Signal readiness g_mainThreadId = GetCurrentThreadId(); - { - char buf[80]; - std::snprintf(buf, sizeof(buf), - "{\"type\":\"ready\",\"timestampMs\":%" PRId64 "}", nowMs()); - writeJsonLine(buf); + std::string readyEvent = "{\"type\":\"ready\",\"timestampMs\":"; + readyEvent += std::to_string(nowMs()); + if (targetDisplayBounds.has_value()) { + readyEvent += ",\"bounds\":"; + readyEvent += boundsToJson(*targetDisplayBounds); } + readyEvent += "}"; + writeJsonLine(readyEvent); // Start sampling on a background thread std::thread sampler( runSamplingLoop, intervalMs, targetWindow, - std::cref(targetDisplayBounds), std::cref(pngClsid)); // Run the message pump on the main thread — required for WH_MOUSE_LL callbacks diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index d4f100f6c..858e30bff 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -645,6 +645,61 @@ describe("LaunchWindow HUD dragging", () => { expect(window.electronAPI.setHudOverlaySize).toHaveBeenLastCalledWith(220, 524); }); + it("measures a shifted notice from the compensated center after drag", async () => { + resizeCallbacks.length = 0; + vi.stubGlobal("ResizeObserver", CapturingResizeObserver); + i18nState.value.systemLocaleSuggestion = "zh-CN"; + let innerWidth = 588; + let innerHeight = 95; + vi.spyOn(window, "innerWidth", "get").mockImplementation(() => innerWidth); + vi.spyOn(window, "innerHeight", "get").mockImplementation(() => innerHeight); + const { container } = renderLaunchWindow(); + const prompt = await screen.findByText("Use your system language?"); + const promptPanel = prompt.parentElement as HTMLElement; + const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; + const handle = screen.getByTestId("launch-drag-handle"); + + Object.defineProperties(hudBar, { + scrollWidth: { value: 400, configurable: true }, + scrollHeight: { value: 56, configurable: true }, + }); + Object.defineProperties(promptPanel, { + scrollHeight: { value: 130, configurable: true }, + offsetHeight: { value: 130, configurable: true }, + }); + vi.spyOn(promptPanel, "getBoundingClientRect").mockReturnValue({ + left: 54, + right: 534, + top: 32, + bottom: 162, + width: 480, + height: 130, + x: 54, + y: 32, + toJSON: () => ({}), + }); + Object.defineProperties(handle, { + setPointerCapture: { value: vi.fn(), configurable: true }, + hasPointerCapture: { value: vi.fn(() => true), configurable: true }, + releasePointerCapture: { value: vi.fn(), configurable: true }, + }); + + act(() => { + for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); + }); + expect(window.electronAPI.setHudOverlaySize).toHaveBeenLastCalledWith(504, 186); + vi.mocked(window.electronAPI.setHudOverlaySize).mockClear(); + + fireEvent.pointerDown(handle, { button: 0, pointerId: 16, screenX: 100, screenY: 200 }); + innerWidth = 594; + innerHeight = 99; + fireEvent.resize(window); + await waitFor(() => expect(hudBar.style.left).toBe("calc(50% - 3px)")); + fireEvent.pointerUp(handle, { button: 0, pointerId: 16, screenX: 110, screenY: 210 }); + + expect(window.electronAPI.setHudOverlaySize).not.toHaveBeenCalled(); + }); + it("keeps Electron's native drag region on non-Windows platforms", async () => { window.electronAPI.platform = "darwin"; renderLaunchWindow(); @@ -682,7 +737,7 @@ describe("LaunchWindow HUD dragging", () => { expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(false); }); - it("returns the transparent overlay to click-through outside the HUD bounds", async () => { + it("returns the transparent overlay to click-through at the exclusive HUD edges", async () => { const { container } = renderLaunchWindow(); const root = container.firstElementChild as HTMLElement; const hudBar = container.querySelector("[data-tray-layout]") as HTMLElement; @@ -705,7 +760,13 @@ describe("LaunchWindow HUD dragging", () => { fireEvent.pointerMove(root, { clientX: 110, clientY: 730 }); vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents).mockClear(); - fireEvent.pointerMove(root, { clientX: 50, clientY: 500 }); + fireEvent.pointerMove(root, { clientX: 300, clientY: 730 }); + + expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(true); + + fireEvent.pointerMove(root, { clientX: 110, clientY: 730 }); + vi.mocked(window.electronAPI.setHudOverlayIgnoreMouseEvents).mockClear(); + fireEvent.pointerMove(root, { clientX: 110, clientY: 760 }); expect(window.electronAPI.setHudOverlayIgnoreMouseEvents).toHaveBeenCalledWith(true); }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 672902c2a..78a65e4f4 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -376,6 +376,7 @@ export function LaunchWindow() { const MIN_WIDTH = 220; const centerX = window.innerWidth / 2; + const compensatedCenterX = centerX - hudViewportCompensationRef.current.x; // Use the natural size, not viewport-relative top/bottom coordinates. At fractional // Windows scaling those coordinates can round differently after every move, causing @@ -409,7 +410,11 @@ export function LaunchWindow() { // Its presence in the DOM means it's open. if (languageMenuPanelRef.current) { const rect = languageMenuPanelRef.current.getBoundingClientRect(); - halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + halfWidth = Math.max( + halfWidth, + compensatedCenterX - rect.left, + rect.right - compensatedCenterX, + ); } // Prompts sit at fixed `top-8`; use that CSS constant rather than their rounded @@ -423,7 +428,11 @@ export function LaunchWindow() { if (promptHeight > 0) { contentHeight = Math.max(contentHeight, FIXED_NOTICE_TOP + promptHeight); } - halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + halfWidth = Math.max( + halfWidth, + compensatedCenterX - rect.left, + rect.right - compensatedCenterX, + ); } // The software-encoder fallback notice shares the prompt's fixed top-8 slot and needs @@ -437,7 +446,11 @@ export function LaunchWindow() { if (noticeHeight > 0) { contentHeight = Math.max(contentHeight, FIXED_NOTICE_TOP + noticeHeight); } - halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + halfWidth = Math.max( + halfWidth, + compensatedCenterX - rect.left, + rect.right - compensatedCenterX, + ); } setHudBarHeight((prev) => { @@ -733,9 +746,9 @@ export function LaunchWindow() { hudBarBounds.right > hudBarBounds.left && hudBarBounds.bottom > hudBarBounds.top && event.clientX >= hudBarBounds.left && - event.clientX <= hudBarBounds.right && + event.clientX < hudBarBounds.right && event.clientY >= hudBarBounds.top && - event.clientY <= hudBarBounds.bottom, + event.clientY < hudBarBounds.bottom, ); const shouldCapture = isLanguageMenuOpen ||