diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f954fc99e..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>; @@ -300,8 +301,10 @@ interface Window { hudOverlayHide: () => void; hudOverlayClose: () => void; setHudOverlayIgnoreMouseEvents: (ignore: boolean) => void; - moveHudOverlayBy: (deltaX: number, deltaY: number) => 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/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/hudOverlayDrag.test.ts b/electron/hudOverlayDrag.test.ts new file mode 100644 index 000000000..aa773601a --- /dev/null +++ b/electron/hudOverlayDrag.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + getHudOverlayDragBounds, + 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("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(); + 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..026ff7fc2 --- /dev/null +++ b/electron/hudOverlayDrag.ts @@ -0,0 +1,49 @@ +export type HudOverlayDragPoint = { + x: number; + 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 } + : 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), + }; +} + +/** + * 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/hudOverlayMousePolicy.test.ts b/electron/hudOverlayMousePolicy.test.ts new file mode 100644 index 000000000..491ce5257 --- /dev/null +++ b/electron/hudOverlayMousePolicy.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + isPointInsideHudOverlayBounds, + shouldIgnoreHudOverlayMouseEvents, + supportsHudOverlayHoverClickThrough, +} 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); + }); + + 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 new file mode 100644 index 000000000..aec614523 --- /dev/null +++ b/electron/hudOverlayMousePolicy.ts @@ -0,0 +1,51 @@ +export type HudOverlayPoint = { + x: number; + y: number; +}; + +export type HudOverlayBounds = HudOverlayPoint & { + width: number; + 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, +): 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/ipc/handlers.ts b/electron/ipc/handlers.ts index 852250ec4..39fca8b54 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,19 +796,23 @@ 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, { getDisplayBounds: getSelectedSourceBounds, + sourceId: getSelectedSourceId(), + }); + cursorRecordingSession = createCursorRecordingSession({ + 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, }); @@ -1631,6 +1639,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 +1673,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 +1719,7 @@ export function registerIpcHandlers( encoder: { preferSoftwareEncoder }, cursor: { mode: cursorCaptureMode }, bounds, + physicalDisplayBounds, sourceId: selectedSource?.id ?? null, usedDisplayMatch: Boolean(sourceDisplay), outputPath, @@ -1725,7 +1740,10 @@ export function registerIpcHandlers( const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { nativeWindowsCursorRecordingStartMs = cursorStartTimeMs; - await startCursorRecording(cursorStartTimeMs); + await startCursorRecording(cursorStartTimeMs, { + 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..bb5cb94d9 --- /dev/null +++ b/electron/native-bridge/cursor/recording/cursorRecordingTarget.test.ts @@ -0,0 +1,38 @@ +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 = { + getDisplayBounds: selectedBounds, + sourceId: "screen:1:0", + }; + const requested = { + getDisplayBounds: requestedBounds, + sourceId: "window:424242:0", + }; + + const resolved = resolveCursorRecordingTarget(requested, selected); + + expect(resolved).toBe(requested); + 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 = { + 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..1aad3279e --- /dev/null +++ b/electron/native-bridge/cursor/recording/cursorRecordingTarget.ts @@ -0,0 +1,18 @@ +import type { Rectangle } from "electron"; + +export interface CursorRecordingTarget { + 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/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts new file mode 100644 index 000000000..b34d65b29 --- /dev/null +++ b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { + normalizePhysicalPoint, + resolveWindowsCursorPhysicalBounds, +} from "./windowsCursorCoordinates"; + +describe("normalizePhysicalPoint", () => { + it.each([ + 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 = { + 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, + }; + + 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", () => { + 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 }); + }); + + 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 new file mode 100644 index 000000000..82e8566da --- /dev/null +++ b/electron/native-bridge/cursor/recording/windowsCursorCoordinates.ts @@ -0,0 +1,46 @@ +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; +} + +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. + * + * 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/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 c7a057fea..d3a45269e 100644 --- a/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/windowsNativeRecordingSession.ts @@ -10,6 +10,11 @@ import type { NativeCursorAsset, } from "../../../../src/native/contracts"; import type { CursorRecordingSession } from "./session"; +import { + normalizePhysicalPoint, + resolveWindowsCursorPhysicalBounds, +} from "./windowsCursorCoordinates"; +import { buildWindowsCursorSamplerArgs } from "./windowsCursorSamplerArgs"; import type { WindowsCursorEvent, WindowsNativeRecordingSessionOptions, @@ -59,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) {} @@ -70,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) { @@ -77,8 +84,13 @@ 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); + const args = buildWindowsCursorSamplerArgs( + this.options.sampleIntervalMs, + windowHandle, + physicalDisplayBounds, + ); const child = spawn(helperPath, args, { stdio: ["ignore", "pipe", "pipe"], @@ -180,13 +192,20 @@ 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; } 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", @@ -224,25 +243,17 @@ export class WindowsNativeRecordingSession implements CursorRecordingSession { private normalizeSample( payload: Extract, ): 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 - // 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 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; + const withinBounds = normalized.withinBounds; const leftButtonDown = payload.leftButtonDown === true; const leftButtonPressed = payload.leftButtonPressed === true; const leftButtonReleased = payload.leftButtonReleased === true; @@ -262,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 f3b69da0f..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 { 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/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..ca0e21d01 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,42 @@ 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 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 CLSID& pngClsid) +{ HCURSOR lastCursor = nullptr; while (!g_stop.load(std::memory_order_relaxed)) { @@ -331,6 +371,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; @@ -360,19 +411,14 @@ static void runSamplingLoop(int intervalMs, HWND targetWindow, const CLSID& pngC lastCursor = hc; } - // Window bounds 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) { - 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); } } } @@ -382,15 +428,17 @@ 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"; 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 += "}"; @@ -410,8 +458,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] [displayX displayY displayW displayH]" << std::endl; return 1; } @@ -429,6 +482,28 @@ int main(int argc, char* argv[]) { } } + std::optional targetDisplayBounds; + if (!targetWindow && argc >= 7) { + try { + MonitorBounds 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)) { + 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; @@ -457,15 +532,21 @@ 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(pngClsid)); + std::thread sampler( + runSamplingLoop, + intervalMs, + targetWindow, + 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 #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/preload.ts b/electron/preload.ts index f02a2ccd2..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; @@ -26,12 +27,18 @@ 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); - }, 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 60dbc9df4..ccb32a92d 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,6 +1,17 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { BrowserWindow, ipcMain, screen } from "electron"; +import { getHudOverlayResizedBounds } from "./hudOverlayBounds"; +import { + getHudOverlayDragBounds, + type HudOverlayDragBounds, + type HudOverlayDragPoint, + parseHudOverlayDragPoint, +} from "./hudOverlayDrag"; +import { + shouldIgnoreHudOverlayMouseEvents, + supportsHudOverlayHoverClickThrough, +} from "./hudOverlayMousePolicy"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -17,6 +28,111 @@ 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; +let hudOverlayDragTimeout: NodeJS.Timeout | null = null; +const HUD_OVERLAY_DRAG_INACTIVITY_TIMEOUT_MS = 30_000; +let hudOverlayDrag: + | { + windowId: number; + webContentsId: number; + startWindow: HudOverlayDragBounds; + 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 === effectiveIgnore + ) { + return; + } + + hudOverlayMouseEventsIgnored = effectiveIgnore; + hudOverlayWindow.setIgnoreMouseEvents( + effectiveIgnore, + effectiveIgnore ? { 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(); + 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 armHudOverlayDragTimeout(): void { + if (hudOverlayDragTimeout) { + clearTimeout(hudOverlayDragTimeout); + } + hudOverlayDragTimeout = setTimeout(stopHudOverlayDrag, HUD_OVERLAY_DRAG_INACTIVITY_TIMEOUT_MS); + hudOverlayDragTimeout.unref(); +} + +function updateHudOverlayDragPosition(currentCursor: HudOverlayDragPoint): void { + if ( + !hudOverlayDrag || + !hudOverlayWindow || + hudOverlayWindow.isDestroyed() || + hudOverlayWindow.id !== hudOverlayDrag.windowId + ) { + stopHudOverlayDrag(); + return; + } + + const nextBounds = getHudOverlayDragBounds( + hudOverlayDrag.startWindow, + hudOverlayDrag.startCursor, + currentCursor, + ); + hudOverlayWindow.setBounds(nextBounds); +} ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { @@ -25,23 +141,60 @@ 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(); +}); + +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, + startWindow: bounds, + startCursor: parseHudOverlayDragPoint(screenX, screenY) ?? screen.getCursorScreenPoint(), + }; + // A lost pointer-up must not leave an inactive drag session alive indefinitely. + armHudOverlayDragTimeout(); }); -ipcMain.on("hud-overlay-move-by", (_event, deltaX: number, deltaY: number) => { +ipcMain.on("hud-overlay-drag-move", (event, screenX: unknown, screenY: unknown) => { if ( + process.platform !== "win32" || + !hudOverlayDrag || !hudOverlayWindow || hudOverlayWindow.isDestroyed() || - !Number.isFinite(deltaX) || - !Number.isFinite(deltaY) + hudOverlayWindow.id !== hudOverlayDrag.windowId || + event.sender.id !== hudOverlayDrag.webContentsId ) { return; } - const [x, y] = hudOverlayWindow.getPosition(); - hudOverlayWindow.setPosition(Math.round(x + deltaX), Math.round(y + deltaY), false); + const currentCursor = parseHudOverlayDragPoint(screenX, screenY); + if (!currentCursor) return; + updateHudOverlayDragPosition(currentCursor); + armHudOverlayDragTimeout(); +}); + +ipcMain.on("hud-overlay-drag-end", (event, screenX: unknown, screenY: unknown) => { + if (hudOverlayDrag?.webContentsId === event.sender.id) { + // 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(); + } }); // Resize the HUD to fit its rendered content. Anchored by its bottom-centre so it @@ -62,22 +215,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); }); /** @@ -112,6 +261,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, @@ -124,7 +274,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. @@ -142,11 +291,19 @@ 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(); + stopHudOverlayDrag(); hudOverlayWindow = null; + hudOverlayMouseEventsIgnored = undefined; } }); 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.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 154feef73..858e30bff 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,7 +179,9 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo getPlatform: vi.fn(async () => "darwin"), setHudOverlaySize: vi.fn(), setHudOverlayIgnoreMouseEvents: vi.fn(), - moveHudOverlayBy: vi.fn(), + beginHudOverlayDrag: vi.fn(), + updateHudOverlayDrag: vi.fn(), + endHudOverlayDrag: vi.fn(), hudOverlayHide: vi.fn(), hudOverlayClose: vi.fn(), switchToEditor: vi.fn(async () => undefined), @@ -378,6 +381,433 @@ describe("LaunchWindow record button", () => { }); }); +describe("LaunchWindow HUD dragging", () => { + beforeEach(() => { + platformState.value = "win32"; + resetLaunchMocks(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("uses anchored pointer dragging on the Windows HUD handle", async () => { + const { container } = renderLaunchWindow(); + + const handle = screen.getByTestId("launch-drag-handle"); + 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("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; + 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("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("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("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(); + + 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 () => { + 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 at the exclusive HUD edges", 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: 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); + }); + + 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", () => { beforeEach(() => { platformState.value = "darwin"; @@ -425,6 +855,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 1136d8537..78a65e4f4 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); @@ -317,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. @@ -324,23 +361,30 @@ 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 // 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; + const compensatedCenterX = centerX - hudViewportCompensationRef.current.x; - // 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 +395,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); } } @@ -362,43 +410,65 @@ 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, + ); } - // 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); + 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 // 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); + halfWidth = Math.max( + halfWidth, + compensatedCenterX - rect.left, + rect.right - compensatedCenterX, + ); } 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; } + // 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. @@ -474,6 +544,76 @@ export function LaunchWindow() { setHudMouseEventsEnabled(isLanguageMenuOpen); }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); + 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); + }, []); + + 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", handleWindowsHudViewportResize); + return () => window.removeEventListener("resize", handleWindowsHudViewportResize); + }, [handleWindowsHudViewportResize, isWindowsHud]); + + const endWindowsHudDrag = useCallback( + (pointerId: number, target?: HTMLDivElement, screenX?: number, screenY?: number) => { + if (hudDragPointerIdRef.current !== pointerId) return; + updateHudDragViewportCompensation(); + hudDragPointerIdRef.current = null; + if (target?.hasPointerCapture?.(pointerId)) { + target.releasePointerCapture(pointerId); + } + window.electronAPI?.endHudOverlayDrag?.(screenX, screenY); + scheduleHudDragViewportSettle(); + measureHudSize(); + }, + [measureHudSize, scheduleHudDragViewportSettle, updateHudDragViewportCompensation], + ); + + useEffect( + () => () => { + cancelHudDragViewportSettle(); + if (hudDragPointerIdRef.current !== null) { + window.electronAPI?.endHudOverlayDrag?.(); + } + hudDragPointerIdRef.current = null; + hudDragViewportAnchorRef.current = undefined; + }, + [cancelHudDragViewportSettle], + ); + const defaultSourceName = t("sourceSelector.defaultSourceName"); const [selectedSource, setSelectedSource] = useState(defaultSourceName); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -579,6 +719,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 }); }; @@ -588,79 +729,31 @@ export function LaunchWindow() { setMicrophoneEnabled(!microphoneEnabled); } }; - const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); - 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); - }, []); - 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(() => { - 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); - } - }; - }, []); - const handleHudDragPointerDown = (event: React.PointerEvent) => { - event.preventDefault(); - event.stopPropagation(); - setHudMouseEventsEnabled(true); - event.currentTarget.setPointerCapture(event.pointerId); - dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; - }; - 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 handleHudDragPointerEnd = (event: React.PointerEvent) => { - dragLastPositionRef.current = null; - flushPendingHudDragMove(); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - setHudMouseEventsEnabled(false); - }; - 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).
{ 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={() => { @@ -671,7 +764,10 @@ export function LaunchWindow() { > {/* Top-center notices share one fixed column so they stack instead of overlapping */} {(systemLocaleSuggestion || softwareEncoderFallbackNoticeVisible) && ( -
+
{systemLocaleSuggestion && (
{/* Mic selector */} {showMicControls && ( @@ -905,6 +1003,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)} @@ -914,13 +1016,36 @@ 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(); + updateHudDragViewportCompensation(); + cancelHudDragViewportSettle(); + 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)} + onLostPointerCapture={(event) => endWindowsHudDrag(event.pointerId)} > {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..1826a1b7f --- /dev/null +++ b/src/components/launch/hudViewportCompensation.test.ts @@ -0,0 +1,44 @@ +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 }); + }); + + 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 new file mode 100644 index 000000000..da6382258 --- /dev/null +++ b/src/components/launch/hudViewportCompensation.ts @@ -0,0 +1,37 @@ +export type HudViewportSize = { + width: number; + height: number; +}; + +export type HudViewportCompensation = { + x: number; + 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. + */ +export function getHudViewportCompensation( + startCompensation: HudViewportCompensation, + 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 + 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; };