From 44756e022da9162e6434f5b6771246a7754b7f2d Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 11 Aug 2026 16:14:26 +0200 Subject: [PATCH 01/12] fix(linux): sync Hyprland cursor telemetry --- electron/electron-env.d.ts | 6 +- electron/gpuSwitches.ts | 36 +---- electron/ipc/cursor/hyprland.test.ts | 190 ++++++++++++++++++++++++ electron/ipc/cursor/hyprland.ts | 191 +++++++++++++++++++++++++ electron/ipc/cursor/interaction.ts | 10 +- electron/ipc/cursor/telemetry.ts | 3 +- electron/ipc/register/recording.ts | 34 ++++- electron/ipc/register/sourceMapping.ts | 14 +- electron/ipc/state.ts | 11 +- electron/linuxWindowSystem.test.ts | 32 +++++ electron/linuxWindowSystem.ts | 36 +++++ electron/preload.ts | 10 +- src/hooks/useScreenRecorder.test.ts | 56 ++++++++ src/hooks/useScreenRecorder.ts | 63 +++++++- 14 files changed, 631 insertions(+), 61 deletions(-) create mode 100644 electron/ipc/cursor/hyprland.test.ts create mode 100644 electron/ipc/cursor/hyprland.ts create mode 100644 electron/linuxWindowSystem.test.ts create mode 100644 electron/linuxWindowSystem.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..ad9e97306 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -566,7 +566,10 @@ interface Window { startDelayMsByPath?: Record; error?: string; }>; - setRecordingState: (recording: boolean) => Promise; + setRecordingState: ( + recording: boolean, + options?: { mediaTimelineStartedAtEpochMs?: number }, + ) => Promise<{ cursorOverlayAvailable: boolean }>; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; @@ -839,7 +842,6 @@ interface Window { onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; getPlatform: () => Promise; - getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>; revealInFolder: ( filePath: string, ) => Promise<{ success: boolean; error?: string; message?: string }>; diff --git a/electron/gpuSwitches.ts b/electron/gpuSwitches.ts index 7b7c81ee8..570815d3f 100644 --- a/electron/gpuSwitches.ts +++ b/electron/gpuSwitches.ts @@ -1,43 +1,13 @@ +import { resolveLinuxWindowSystem } from "./linuxWindowSystem"; + export interface GpuSwitches { useAngle?: string; useGl?: string; disableFeatures?: string[]; } -function normalizeLinuxWindowSystem(value: string | undefined): "wayland" | "x11" | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "wayland" || normalized === "x11") { - return normalized; - } - - return null; -} - -function getForcedLinuxWindowSystem(env: NodeJS.ProcessEnv): "wayland" | "x11" | null { - return ( - normalizeLinuxWindowSystem(env.OZONE_PLATFORM) ?? - normalizeLinuxWindowSystem(env.ELECTRON_OZONE_PLATFORM_HINT) - ); -} - export function shouldForceLinuxEgl(env: NodeJS.ProcessEnv): boolean { - const forcedWindowSystem = getForcedLinuxWindowSystem(env); - if (forcedWindowSystem === "wayland") { - return false; - } - if (forcedWindowSystem === "x11") { - return true; - } - - const sessionType = env.XDG_SESSION_TYPE?.toLowerCase(); - if (sessionType === "wayland") { - return false; - } - if (sessionType === "x11") { - return true; - } - - return !env.WAYLAND_DISPLAY; + return resolveLinuxWindowSystem("linux", env) !== "wayland"; } export function getGpuSwitches( diff --git a/electron/ipc/cursor/hyprland.test.ts b/electron/ipc/cursor/hyprland.test.ts new file mode 100644 index 000000000..da56cba1e --- /dev/null +++ b/electron/ipc/cursor/hyprland.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + }, +})); + +import { activeCursorSamples, linuxCursorScreenPoint, setActiveCursorSamples } from "../state"; +import { + getHyprlandRequestSocketPath, + isHyprlandCursorProviderActive, + parseHyprlandCursorPosition, + resolveHyprlandCursorCaptureEpochMs, + startHyprlandCursorProvider, + stopHyprlandCursorProvider, +} from "./hyprland"; + +const waylandEnv = { + XDG_RUNTIME_DIR: "/run/user/1000", + XDG_SESSION_TYPE: "wayland", + WAYLAND_DISPLAY: "wayland-1", + HYPRLAND_INSTANCE_SIGNATURE: "abc123_456", +}; + +describe("Hyprland cursor provider", () => { + beforeEach(() => { + stopHyprlandCursorProvider(); + vi.useRealTimers(); + }); + + afterEach(() => { + stopHyprlandCursorProvider(); + vi.useRealTimers(); + }); + + it("resolves the Hyprland request socket on native Wayland", async () => { + expect(getHyprlandRequestSocketPath(waylandEnv, "linux")).toBe( + "/run/user/1000/hypr/abc123_456/.socket.sock", + ); + }); + + it("does not start until the cursor socket returns an initial point", async () => { + await expect( + startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query: vi.fn().mockResolvedValue(null), + }), + ).resolves.toBe(false); + expect(isHyprlandCursorProviderActive()).toBe(false); + }); + + it("does not activate for X11 or unsafe instance signatures", () => { + expect( + getHyprlandRequestSocketPath({ ...waylandEnv, OZONE_PLATFORM: "x11" }, "linux"), + ).toBeNull(); + expect( + getHyprlandRequestSocketPath( + { ...waylandEnv, OZONE_PLATFORM: "auto", ELECTRON_OZONE_PLATFORM_HINT: "x11" }, + "linux", + ), + ).toBeNull(); + expect( + getHyprlandRequestSocketPath( + { ...waylandEnv, HYPRLAND_INSTANCE_SIGNATURE: "../../other" }, + "linux", + ), + ).toBeNull(); + expect(getHyprlandRequestSocketPath(waylandEnv, "darwin")).toBeNull(); + }); + + it("parses finite logical cursor coordinates", () => { + expect(parseHyprlandCursorPosition('{"x":-120.5,"y":480}')).toEqual({ + x: -120.5, + y: 480, + }); + expect(parseHyprlandCursorPosition('{"x":"12","y":4}')).toBeNull(); + expect(parseHyprlandCursorPosition("not json")).toBeNull(); + }); + + it("applies the measured Hyprland media timeline correction", () => { + expect(resolveHyprlandCursorCaptureEpochMs(10_000)).toBe(9_700); + }); + + it("polls serially and stops without publishing a late response", async () => { + vi.useFakeTimers(); + let resolveQuery!: (point: { x: number; y: number }) => void; + const query = vi.fn( + () => + new Promise<{ x: number; y: number }>((resolve) => { + resolveQuery = resolve; + }), + ); + const onPoint = vi.fn(); + + const started = startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query, + onPoint, + pollIntervalMs: 10, + }); + expect(query).toHaveBeenCalledOnce(); + expect(isHyprlandCursorProviderActive()).toBe(false); + + stopHyprlandCursorProvider(); + expect(isHyprlandCursorProviderActive()).toBe(false); + resolveQuery({ x: 10, y: 20 }); + await vi.runAllTimersAsync(); + + await expect(started).resolves.toBe(false); + expect(onPoint).not.toHaveBeenCalled(); + expect(query).toHaveBeenCalledOnce(); + }); + + it("publishes the initial compositor response before reporting success", async () => { + const onPoint = vi.fn(); + + await expect( + startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query: vi.fn().mockResolvedValue({ x: 12, y: 34 }), + onPoint, + pollIntervalMs: 60_000, + }), + ).resolves.toBe(true); + + expect(onPoint).toHaveBeenCalledWith({ x: 12, y: 34 }); + expect(isHyprlandCursorProviderActive()).toBe(true); + }); + + it("only refreshes provider state and clears it when polling fails", async () => { + vi.useFakeTimers(); + setActiveCursorSamples([]); + const query = vi.fn().mockResolvedValueOnce({ x: 12, y: 34 }).mockResolvedValueOnce(null); + + await startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query, + pollIntervalMs: 10, + }); + + expect(linuxCursorScreenPoint).toMatchObject({ + x: 12, + y: 34, + coordinateSpace: "logical", + source: "hyprland", + }); + expect(activeCursorSamples).toEqual([]); + + await vi.advanceTimersByTimeAsync(10); + expect(linuxCursorScreenPoint).toBeNull(); + expect(isHyprlandCursorProviderActive()).toBe(false); + }); + + it("keeps a successful provider healthy while the next query is pending", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + let resolvePendingQuery!: (point: { x: number; y: number } | null) => void; + const query = vi + .fn() + .mockResolvedValueOnce({ x: 12, y: 34 }) + .mockImplementationOnce( + () => + new Promise<{ x: number; y: number } | null>((resolve) => { + resolvePendingQuery = resolve; + }), + ); + + await startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query, + pollIntervalMs: 33, + }); + expect(isHyprlandCursorProviderActive()).toBe(true); + + await vi.advanceTimersByTimeAsync(33); + expect(query).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(167); + expect(isHyprlandCursorProviderActive()).toBe(true); + + resolvePendingQuery(null); + await vi.advanceTimersByTimeAsync(0); + expect(isHyprlandCursorProviderActive()).toBe(false); + }); +}); diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts new file mode 100644 index 000000000..d56eb629d --- /dev/null +++ b/electron/ipc/cursor/hyprland.ts @@ -0,0 +1,191 @@ +import net from "node:net"; +import path from "node:path"; +import { resolveLinuxWindowSystem } from "../../linuxWindowSystem"; +import { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; +import { linuxCursorScreenPoint, setLinuxCursorScreenPoint } from "../state"; + +const MAX_RESPONSE_BYTES = 4096; +const REQUEST_TIMEOUT_MS = 250; +const PROVIDER_FRESHNESS_INTERVALS = 3; +// Calibration against Hyprland portal recordings showed cursor telemetry 300 ms early. +export const HYPRLAND_CURSOR_MEDIA_OFFSET_MS = 300; + +type CursorPoint = { x: number; y: number }; +type QueryCursorPoint = (socketPath: string) => Promise; + +let pollTimer: NodeJS.Timeout | null = null; +let pollGeneration = 0; +let providerHealthyUntilMs = 0; + +export function resolveHyprlandCursorCaptureEpochMs(mediaTimelineStartedAtEpochMs: number) { + return Math.max(0, mediaTimelineStartedAtEpochMs - HYPRLAND_CURSOR_MEDIA_OFFSET_MS); +} + +export function getHyprlandRequestSocketPath( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform | string = process.platform, +) { + if (resolveLinuxWindowSystem(platform, env) !== "wayland") { + return null; + } + + const runtimeDir = env.XDG_RUNTIME_DIR?.trim(); + const instanceSignature = env.HYPRLAND_INSTANCE_SIGNATURE?.trim(); + if ( + !runtimeDir || + !path.isAbsolute(runtimeDir) || + !instanceSignature || + !/^[A-Za-z0-9_.-]+$/.test(instanceSignature) + ) { + return null; + } + + return path.join(runtimeDir, "hypr", instanceSignature, ".socket.sock"); +} + +export function parseHyprlandCursorPosition(response: string): CursorPoint | null { + try { + const parsed = JSON.parse(response) as { x?: unknown; y?: unknown }; + if ( + typeof parsed.x !== "number" || + !Number.isFinite(parsed.x) || + typeof parsed.y !== "number" || + !Number.isFinite(parsed.y) + ) { + return null; + } + + return { x: parsed.x, y: parsed.y }; + } catch { + return null; + } +} + +export function queryHyprlandCursorPosition(socketPath: string): Promise { + return new Promise((resolve) => { + let output = ""; + let settled = false; + const socket = net.createConnection(socketPath); + + const finish = (point: CursorPoint | null) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(point); + }; + + socket.setEncoding("utf8"); + socket.setTimeout(REQUEST_TIMEOUT_MS, () => finish(null)); + socket.once("connect", () => socket.end("j/cursorpos")); + socket.on("data", (chunk: string) => { + output += chunk; + if (Buffer.byteLength(output) > MAX_RESPONSE_BYTES) { + finish(null); + } + }); + socket.once("end", () => finish(parseHyprlandCursorPosition(output))); + socket.once("error", () => finish(null)); + socket.once("close", () => finish(null)); + }); +} + +function clearHyprlandCursorPoint() { + if (linuxCursorScreenPoint?.source === "hyprland") { + setLinuxCursorScreenPoint(null); + } +} + +export function stopHyprlandCursorProvider() { + pollGeneration += 1; + providerHealthyUntilMs = 0; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + clearHyprlandCursorPoint(); +} + +export async function startHyprlandCursorProvider(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform | string; + pollIntervalMs?: number; + query?: QueryCursorPoint; + onPoint?: (point: CursorPoint) => void; +}) { + stopHyprlandCursorProvider(); + + const socketPath = getHyprlandRequestSocketPath( + options?.env ?? process.env, + options?.platform ?? process.platform, + ); + if (!socketPath) { + return false; + } + + const generation = pollGeneration; + const query = options?.query ?? queryHyprlandCursorPosition; + const pollIntervalMs = options?.pollIntervalMs ?? CURSOR_SAMPLE_INTERVAL_MS; + const onPoint = + options?.onPoint ?? + ((point: CursorPoint) => { + setLinuxCursorScreenPoint({ + ...point, + updatedAt: Date.now(), + coordinateSpace: "logical", + source: "hyprland", + }); + }); + const markHealthy = () => { + providerHealthyUntilMs = + Date.now() + + Math.max( + REQUEST_TIMEOUT_MS + pollIntervalMs, + pollIntervalMs * PROVIDER_FRESHNESS_INTERVALS, + ); + }; + const queryPoint = async () => { + try { + return await query(socketPath); + } catch { + return null; + } + }; + + const initialPoint = await queryPoint(); + if (generation !== pollGeneration || !initialPoint) { + return false; + } + markHealthy(); + onPoint(initialPoint); + + let nextPollAtMs = performance.now() + pollIntervalMs; + const poll = async () => { + const pollStartedAtMs = performance.now(); + const point = await queryPoint(); + if (generation !== pollGeneration) { + return; + } + + if (point) { + markHealthy(); + onPoint(point); + } else { + providerHealthyUntilMs = 0; + clearHyprlandCursorPoint(); + } + + nextPollAtMs += pollIntervalMs; + const nowMs = performance.now(); + if (nextPollAtMs <= pollStartedAtMs || nextPollAtMs < nowMs - pollIntervalMs) { + nextPollAtMs = nowMs + pollIntervalMs; + } + pollTimer = setTimeout(poll, Math.max(1, nextPollAtMs - nowMs)); + }; + + pollTimer = setTimeout(poll, pollIntervalMs); + return true; +} + +export function isHyprlandCursorProviderActive() { + return providerHealthyUntilMs > Date.now(); +} diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 9b6f3ac92..79e708e9f 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -12,6 +12,7 @@ import { setLastLeftClick, setLinuxCursorScreenPoint, } from "../state"; +import { isHyprlandCursorProviderActive } from "./hyprland"; import { getNormalizedCursorPoint, getCursorCaptureElapsedMs, @@ -257,6 +258,7 @@ export async function startInteractionCapture() { const onMouseMove = (event: HookMouseEvent) => { if ( process.platform !== "linux" || + isHyprlandCursorProviderActive() || !isCursorCaptureActive || isCursorCapturePaused() ) { @@ -268,7 +270,13 @@ export async function startInteractionCapture() { return; } - setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() }); + setLinuxCursorScreenPoint({ + x: point.x, + y: point.y, + updatedAt: Date.now(), + coordinateSpace: "physical", + source: "uiohook", + }); }; hook.on("mousedown", onMouseDown); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index ebedfe72a..d66bebe50 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -178,8 +178,9 @@ export function getNormalizedCursorPoint() { const primarySf = process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1; + const linuxCursorScale = linuxCursorCache?.coordinateSpace === "logical" ? 1 : primarySf; const cursor = isLinuxCacheFresh - ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } + ? { x: linuxCursorCache.x / linuxCursorScale, y: linuxCursorCache.y / linuxCursorScale } : fallbackCursor; const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 06a33f84f..efbcfe88e 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -16,6 +16,11 @@ import { showCursor } from "../../cursorHider"; import { getMonitorHandles } from "../monitorResolver"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds"; +import { + resolveHyprlandCursorCaptureEpochMs, + startHyprlandCursorProvider, + stopHyprlandCursorProvider, +} from "../cursor/hyprland"; import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; import { @@ -394,6 +399,8 @@ async function resolveExistingPath(...candidates: Array void, ) { + let cursorCaptureGeneration = 0; + ipcMain.handle( "start-native-screen-recording", async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { @@ -1811,7 +1818,9 @@ export function registerRecordingHandlers( } }); - ipcMain.handle("set-recording-state", (_, recording: boolean) => { + ipcMain.handle("set-recording-state", async (_, recording: boolean, options?: unknown) => { + const captureGeneration = ++cursorCaptureGeneration; + let cursorOverlayAvailable = false; if (recording) { stopCursorCapture(); stopInteractionCapture(); @@ -1820,10 +1829,28 @@ export function registerRecordingHandlers( setIsCursorCaptureActive(true); setActiveCursorSamples([]); setPendingCursorSamples([]); - setCursorCaptureStartTimeMs(Date.now()); resetCursorCaptureClock(); setLinuxCursorScreenPoint(null); setLastLeftClick(null); + const hyprlandCursorProviderStarted = await startHyprlandCursorProvider(); + if (captureGeneration !== cursorCaptureGeneration) { + return { cursorOverlayAvailable: false }; + } + + cursorOverlayAvailable = hyprlandCursorProviderStarted; + const mediaTimelineStartedAtEpochMs = isRecord(options) + ? options.mediaTimelineStartedAtEpochMs + : undefined; + const captureStartedAtMs = normalizeRendererTimestampMs( + mediaTimelineStartedAtEpochMs, + ); + setCursorCaptureStartTimeMs( + hyprlandCursorProviderStarted && + typeof mediaTimelineStartedAtEpochMs === "number" && + Number.isFinite(mediaTimelineStartedAtEpochMs) + ? resolveHyprlandCursorCaptureEpochMs(captureStartedAtMs) + : captureStartedAtMs, + ); sampleCursorPoint(); startCursorSampling(); void startInteractionCapture(); @@ -1831,6 +1858,7 @@ export function registerRecordingHandlers( setIsCursorCaptureActive(false); stopCursorCapture(); stopInteractionCapture(); + stopHyprlandCursorProvider(); stopWindowBoundsCapture(); stopNativeCursorMonitor(); showCursor(); @@ -1853,6 +1881,8 @@ export function registerRecordingHandlers( if (onRecordingStateChange) { onRecordingStateChange(recording, source.name); } + + return { cursorOverlayAvailable }; }); ipcMain.handle("pause-cursor-capture", (_, pausedAtMs?: unknown) => { diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index a61b4cf72..c6a649ff9 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -1,15 +1,9 @@ +import { resolveLinuxWindowSystem } from "../../linuxWindowSystem"; + export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal"; export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) { - const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase(); - if (sessionType === "wayland") { - return true; - } - if (sessionType === "x11") { - return false; - } - - return Boolean(env.WAYLAND_DISPLAY); + return resolveLinuxWindowSystem("linux", env) === "wayland"; } export function getScreenSourceIdForDisplay({ @@ -32,4 +26,4 @@ export function getScreenSourceIdForDisplay({ } return `screen:fallback:${displayId}`; -} \ No newline at end of file +} diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts index a0a41744e..2cffce248 100644 --- a/electron/ipc/state.ts +++ b/electron/ipc/state.ts @@ -84,7 +84,14 @@ export let isCursorCaptureActive = false; export let interactionCaptureCleanup: (() => void) | null = null; export let hasLoggedInteractionHookFailure = false; export let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null; -export let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null; +export interface LinuxCursorScreenPoint { + x: number; + y: number; + updatedAt: number; + coordinateSpace: "logical" | "physical"; + source: "hyprland" | "uiohook"; +} +export let linuxCursorScreenPoint: LinuxCursorScreenPoint | null = null; export let selectedWindowBounds: WindowBounds | null = null; export let windowBoundsCaptureInterval: NodeJS.Timeout | null = null; @@ -263,7 +270,7 @@ export function setHasLoggedInteractionHookFailure(v: boolean) { export function setLastLeftClick(v: { timeMs: number; cx: number; cy: number } | null) { lastLeftClick = v; } -export function setLinuxCursorScreenPoint(v: { x: number; y: number; updatedAt: number } | null) { +export function setLinuxCursorScreenPoint(v: LinuxCursorScreenPoint | null) { linuxCursorScreenPoint = v; } export function setSelectedWindowBounds(v: WindowBounds | null) { diff --git a/electron/linuxWindowSystem.test.ts b/electron/linuxWindowSystem.test.ts new file mode 100644 index 000000000..5e5854a9c --- /dev/null +++ b/electron/linuxWindowSystem.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveLinuxWindowSystem } from "./linuxWindowSystem"; + +describe("resolveLinuxWindowSystem", () => { + it("uses validated Ozone settings before session environment fallbacks", () => { + expect( + resolveLinuxWindowSystem("linux", { + OZONE_PLATFORM: "auto", + ELECTRON_OZONE_PLATFORM_HINT: "x11", + XDG_SESSION_TYPE: "wayland", + }), + ).toBe("x11"); + }); + + it("uses the explicit session type before display variables", () => { + expect( + resolveLinuxWindowSystem("linux", { + XDG_SESSION_TYPE: "x11", + WAYLAND_DISPLAY: "wayland-0", + }), + ).toBe("x11"); + }); + + it("falls back to the available display variable", () => { + expect(resolveLinuxWindowSystem("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe("wayland"); + expect(resolveLinuxWindowSystem("linux", { DISPLAY: ":0" })).toBe("x11"); + }); + + it("returns null outside Linux", () => { + expect(resolveLinuxWindowSystem("darwin", { XDG_SESSION_TYPE: "wayland" })).toBeNull(); + }); +}); diff --git a/electron/linuxWindowSystem.ts b/electron/linuxWindowSystem.ts new file mode 100644 index 000000000..68f4126a1 --- /dev/null +++ b/electron/linuxWindowSystem.ts @@ -0,0 +1,36 @@ +export type LinuxWindowSystem = "wayland" | "x11" | null; + +function normalizeLinuxWindowSystem(value: string | undefined): LinuxWindowSystem { + const normalized = value?.trim().toLowerCase(); + return normalized === "wayland" || normalized === "x11" ? normalized : null; +} + +export function resolveLinuxWindowSystem( + platform: NodeJS.Platform | string, + env: NodeJS.ProcessEnv = process.env, +): LinuxWindowSystem { + if (platform !== "linux") { + return null; + } + + const configuredWindowSystem = + normalizeLinuxWindowSystem(env.OZONE_PLATFORM) ?? + normalizeLinuxWindowSystem(env.ELECTRON_OZONE_PLATFORM_HINT); + if (configuredWindowSystem) { + return configuredWindowSystem; + } + + const sessionWindowSystem = normalizeLinuxWindowSystem(env.XDG_SESSION_TYPE); + if (sessionWindowSystem) { + return sessionWindowSystem; + } + + if (env.WAYLAND_DISPLAY) { + return "wayland"; + } + if (env.DISPLAY) { + return "x11"; + } + + return null; +} diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..afcff9a80 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -575,8 +575,11 @@ contextBridge.exposeInMainWorld("electronAPI", { getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); }, - setRecordingState: (recording: boolean) => { - return ipcRenderer.invoke("set-recording-state", recording); + setRecordingState: ( + recording: boolean, + options?: { mediaTimelineStartedAtEpochMs?: number }, + ) => { + return ipcRenderer.invoke("set-recording-state", recording, options); }, setCursorScale: (scale: number) => { return ipcRenderer.invoke("set-cursor-scale", scale); @@ -910,9 +913,6 @@ contextBridge.exposeInMainWorld("electronAPI", { getPlatform: () => { return ipcRenderer.invoke("get-platform"); }, - getLinuxWindowSystem: () => { - return ipcRenderer.invoke("get-linux-window-system"); - }, revealInFolder: (filePath: string) => { return ipcRenderer.invoke("reveal-in-folder", filePath); }, diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index d74e884ce..24512097a 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -6,6 +6,7 @@ import { normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, shouldUseNativeWindowsCaptureForSource, + startMediaRecorderAtTimelineBoundary, } from "./useScreenRecorder"; type RecordingState = "inactive" | "recording" | "paused"; @@ -159,6 +160,61 @@ describe("resolveBrowserCaptureCursorPolicy", () => { }); }); +describe("startMediaRecorderAtTimelineBoundary", () => { + it("uses the recorder start event as the media timeline origin", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(), + }) as unknown as MediaRecorder; + const startedAt = startMediaRecorderAtTimelineBoundary(recorder, 250); + + vi.setSystemTime(2_400); + recorder.dispatchEvent(new Event("start")); + + await expect(startedAt).resolves.toBe(2_400); + expect(recorder.start).toHaveBeenCalledWith(250); + vi.useRealTimers(); + }); + + it("rejects if no media timeline starts before the timeout", async () => { + vi.useFakeTimers(); + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(), + }) as unknown as MediaRecorder; + const startedAt = startMediaRecorderAtTimelineBoundary(recorder, 250, 100); + const expectation = expect(startedAt).rejects.toThrow( + "did not start within the expected time", + ); + + await vi.advanceTimersByTimeAsync(100); + await expectation; + vi.useRealTimers(); + }); + + it("rejects if the recorder fails before its media timeline starts", async () => { + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(), + }) as unknown as MediaRecorder; + const startedAt = startMediaRecorderAtTimelineBoundary(recorder, 250); + + recorder.dispatchEvent(new Event("error")); + + await expect(startedAt).rejects.toThrow("failed before its media timeline started"); + }); + + it("rejects and cleans up when MediaRecorder.start throws", async () => { + const startError = new Error("unsupported recording configuration"); + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(() => { + throw startError; + }), + }) as unknown as MediaRecorder; + + await expect(startMediaRecorderAtTimelineBoundary(recorder, 250)).rejects.toBe(startError); + }); +}); + describe("shouldUseNativeWindowsCaptureForSource", () => { it("keeps native Windows capture on screen sources", () => { expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd70056..37d691e4a 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -202,7 +202,6 @@ export function resolveBrowserCaptureCursorPolicy({ hideEditorOverlayCursorByDefault: true, }; } - return { streamCursor: "never", hideOsCursorBeforeRecording: true, @@ -210,6 +209,54 @@ export function resolveBrowserCaptureCursorPolicy({ }; } +export function startMediaRecorderAtTimelineBoundary( + recorder: Pick, + timesliceMs: number, + timeoutMs = 5_000, +) { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timeoutId); + recorder.removeEventListener("start", handleStart); + recorder.removeEventListener("error", handleFailure); + recorder.removeEventListener("stop", handleFailure); + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(Date.now()); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject( + error instanceof Error + ? error + : new Error("MediaRecorder failed before its media timeline started."), + ); + }; + const handleStart = () => finish(); + const handleFailure = () => + fail(new Error("MediaRecorder failed before its media timeline started.")); + const timeoutId = setTimeout( + () => fail(new Error("MediaRecorder did not start within the expected time.")), + timeoutMs, + ); + recorder.addEventListener("start", handleStart, { once: true }); + recorder.addEventListener("error", handleFailure, { once: true }); + recorder.addEventListener("stop", handleFailure, { once: true }); + + try { + recorder.start(timesliceMs); + } catch (error) { + fail(error); + } + }); +} + export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { @@ -1768,7 +1815,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!stream.current || !videoTrack) { throw new Error("Media stream is not available."); } - try { await videoTrack.applyConstraints({ frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, @@ -1901,15 +1947,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.onerror = () => { setRecording(false); }; - const mainStartedAt = Date.now(); + const mainStartedAt = await startMediaRecorderAtTimelineBoundary( + recorder, + RECORDER_TIMESLICE_MS, + ); beginWebcamCapture(); resetRecordingClock(mainStartedAt); webcamTimeOffsetMs.current = webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; - recorder.start(RECORDER_TIMESLICE_MS); setRecording(true); try { - await window.electronAPI?.setRecordingState(true); + const cursorCaptureState = await window.electronAPI?.setRecordingState(true, { + mediaTimelineStartedAtEpochMs: mainStartedAt, + }); + if (cursorCaptureState?.cursorOverlayAvailable) { + hideEditorOverlayCursorByDefault.current = false; + } } catch (stateError) { console.warn("Failed to notify main process that recording started:", stateError); } From 0921d6dbf5c202b7a47e508021ce3dea440d084b Mon Sep 17 00:00:00 2001 From: AlexSilva-dev Date: Tue, 8 Sep 2026 14:23:55 -0300 Subject: [PATCH 02/12] feat(linux): cursor button events via non-blocking evdev capture - collect mouse button events from /dev/input/event* with O_NONBLOCK reads (20ms polling) instead of blocking fs.createReadStream - blocking reads on evdev char devices park libuv threadpool threads (4 by default); with several devices open and the mouse idle, the whole pool starves and the recording save hangs indefinitely - evdev collection only on Linux + Hyprland sessions, avoiding double-counted clicks where the uiohook X11 path works - requires the user in the "input" group for /dev/input access Tested on: AMD Lucienne, Hyprland 0.56.2, XDPH 1.4.1, PipeWire 1.6.8 Relates to: #808, #863, #891 --- electron/ipc/cursor/hyprland.ts | 128 +++++++++++++++++++++++++++++ electron/ipc/cursor/interaction.ts | 13 ++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts index d56eb629d..20e5dcc48 100644 --- a/electron/ipc/cursor/hyprland.ts +++ b/electron/ipc/cursor/hyprland.ts @@ -1,5 +1,7 @@ +import fs from "node:fs"; import net from "node:net"; import path from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; import { resolveLinuxWindowSystem } from "../../linuxWindowSystem"; import { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; import { linuxCursorScreenPoint, setLinuxCursorScreenPoint } from "../state"; @@ -189,3 +191,129 @@ export async function startHyprlandCursorProvider(options?: { export function isHyprlandCursorProviderActive() { return providerHealthyUntilMs > Date.now(); } + +// ===== Cursor button events via evdev (our addition on top of #808) ===== +// Position comes from the Hyprland polling above; buttons need raw input +// device access (user must be in the "input" group). +// Non-blocking reads: a blocking read() on an evdev char device parks a +// libuv threadpool thread (only 4 by default) until the mouse moves — with +// several devices open that starves the pool and hangs the recording save. +// O_NONBLOCK makes read() return immediately (EAGAIN) when there is no data. +const EV_KEY = 1; +const BTN_LEFT = 0x110; +const BTN_RIGHT = 0x111; +const BTN_MIDDLE = 0x112; +const INPUT_EVENT_SIZE = 24; +const EVDEV_POLL_INTERVAL_MS = 20; + +export function hasMouseButtonCapability(keyCapabilities: string): boolean { + const words = keyCapabilities.trim().split(/\s+/); + const word = words[words.length - 1 - Math.floor(BTN_LEFT / 64)]; + if (!word) { + return false; + } + return ((Number.parseInt(word, 16) >>> (BTN_LEFT % 64)) & 1) === 1; +} + +export type EvdevButtonEvent = { button: 1 | 2 | 3; pressed: boolean }; + +export function parseEvdevButtonEvents(buffer: Buffer): EvdevButtonEvent[] { + const events: EvdevButtonEvent[] = []; + for (let offset = 0; offset + INPUT_EVENT_SIZE <= buffer.length; offset += INPUT_EVENT_SIZE) { + const type = buffer.readUInt16LE(offset + 16); + const code = buffer.readUInt16LE(offset + 18); + const value = buffer.readInt32LE(offset + 20); + if (type !== EV_KEY || value > 1) { + continue; + } + const button = + code === BTN_LEFT ? 1 : code === BTN_RIGHT ? 2 : code === BTN_MIDDLE ? 3 : null; + if (button) { + events.push({ button, pressed: value === 1 }); + } + } + return events; +} + +function listMouseEventDevices(): string[] { + try { + return readdirSync("/sys/class/input") + .filter((name) => name.startsWith("event")) + .filter((name) => { + try { + const capabilities = readFileSync( + `/sys/class/input/${name}/device/capabilities/key`, + "utf-8", + ); + return hasMouseButtonCapability(capabilities); + } catch { + return false; + } + }) + .map((name) => `/dev/input/${name}`); + } catch { + return []; + } +} + +export function startEvdevButtonCapture(handlers: { + onMouseDown: (button: 1 | 2 | 3) => void; + onMouseUp: () => void; +}): () => void { + // Only Hyprland/Wayland sessions need raw evdev buttons: on X11 the uiohook + // already captures clicks, and double-counting them corrupts the telemetry. + if (process.platform !== "linux" || !getHyprlandRequestSocketPath(process.env)) { + return () => {}; + } + const stoppers = listMouseEventDevices().map((devicePath) => { + let fd: number | null = null; + let timer: NodeJS.Timeout | null = null; + const buffer = Buffer.alloc(256); + const stop = () => { + if (timer) { + clearInterval(timer); + timer = null; + } + if (fd !== null) { + const fdToClose = fd; + fd = null; + fs.close(fdToClose, () => {}); + } + }; + fs.open(devicePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK, (openError, openedFd) => { + if (openError || openedFd === undefined) { + console.log("[REC-DEBUG] evdev open FAILED:", devicePath, openError?.message); + stop(); + return; + } + fd = openedFd; + console.log("[REC-DEBUG] evdev fd opened:", devicePath); + timer = setInterval(() => { + if (fd === null) { + clearInterval(timer ?? undefined); + return; + } + fs.read(fd, buffer, 0, buffer.length, null, (readError, bytesRead) => { + if (readError || bytesRead <= 0) { + return; + } + console.log("[REC-DEBUG] evdev data:", bytesRead, "bytes"); + for (const event of parseEvdevButtonEvents(buffer.subarray(0, bytesRead))) { + if (event.pressed) { + handlers.onMouseDown(event.button); + } else { + handlers.onMouseUp(); + } + } + }); + }, EVDEV_POLL_INTERVAL_MS); + }); + return stop; + }); + + return () => { + for (const stop of stoppers) { + stop(); + } + }; +} diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 79e708e9f..8dce6c8ea 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -12,7 +12,10 @@ import { setLastLeftClick, setLinuxCursorScreenPoint, } from "../state"; -import { isHyprlandCursorProviderActive } from "./hyprland"; +import { + isHyprlandCursorProviderActive, + startEvdevButtonCapture, +} from "./hyprland"; import { getNormalizedCursorPoint, getCursorCaptureElapsedMs, @@ -210,6 +213,7 @@ export async function startInteractionCapture() { const point = getNormalizedCursorPoint(); if (!point) { + console.log("[REC-DEBUG] cursor point NULL — click sample skipped"); return; } @@ -285,7 +289,14 @@ export async function startInteractionCapture() { hook.on("mousemove", onMouseMove); } + // Raw evdev clicks (Wayland: the uiohook never sees them) — handlers + // above read the cursor position from the Hyprland provider state. + const stopEvdevCapture = startEvdevButtonCapture({ + onMouseDown: (button) => onMouseDown({ button } as unknown as HookMouseEvent), + onMouseUp: () => onMouseUp(), + }); setInteractionCaptureCleanup(() => { + stopEvdevCapture(); try { if (typeof hook.off === "function") { hook.off("mousedown", onMouseDown); From 26fc16a3d46b519a08c98c349a1af767fbb98db4 Mon Sep 17 00:00:00 2001 From: AlexSilva-dev Date: Tue, 8 Sep 2026 15:06:54 -0300 Subject: [PATCH 03/12] refactor(linux): non-blocking evdev reads with platform guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - evdev button capture reads with O_NONBLOCK + 20ms polling instead of blocking fs.createReadStream streams: blocking reads park libuv threadpool threads (4 by default) and starve the pool when the mouse is idle, hanging the recording save indefinitely - capture only on Linux + Hyprland sessions (guard), avoiding double-counted clicks where the uiohook X11 path works - [REC-DEBUG] lifecycle logging for diagnostics Tested on: AMD Lucienne, Hyprland 0.56.2 — save completes immediately, clicks captured, cursor telemetry flowing end-to-end. --- electron/ipc/cursor/hyprland.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts index 20e5dcc48..150283cc8 100644 --- a/electron/ipc/cursor/hyprland.ts +++ b/electron/ipc/cursor/hyprland.ts @@ -263,7 +263,9 @@ export function startEvdevButtonCapture(handlers: { // Only Hyprland/Wayland sessions need raw evdev buttons: on X11 the uiohook // already captures clicks, and double-counting them corrupts the telemetry. if (process.platform !== "linux" || !getHyprlandRequestSocketPath(process.env)) { - return () => {}; + return () => { + console.log("[REC-DEBUG] evdev capture skipped (no Hyprland session)"); + }; } const stoppers = listMouseEventDevices().map((devicePath) => { let fd: number | null = null; @@ -277,7 +279,9 @@ export function startEvdevButtonCapture(handlers: { if (fd !== null) { const fdToClose = fd; fd = null; - fs.close(fdToClose, () => {}); + fs.close(fdToClose, () => { + console.log(`[REC-DEBUG] evdev closed: ${devicePath}`); + }); } }; fs.open(devicePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK, (openError, openedFd) => { From b5628b68c4c162897885828ffb03e0151131c042 Mon Sep 17 00:00:00 2001 From: AlexSilva-dev Date: Tue, 8 Sep 2026 15:47:03 -0300 Subject: [PATCH 04/12] refactor(linux): non-blocking evdev reads with platform guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - evdev button capture reads with O_NONBLOCK + 20ms polling instead of blocking fs.createReadStream streams: blocking reads park libuv threadpool threads (4 by default) and starve the pool when the mouse is idle, hanging the recording save indefinitely - evdev collection only on Linux + Hyprland sessions (guard), avoiding double-counted clicks where the uiohook X11 path works - [REC-DEBUG] lifecycle logging for diagnostics Tested on: AMD Lucienne, Hyprland 0.56.2 — save completes immediately, clicks captured and rendered, telemetry flowing end-to-end. --- electron/ipc/cursor/hyprland.ts | 14 +++++++++--- electron/ipc/cursor/interaction.ts | 35 ++++++++++++++++++------------ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts index 150283cc8..217fbb77a 100644 --- a/electron/ipc/cursor/hyprland.ts +++ b/electron/ipc/cursor/hyprland.ts @@ -270,8 +270,10 @@ export function startEvdevButtonCapture(handlers: { const stoppers = listMouseEventDevices().map((devicePath) => { let fd: number | null = null; let timer: NodeJS.Timeout | null = null; + let stopped = false; const buffer = Buffer.alloc(256); const stop = () => { + stopped = true; if (timer) { clearInterval(timer); timer = null; @@ -290,18 +292,24 @@ export function startEvdevButtonCapture(handlers: { stop(); return; } + if (stopped) { + // stop() ran while fs.open was in flight — close the descriptor + // immediately instead of leaking it. + fs.closeSync(openedFd); + console.log("[REC-DEBUG] evdev closed (stop before open):", devicePath); + return; + } fd = openedFd; console.log("[REC-DEBUG] evdev fd opened:", devicePath); timer = setInterval(() => { - if (fd === null) { + if (stopped || fd === null) { clearInterval(timer ?? undefined); return; } fs.read(fd, buffer, 0, buffer.length, null, (readError, bytesRead) => { - if (readError || bytesRead <= 0) { + if (stopped || readError || bytesRead <= 0) { return; } - console.log("[REC-DEBUG] evdev data:", bytesRead, "bytes"); for (const event of parseEvdevButtonEvents(buffer.subarray(0, bytesRead))) { if (event.pressed) { handlers.onMouseDown(event.button); diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 856a0706d..c3aac3fa7 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -253,6 +253,24 @@ export async function startInteractionCapture() { stopInteractionCapture(); + const onMouseDown = (event: HookMouseEvent) => { + recordCursorMouseDown(getHookMouseButton(event)); + }; + + const onMouseUp = () => { + recordCursorMouseUp(); + }; + + // Raw evdev clicks (Wayland: the uiohook never sees them) — must start + // independently of the uiohook, which can fail to load on Wayland. + const stopEvdevCapture = startEvdevButtonCapture({ + onMouseDown: (button) => onMouseDown({ button } as unknown as HookMouseEvent), + onMouseUp: () => onMouseUp(), + }); + setInteractionCaptureCleanup(() => { + stopEvdevCapture(); + }); + try { const hook = loadUiohookModule(); console.log( @@ -264,22 +282,16 @@ export async function startInteractionCapture() { typeof hook?.start, ); if (!isCursorCaptureActive) { + stopEvdevCapture(); return; } if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") { console.log("[CursorTelemetry] hook unusable — aborting interaction capture"); + stopEvdevCapture(); return; } - const onMouseDown = (event: HookMouseEvent) => { - recordCursorMouseDown(getHookMouseButton(event)); - }; - - const onMouseUp = () => { - recordCursorMouseUp(); - }; - const onMouseMove = (event: HookMouseEvent) => { if ( process.platform !== "linux" || @@ -310,12 +322,6 @@ export async function startInteractionCapture() { hook.on("mousemove", onMouseMove); } - // Raw evdev clicks (Wayland: the uiohook never sees them) — handlers - // above read the cursor position from the Hyprland provider state. - const stopEvdevCapture = startEvdevButtonCapture({ - onMouseDown: (button) => onMouseDown({ button } as unknown as HookMouseEvent), - onMouseUp: () => onMouseUp(), - }); setInteractionCaptureCleanup(() => { stopEvdevCapture(); try { @@ -347,6 +353,7 @@ export async function startInteractionCapture() { hook.start(); } catch (error) { + stopEvdevCapture(); if (!hasLoggedInteractionHookFailure) { setHasLoggedInteractionHookFailure(true); console.warn("[CursorTelemetry] Global interaction capture unavailable:", error); From 82ce433a01db02ee4bf0e6455645b49c5a360e0b Mon Sep 17 00:00:00 2001 From: AlexSilva-dev Date: Wed, 9 Sep 2026 01:06:06 -0300 Subject: [PATCH 05/12] fix(linux): request screen capture before countdown, zero cursor offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Hyprland/Wayland the recording flow ran the countdown BEFORE the getDisplayMedia request — the portal picker blocked getUserMedia, so the video started late while cursor telemetry had already started, producing desynchronized cursor playback (and a frozen lead-in for the duration of the picker dialog). - Linux flow: request screen capture (portal picker) BEFORE the countdown - Cursor telemetry now starts together with the video capture - HYPRLAND_CURSOR_MEDIA_OFFSET_MS: 300 -> 0 (the calibration compensated for the wrong order; with capture-first it is no longer needed) Tested on: AMD Lucienne, Hyprland 0.56.2 — recording, save, editor and cursor/click sync all working in a single natural launch. --- electron/ipc/cursor/hyprland.ts | 15 ++++++++++++--- src/hooks/useScreenRecorder.ts | 25 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts index 217fbb77a..0c1031879 100644 --- a/electron/ipc/cursor/hyprland.ts +++ b/electron/ipc/cursor/hyprland.ts @@ -9,8 +9,12 @@ import { linuxCursorScreenPoint, setLinuxCursorScreenPoint } from "../state"; const MAX_RESPONSE_BYTES = 4096; const REQUEST_TIMEOUT_MS = 250; const PROVIDER_FRESHNESS_INTERVALS = 3; -// Calibration against Hyprland portal recordings showed cursor telemetry 300 ms early. -export const HYPRLAND_CURSOR_MEDIA_OFFSET_MS = 300; +// EXPERIMENTO: offset zerado para medir o desalinhamento real entre a +// telemetria do cursor e o início do vídeo (hipótese: a telemetria inicia +// antes da captura, pois o seletor do portal bloqueia o getUserMedia após +// a contagem). Original do upstream: 300. +export const HYPRLAND_CURSOR_MEDIA_OFFSET_MS = 0; +let lastDebugPosLogAt = 0; type CursorPoint = { x: number; y: number }; type QueryCursorPoint = (socketPath: string) => Promise; @@ -130,9 +134,14 @@ export async function startHyprlandCursorProvider(options?: { const onPoint = options?.onPoint ?? ((point: CursorPoint) => { + const nowMs = Date.now(); + if (nowMs - lastDebugPosLogAt > 500) { + lastDebugPosLogAt = nowMs; + console.log(`[REC-DEBUG] POS ${point.x},${point.y} at ${nowMs}`); + } setLinuxCursorScreenPoint({ ...point, - updatedAt: Date.now(), + updatedAt: nowMs, coordinateSpace: "logical", source: "hyprland", }); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 1a32f363a..ea5c8d871 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1739,7 +1739,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { preparedStart; const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture; const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0; - if (countdownDelay > 0 && !shouldWarmStartNativeCapture) { + if ( + countdownDelay > 0 && + !shouldWarmStartNativeCapture && + selectedSource.id !== "screen:linux-portal" + ) { setCountdownActive(true); try { const result = await window.electronAPI.startCountdown(countdownDelay); @@ -2186,6 +2190,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn { )} Mbps`, ); + // Linux portal: the screen picker (and its permission token) runs + // BEFORE the countdown, so the recording starts immediately after + // it — no frozen lead-in frames and no telemetry/video drift. + if (countdownDelay > 0) { + setCountdownActive(true); + try { + const result = await window.electronAPI.startCountdown(countdownDelay); + if (!result.success || result.cancelled || startWasCancelled()) { + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + } finally { + setCountdownActive(false); + } + recordingSessionTimestamp.current = Date.now(); + resetRecordingClock(recordingSessionTimestamp.current); + } + chunks.current = []; const hasAudio = stream.current.getAudioTracks().length > 0; const audioBitsPerSecond = hasAudio From cfa98236d5b1baa120489995b9aa519c8347e718 Mon Sep 17 00:00:00 2001 From: XelaJr Date: Thu, 24 Sep 2026 11:43:35 +0200 Subject: [PATCH 06/12] Address Hyprland cursor review findings --- electron/ipc/cursor/hyprland.test.ts | 48 +++++++++++++++++++++ electron/ipc/cursor/hyprland.ts | 31 +++++++++---- electron/ipc/cursor/interaction.ts | 24 ++++++++--- electron/ipc/register/sourceMapping.test.ts | 14 ++++++ electron/ipc/register/sourceMapping.ts | 7 +-- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/electron/ipc/cursor/hyprland.test.ts b/electron/ipc/cursor/hyprland.test.ts index ce365e379..17cfda590 100644 --- a/electron/ipc/cursor/hyprland.test.ts +++ b/electron/ipc/cursor/hyprland.test.ts @@ -259,4 +259,52 @@ describe("Hyprland mouse buttons", () => { } expect(close).toHaveBeenCalledWith(42, expect.any(Function)); }); + + it("drains a full evdev buffer before the next polling interval", async () => { + vi.useFakeTimers(); + const onMouseDown = vi.fn(); + const onMouseUp = vi.fn(); + const onDeviceOpened = vi.fn(); + const read = vi.fn((_fd, buffer: Buffer, _offset, length, _position, callback) => { + if (read.mock.calls.length === 1) { + expect(length).toBeGreaterThan(256); + buffer.fill(0); + buffer.writeUInt16LE(1, 16); + buffer.writeUInt16LE(0x110, 18); + buffer.writeInt32LE(1, 20); + callback(null, length, buffer); + } else { + buffer.fill(0); + buffer.writeUInt16LE(1, 16); + buffer.writeUInt16LE(0x110, 18); + callback(null, 24, buffer); + } + }); + const fsApi = { + open: vi.fn((_path, _flags, callback) => callback(null, 42)), + read, + close: vi.fn((_fd, callback) => callback(null)), + } as unknown as typeof fs; + const stop = startEvdevButtonCapture( + { onMouseDown, onMouseUp }, + { + devicePaths: ["/dev/input/event-test"], + fsApi, + platform: "linux", + env: waylandEnv, + pollIntervalMs: 10, + onDeviceOpened, + }, + ); + try { + await vi.advanceTimersByTimeAsync(10); + expect(onDeviceOpened).toHaveBeenCalledOnce(); + expect(read).toHaveBeenCalledTimes(2); + expect(onMouseDown).toHaveBeenCalledOnce(); + expect(onMouseUp).toHaveBeenCalledOnce(); + } finally { + stop(); + vi.useRealTimers(); + } + }); }); diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts index 773ba43cc..a933989cd 100644 --- a/electron/ipc/cursor/hyprland.ts +++ b/electron/ipc/cursor/hyprland.ts @@ -195,7 +195,7 @@ export function isHyprlandCursorProviderActive() { // ===== Cursor button events via evdev (our addition on top of #808) ===== // Position comes from the Hyprland polling above; buttons need raw input -// device access (user must be in the "input" group). +// device access (for example through a udev uaccess rule). // Non-blocking reads: a blocking read() on an evdev char device parks a // libuv threadpool thread (only 4 by default) until the mouse moves — with // several devices open that starves the pool and hangs the recording save. @@ -281,13 +281,15 @@ export function startEvdevButtonCapture( platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; pollIntervalMs?: number; + onDeviceOpened?: () => void; }, ): () => void { // Only Hyprland/Wayland sessions need raw evdev buttons: on X11 the uiohook // already captures clicks, and double-counting them corrupts the telemetry. + const platform = options?.platform ?? process.platform; if ( - (options?.platform ?? process.platform) !== "linux" || - !getHyprlandRequestSocketPath(options?.env ?? process.env) + platform !== "linux" || + !getHyprlandRequestSocketPath(options?.env ?? process.env, platform) ) { return () => undefined; } @@ -298,7 +300,7 @@ export function startEvdevButtonCapture( let stopped = false; let readInFlight = false; let pendingBytes = Buffer.alloc(0); - const buffer = Buffer.alloc(256); + const buffer = Buffer.alloc(INPUT_EVENT_SIZE * 64); const stop = () => { stopped = true; if (timer) { @@ -331,14 +333,15 @@ export function startEvdevButtonCapture( return; } fd = openedFd; - timer = setInterval(() => { - if (stopped || fd === null || readInFlight) { + options?.onDeviceOpened?.(); + const readAvailable = () => { + if (stopped || fd === null) { + readInFlight = false; return; } - readInFlight = true; fsApi.read(fd, buffer, 0, buffer.length, null, (readError, bytesRead) => { - readInFlight = false; if (stopped || readError || bytesRead <= 0) { + readInFlight = false; return; } const decoded = decodeEvdevButtonChunk( @@ -353,7 +356,19 @@ export function startEvdevButtonCapture( handlers.onMouseUp(); } } + if (bytesRead === buffer.length) { + readAvailable(); + } else { + readInFlight = false; + } }); + }; + timer = setInterval(() => { + if (stopped || fd === null || readInFlight) { + return; + } + readInFlight = true; + readAvailable(); }, options?.pollIntervalMs ?? EVDEV_POLL_INTERVAL_MS); }, ); diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 5ce423268..e7d1c9dec 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -249,21 +249,33 @@ export async function startInteractionCapture() { } stopInteractionCapture(); + let evdevAvailable = false; const onMouseDown = (event: HookMouseEvent) => { - recordCursorMouseDown(getHookMouseButton(event)); + if (!evdevAvailable) { + recordCursorMouseDown(getHookMouseButton(event)); + } }; const onMouseUp = () => { - recordCursorMouseUp(); + if (!evdevAvailable) { + recordCursorMouseUp(); + } }; // Raw evdev clicks (Wayland: the uiohook never sees them) — must start // independently of the uiohook, which can fail to load on Wayland. - const stopEvdevCapture = startEvdevButtonCapture({ - onMouseDown: (button) => onMouseDown({ button } as unknown as HookMouseEvent), - onMouseUp: () => onMouseUp(), - }); + const stopEvdevCapture = startEvdevButtonCapture( + { + onMouseDown: recordCursorMouseDown, + onMouseUp: recordCursorMouseUp, + }, + { + onDeviceOpened: () => { + evdevAvailable = true; + }, + }, + ); setInteractionCaptureCleanup(() => { stopEvdevCapture(); }); diff --git a/electron/ipc/register/sourceMapping.test.ts b/electron/ipc/register/sourceMapping.test.ts index 84351a506..6590c7c7c 100644 --- a/electron/ipc/register/sourceMapping.test.ts +++ b/electron/ipc/register/sourceMapping.test.ts @@ -24,6 +24,20 @@ describe("getScreenSourceIdForDisplay", () => { ).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID); }); + it("keeps the portal source when Electron uses X11 inside a Wayland session", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + env: { + XDG_SESSION_TYPE: "wayland", + WAYLAND_DISPLAY: "wayland-0", + OZONE_PLATFORM: "x11", + }, + platform: "linux", + }), + ).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID); + }); + it("keeps unmatched Linux X11 screens on the explicit fallback id", () => { expect( getScreenSourceIdForDisplay({ diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index c6a649ff9..83e73d10f 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -1,9 +1,10 @@ -import { resolveLinuxWindowSystem } from "../../linuxWindowSystem"; - export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal"; export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) { - return resolveLinuxWindowSystem("linux", env) === "wayland"; + const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase(); + if (sessionType === "wayland") return true; + if (sessionType === "x11") return false; + return Boolean(env.WAYLAND_DISPLAY); } export function getScreenSourceIdForDisplay({ From 656a246208331f1760fc246baa9e61bda4e51eca Mon Sep 17 00:00:00 2001 From: Md Hasan Hamid <86195509+Mr-Hasan-Hamid@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:38:00 +0530 Subject: [PATCH 07/12] feat(linux): add native Wayland cursor position tracking and evdev click telemetry --- electron/ipc/cursor/interaction.ts | 34 +++- electron/ipc/cursor/linuxTracker.test.ts | 73 +++++++++ electron/ipc/cursor/linuxTracker.ts | 192 +++++++++++++++++++++++ electron/ipc/cursor/telemetry.ts | 13 +- electron/ipc/register/recording.ts | 2 +- 5 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 electron/ipc/cursor/linuxTracker.test.ts create mode 100644 electron/ipc/cursor/linuxTracker.ts diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 47c42437f..78e4ab586 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -17,6 +17,7 @@ import type { UiohookLike, UiohookModuleNamespace, } from "../types"; +import { isLinuxWayland, startLinuxCursorTracker } from "./linuxTracker"; import { getCursorCaptureElapsedMs, getHookCursorScreenPoint, @@ -178,12 +179,6 @@ function loadUiohookModule() { } export function shouldStartGlobalInteractionHook(platform: NodeJS.Platform = process.platform) { - // On macOS, uiohook can block forever while its native event tap starts - // (notably when Accessibility permission is unavailable or stale). Because - // start() executes synchronously, that freezes Electron's main thread and - // makes every window, including the recording HUD, unresponsive. Cursor - // position and visual-state telemetry still come from the existing native - // macOS monitor and Electron sampler. return platform !== "darwin"; } @@ -242,12 +237,30 @@ export async function startInteractionCapture() { return; } + stopInteractionCapture(); + + let linuxCleanup: (() => void) | null = null; + if (process.platform === "linux") { + linuxCleanup = startLinuxCursorTracker( + (button) => recordCursorMouseDown(button), + () => recordCursorMouseUp(), + ); + } + if (!shouldStartGlobalInteractionHook()) { console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS."); return; } - stopInteractionCapture(); + if (process.platform === "linux" && isLinuxWayland()) { + if (linuxCleanup) { + setInteractionCaptureCleanup(linuxCleanup); + } + console.log( + "[CursorTelemetry] Using native Linux Wayland/evdev tracker (skipping uiohook).", + ); + return; + } try { const hook = loadUiohookModule(); @@ -296,6 +309,13 @@ export async function startInteractionCapture() { } setInteractionCaptureCleanup(() => { + if (linuxCleanup) { + try { + linuxCleanup(); + } catch { + // ignore + } + } try { if (typeof hook.off === "function") { hook.off("mousedown", onMouseDown); diff --git a/electron/ipc/cursor/linuxTracker.test.ts b/electron/ipc/cursor/linuxTracker.test.ts new file mode 100644 index 000000000..bf021b99e --- /dev/null +++ b/electron/ipc/cursor/linuxTracker.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getLinuxCursorSync, isLinuxWayland, startLinuxCursorTracker } from "./linuxTracker"; + +describe("linuxTracker", () => { + const originalEnv = { ...process.env }; + const originalPlatform = process.platform; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + describe("isLinuxWayland", () => { + it("returns false on darwin or win32", () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + process.env.WAYLAND_DISPLAY = "wayland-1"; + expect(isLinuxWayland()).toBe(false); + + Object.defineProperty(process, "platform", { value: "win32" }); + expect(isLinuxWayland()).toBe(false); + }); + + it("returns true on linux when WAYLAND_DISPLAY is set", () => { + Object.defineProperty(process, "platform", { value: "linux" }); + process.env.WAYLAND_DISPLAY = "wayland-1"; + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + delete process.env.XDG_SESSION_TYPE; + expect(isLinuxWayland()).toBe(true); + }); + + it("returns true on linux when HYPRLAND_INSTANCE_SIGNATURE is set", () => { + Object.defineProperty(process, "platform", { value: "linux" }); + delete process.env.WAYLAND_DISPLAY; + delete process.env.XDG_SESSION_TYPE; + process.env.HYPRLAND_INSTANCE_SIGNATURE = "mock_signature"; + expect(isLinuxWayland()).toBe(true); + }); + + it("returns false on linux X11 session without Wayland indicators", () => { + Object.defineProperty(process, "platform", { value: "linux" }); + delete process.env.WAYLAND_DISPLAY; + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + process.env.XDG_SESSION_TYPE = "x11"; + expect(isLinuxWayland()).toBe(false); + }); + }); + + describe("startLinuxCursorTracker", () => { + it("returns null on non-linux platforms", () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + const cleanup = startLinuxCursorTracker(vi.fn(), vi.fn()); + expect(cleanup).toBeNull(); + }); + + it("returns a cleanup function on linux", () => { + Object.defineProperty(process, "platform", { value: "linux" }); + const cleanup = startLinuxCursorTracker(vi.fn(), vi.fn()); + expect(typeof cleanup).toBe("function"); + cleanup?.(); + }); + }); + + describe("getLinuxCursorSync", () => { + it("returns null on non-linux platforms", () => { + Object.defineProperty(process, "platform", { value: "win32" }); + expect(getLinuxCursorSync()).toBeNull(); + }); + }); +}); diff --git a/electron/ipc/cursor/linuxTracker.ts b/electron/ipc/cursor/linuxTracker.ts new file mode 100644 index 000000000..b6a446477 --- /dev/null +++ b/electron/ipc/cursor/linuxTracker.ts @@ -0,0 +1,192 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { setLinuxCursorScreenPoint } from "../state"; + +export function isLinuxWayland(): boolean { + if (process.platform !== "linux") return false; + return Boolean( + process.env.WAYLAND_DISPLAY || + process.env.XDG_SESSION_TYPE?.toLowerCase() === "wayland" || + process.env.HYPRLAND_INSTANCE_SIGNATURE, + ); +} + +function getHyprlandSocketPath(): string | null { + const sig = process.env.HYPRLAND_INSTANCE_SIGNATURE; + if (!sig) return null; + const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`; + const sockPath = path.join(runtimeDir, "hypr", sig, ".socket.sock"); + if (fs.existsSync(sockPath)) { + return sockPath; + } + return null; +} + +export function getLinuxCursorSync(): { x: number; y: number } | null { + if (process.platform !== "linux") return null; + if (process.env.HYPRLAND_INSTANCE_SIGNATURE) { + try { + const out = execFileSync("hyprctl", ["cursorpos"], { + encoding: "utf8", + timeout: 50, + stdio: ["ignore", "pipe", "ignore"], + }); + const parts = out.trim().split(","); + if (parts.length === 2) { + const x = parseFloat(parts[0]); + const y = parseFloat(parts[1]); + if (Number.isFinite(x) && Number.isFinite(y)) { + const pt = { x, y, updatedAt: Date.now() }; + setLinuxCursorScreenPoint(pt); + return { x, y }; + } + } + } catch { + // ignore + } + } + return null; +} + +export function startLinuxCursorTracker( + onMouseDown: (button: 1 | 2 | 3) => void, + onMouseUp: () => void, +): (() => void) | null { + if (process.platform !== "linux") { + return null; + } + + let active = true; + const cleanups: (() => void)[] = []; + + // Fetch initial position immediately + getLinuxCursorSync(); + + // ── 1. Position tracking (Hyprland / Wayland IPC) ─────────────────────────── + const hyprSock = getHyprlandSocketPath(); + if (hyprSock) { + let inFlight = false; + + const pollHyprlandCursor = () => { + if (!active) return; + if (inFlight) { + setTimeout(pollHyprlandCursor, 16); + return; + } + inFlight = true; + + const client = net.createConnection(hyprSock, () => { + client.write("cursorpos"); + }); + + client.on("data", (data) => { + const parts = data.toString().trim().split(","); + if (parts.length === 2) { + const x = parseFloat(parts[0]); + const y = parseFloat(parts[1]); + if (Number.isFinite(x) && Number.isFinite(y)) { + setLinuxCursorScreenPoint({ x, y, updatedAt: Date.now() }); + } + } + client.end(); + }); + + client.on("close", () => { + inFlight = false; + if (active) setTimeout(pollHyprlandCursor, 16); + }); + + client.on("error", () => { + inFlight = false; + if (active) setTimeout(pollHyprlandCursor, 50); + }); + }; + + pollHyprlandCursor(); + cleanups.push(() => { + active = false; + }); + } + + // ── 2. Click tracking via evdev (/dev/input/by-id/ or /dev/input/) ────────── + try { + const byIdDir = "/dev/input/by-id"; + let mouseDevices: string[] = []; + + if (fs.existsSync(byIdDir)) { + mouseDevices = fs + .readdirSync(byIdDir) + .filter((name) => name.includes("event-mouse") || name.includes("touchpad")) + .map((name) => path.join(byIdDir, name)); + } + + if (mouseDevices.length === 0 && fs.existsSync("/dev/input")) { + mouseDevices = fs + .readdirSync("/dev/input") + .filter((name) => name.startsWith("event")) + .map((name) => path.join("/dev/input", name)); + } + + for (const devPath of mouseDevices) { + try { + const stream = fs.createReadStream(devPath); + stream.on("data", (chunk: Buffer) => { + if (!active) return; + // Linux 64-bit input_event is 24 bytes: + // timeval (16 bytes), uint16 type (2), uint16 code (2), int32 value (4) + for (let offset = 0; offset + 24 <= chunk.length; offset += 24) { + const type = chunk.readUInt16LE(offset + 16); + const code = chunk.readUInt16LE(offset + 18); + const value = chunk.readInt32LE(offset + 20); + + // EV_KEY = 1 + if (type === 1) { + // BTN_LEFT = 272 (0x110), BTN_RIGHT = 273 (0x111), BTN_MIDDLE = 274 (0x112) + let button: 1 | 2 | 3 | null = null; + if (code === 272) button = 1; + else if (code === 273) button = 2; + else if (code === 274) button = 3; + + if (button !== null) { + if (value === 1) { + onMouseDown(button); + } else if (value === 0) { + onMouseUp(); + } + } + } + } + }); + + stream.on("error", () => { + // Ignore read stream errors on individual device + }); + + cleanups.push(() => { + try { + stream.destroy(); + } catch { + // ignore destroy errors + } + }); + } catch { + // Device might not be readable, skip + } + } + } catch { + // Ignore /dev/input enumeration errors + } + + return () => { + active = false; + for (const cleanup of cleanups) { + try { + cleanup(); + } catch { + // ignore cleanup errors + } + } + }; +} diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 73f62714e..9f3ce7ed1 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -24,6 +24,7 @@ import { } from "../state"; import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; import { getScreen, getTelemetryPathForVideo } from "../utils"; +import { getLinuxCursorSync } from "./linuxTracker"; export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -172,8 +173,16 @@ export function getNormalizedCursorPoint() { const primarySf = process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1; - const cursor = isLinuxCacheFresh - ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } + let linuxCursor = isLinuxCacheFresh ? linuxCursorCache : null; + if (process.platform === "linux" && !linuxCursor) { + const syncPoint = getLinuxCursorSync(); + if (syncPoint) { + linuxCursor = { x: syncPoint.x, y: syncPoint.y, updatedAt: Date.now() }; + } + } + + const cursor = linuxCursor + ? { x: linuxCursor.x / primarySf, y: linuxCursor.y / primarySf } : fallbackCursor; const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 797f8badd..53c4ac1f4 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -1872,9 +1872,9 @@ export function registerRecordingHandlers( resetCursorCaptureClock(); setLinuxCursorScreenPoint(null); setLastLeftClick(null); + void startInteractionCapture(); sampleCursorPoint(); startCursorSampling(); - void startInteractionCapture(); } else { setIsCursorCaptureActive(false); stopCursorCapture(); From 6d469d9429d7e55c4eb4ce080fea8a2d71a1bb31 Mon Sep 17 00:00:00 2001 From: Md Hasan Hamid <86195509+Mr-Hasan-Hamid@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:58:51 +0530 Subject: [PATCH 08/12] feat(linux): improve window bounds resolution and click accuracy on Hyprland --- electron/ipc/cursor/bounds.ts | 35 +++++++++++++++++++++++++++++ electron/ipc/cursor/linuxTracker.ts | 2 ++ 2 files changed, 37 insertions(+) diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index 1b8b8eaa8..1118485c1 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -123,6 +123,41 @@ export function parseXwininfoBounds(stdout: string): WindowBounds | null { export async function resolveLinuxWindowBounds( source: SelectedSource, ): Promise { + if (process.env.HYPRLAND_INSTANCE_SIGNATURE) { + const targetTitle = ( + typeof source.windowTitle === "string" ? source.windowTitle : source.name || "" + ) + .trim() + .toLowerCase(); + if (targetTitle) { + try { + const { stdout } = await execFileAsync("hyprctl", ["clients", "-j"], { + timeout: 1000, + }); + const clients = JSON.parse(stdout); + if (Array.isArray(clients)) { + const match = clients.find( + (c) => + c.title?.toLowerCase().includes(targetTitle) || + targetTitle.includes(c.title?.toLowerCase()) || + c.class?.toLowerCase().includes(targetTitle) || + targetTitle.includes(c.class?.toLowerCase()), + ); + if (match && Array.isArray(match.at) && Array.isArray(match.size)) { + return { + x: match.at[0], + y: match.at[1], + width: match.size[0], + height: match.size[1], + }; + } + } + } catch { + // fall through to xwininfo + } + } + } + const windowId = parseWindowId(source?.id); if (windowId) { diff --git a/electron/ipc/cursor/linuxTracker.ts b/electron/ipc/cursor/linuxTracker.ts index b6a446477..d43a19681 100644 --- a/electron/ipc/cursor/linuxTracker.ts +++ b/electron/ipc/cursor/linuxTracker.ts @@ -151,8 +151,10 @@ export function startLinuxCursorTracker( if (button !== null) { if (value === 1) { + getLinuxCursorSync(); onMouseDown(button); } else if (value === 0) { + getLinuxCursorSync(); onMouseUp(); } } From f6c0f39dce8eb839450b75ad54d9d304a055047a Mon Sep 17 00:00:00 2001 From: Md Hasan Hamid <86195509+Mr-Hasan-Hamid@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:08:00 +0530 Subject: [PATCH 09/12] fix(linux): non-blocking evdev click handling and strict Hyprland client validation --- electron/ipc/cursor/bounds.ts | 24 +++++++++++++++++++----- electron/ipc/cursor/linuxTracker.ts | 2 -- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/electron/ipc/cursor/bounds.ts b/electron/ipc/cursor/bounds.ts index 1118485c1..9820e3e01 100644 --- a/electron/ipc/cursor/bounds.ts +++ b/electron/ipc/cursor/bounds.ts @@ -138,12 +138,26 @@ export async function resolveLinuxWindowBounds( if (Array.isArray(clients)) { const match = clients.find( (c) => - c.title?.toLowerCase().includes(targetTitle) || - targetTitle.includes(c.title?.toLowerCase()) || - c.class?.toLowerCase().includes(targetTitle) || - targetTitle.includes(c.class?.toLowerCase()), + (typeof c.title === "string" && + c.title.length > 0 && + (c.title.toLowerCase().includes(targetTitle) || + targetTitle.includes(c.title.toLowerCase()))) || + (typeof c.class === "string" && + c.class.length > 0 && + (c.class.toLowerCase().includes(targetTitle) || + targetTitle.includes(c.class.toLowerCase()))), ); - if (match && Array.isArray(match.at) && Array.isArray(match.size)) { + if ( + match && + Array.isArray(match.at) && + Array.isArray(match.size) && + Number.isFinite(match.at[0]) && + Number.isFinite(match.at[1]) && + Number.isFinite(match.size[0]) && + Number.isFinite(match.size[1]) && + match.size[0] > 0 && + match.size[1] > 0 + ) { return { x: match.at[0], y: match.at[1], diff --git a/electron/ipc/cursor/linuxTracker.ts b/electron/ipc/cursor/linuxTracker.ts index d43a19681..b6a446477 100644 --- a/electron/ipc/cursor/linuxTracker.ts +++ b/electron/ipc/cursor/linuxTracker.ts @@ -151,10 +151,8 @@ export function startLinuxCursorTracker( if (button !== null) { if (value === 1) { - getLinuxCursorSync(); onMouseDown(button); } else if (value === 0) { - getLinuxCursorSync(); onMouseUp(); } } From 84f739cf300e3d9d529febf9b546e3869b487d71 Mon Sep 17 00:00:00 2001 From: Md Hasan Hamid <86195509+Mr-Hasan-Hamid@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:08:11 +0530 Subject: [PATCH 10/12] fix(exporter): prefer WebGL over WebGPU by default and add native clipboard fallback --- electron/electron-env.d.ts | 1 + electron/ipc/register/permissions.ts | 12 +++- electron/preload.ts | 3 + .../video-editor/cloud/CloudShareButton.tsx | 25 ++++--- .../video-editor/layout/EditorExportMenu.tsx | 21 +++--- src/lib/clipboard.test.ts | 61 ++++++++++++++++ src/lib/clipboard.ts | 72 +++++++++++++++++++ src/lib/exporter/modernFrameRenderer.test.ts | 54 ++++++++++++++ src/lib/exporter/modernFrameRenderer.ts | 14 +++- 9 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 src/lib/clipboard.test.ts create mode 100644 src/lib/clipboard.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 5409fcbee..71ac2f72a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -623,6 +623,7 @@ interface Window { onCursorStateChanged: ( callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, ) => () => void; + writeClipboardText: (text: string) => Promise<{ success: boolean; error?: string }>; openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; getAccessibilityPermissionStatus: () => Promise<{ success: boolean; diff --git a/electron/ipc/register/permissions.ts b/electron/ipc/register/permissions.ts index f3b8b86f1..049e233fa 100644 --- a/electron/ipc/register/permissions.ts +++ b/electron/ipc/register/permissions.ts @@ -1,7 +1,17 @@ -import { ipcMain, shell, systemPreferences } from "electron"; +import { clipboard, ipcMain, shell, systemPreferences } from "electron"; import { getMacPrivacySettingsUrl } from "../utils"; export function registerPermissionHandlers() { + ipcMain.handle("clipboard-write-text", async (_, text: string) => { + try { + clipboard.writeText(String(text ?? "")); + return { success: true }; + } catch (error) { + console.error("Failed to write to clipboard:", error); + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("open-external-url", async (_, url: string) => { try { // Security: only allow http/https URLs to prevent file:// or custom protocol abuse diff --git a/electron/preload.ts b/electron/preload.ts index ddfadbfa8..7c8c18f44 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -664,6 +664,9 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("cursor-state-changed", listener); return () => ipcRenderer.removeListener("cursor-state-changed", listener); }, + writeClipboardText: (text: string) => { + return ipcRenderer.invoke("clipboard-write-text", text); + }, openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, diff --git a/src/components/video-editor/cloud/CloudShareButton.tsx b/src/components/video-editor/cloud/CloudShareButton.tsx index abe52a00b..57ce57e8b 100644 --- a/src/components/video-editor/cloud/CloudShareButton.tsx +++ b/src/components/video-editor/cloud/CloudShareButton.tsx @@ -1,8 +1,4 @@ -import { saveProjectShareLink } from "./projectShareLinks"; -import { useI18n } from "@/contexts/I18nContext"; -import { Check, CloudArrowUp, Copy, ShareNetwork } from "@/components/ui/icons"; import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "@/components/ui/toast"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -11,13 +7,18 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Check, CloudArrowUp, Copy, ShareNetwork } from "@/components/ui/icons"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { toast } from "@/components/ui/toast"; +import { useI18n } from "@/contexts/I18nContext"; +import { copyToClipboard } from "@/lib/clipboard"; +import { saveProjectShareLink } from "./projectShareLinks"; const DEFAULT_CLOUD_ENDPOINT = "http://localhost:8787/api/upload"; type Props = { - projectPath?: string | null; + projectPath?: string | null; filePath?: string; projectTitle: string; prepareFile?: () => Promise; @@ -130,7 +131,13 @@ export function CloudShareButton({ } setProgress(100); setShareUrl(result.shareUrl); - if (projectPath) { try { saveProjectShareLink(projectPath, result.shareUrl); } catch { toast.error("Share created, but its link could not be saved locally"); } } + if (projectPath) { + try { + saveProjectShareLink(projectPath, result.shareUrl); + } catch { + toast.error("Share created, but its link could not be saved locally"); + } + } toast.success(t("editor.cloud.linkCreated")); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); @@ -149,11 +156,11 @@ export function CloudShareButton({ const copyShareUrl = useCallback(async () => { if (!shareUrl) return; - try { - await navigator.clipboard.writeText(shareUrl); + const copied = await copyToClipboard(shareUrl); + if (copied) { setCopied(true); toast.success(t("editor.cloud.linkCopied")); - } catch { + } else { setCopied(false); toast.error(t("editor.cloud.copyFailed")); } diff --git a/src/components/video-editor/layout/EditorExportMenu.tsx b/src/components/video-editor/layout/EditorExportMenu.tsx index 4dea1caf3..1bf72647e 100644 --- a/src/components/video-editor/layout/EditorExportMenu.tsx +++ b/src/components/video-editor/layout/EditorExportMenu.tsx @@ -1,13 +1,12 @@ +import { Card, ProgressBar } from "@heroui/react"; import { useEffect, useState } from "react"; -import { CloudArrowUp } from "@/components/ui/icons"; -import { CloudShareButton } from "../cloud/CloudShareButton"; -import { Card } from "@heroui/react"; -import { ProgressBar } from "@heroui/react"; -import { DownloadSimple as Download } from "@/components/ui/icons"; -import { toast } from "@/components/ui/toast"; import { Button } from "@/components/ui/button"; +import { CloudArrowUp, DownloadSimple as Download } from "@/components/ui/icons"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { toast } from "@/components/ui/toast"; import type { useI18n } from "@/contexts/I18nContext"; +import { copyToClipboard } from "@/lib/clipboard"; +import { CloudShareButton } from "../cloud/CloudShareButton"; import { ExportSettingsMenu } from "../ExportSettingsMenu"; import type { useExportDimensions } from "../export/useExportDimensions"; import type { useExportSession } from "../export/useExportSession"; @@ -15,7 +14,7 @@ import type { useExportSettings } from "../export/useExportSettings"; import type { useExportStatusViewModel } from "../export/useExportStatusViewModel"; type Props = { - projectPath?: string | null; + projectPath?: string | null; t: ReturnType["t"]; exportSettings: ReturnType; exportSession: ReturnType; @@ -230,15 +229,15 @@ export function EditorExportMenu(props: Props) { variant="outline" className="h-8 text-xs" onClick={async () => { - try { - await navigator.clipboard.writeText(exportError); + const copied = await copyToClipboard(exportError); + if (copied) { toast.success( t( "editor.exportStatus.errorCopied", "Error copied", ), ); - } catch { + } else { toast.error( t( "editor.exportStatus.errorCopyFailed", @@ -360,7 +359,7 @@ export function EditorExportMenu(props: Props) { {shareOpen && ( { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("returns false for empty text", async () => { + const result = await copyToClipboard(""); + expect(result).toBe(false); + }); + + it("uses electronAPI.writeClipboardText when available and successful", async () => { + const writeClipboardText = vi.fn().mockResolvedValue({ success: true }); + vi.stubGlobal("window", { electronAPI: { writeClipboardText } }); + + const result = await copyToClipboard("test error"); + expect(result).toBe(true); + expect(writeClipboardText).toHaveBeenCalledWith("test error"); + }); + + it("falls back to navigator.clipboard when electronAPI fails", async () => { + const writeClipboardText = vi.fn().mockRejectedValue(new Error("IPC failed")); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("window", { electronAPI: { writeClipboardText } }); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + + const result = await copyToClipboard("test error"); + expect(result).toBe(true); + expect(writeText).toHaveBeenCalledWith("test error"); + }); + + it("falls back to document.execCommand when navigator.clipboard fails", async () => { + const writeText = vi + .fn() + .mockRejectedValue(new Error("NotAllowedError: Document is not focused")); + const execCommand = vi.fn().mockReturnValue(true); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + vi.stubGlobal("document", { + createElement: () => ({ + value: "", + style: {}, + setAttribute: vi.fn(), + focus: vi.fn(), + select: vi.fn(), + }), + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + execCommand, + }); + + const result = await copyToClipboard("test error"); + expect(result).toBe(true); + expect(execCommand).toHaveBeenCalledWith("copy"); + }); +}); diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 000000000..82aeab538 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,72 @@ +/** + * Copies text to the system clipboard across platforms. + * + * Tries: + * 1. Native Electron clipboard API (bypasses browser focus / Wayland sandbox constraints) + * 2. Async Clipboard API (navigator.clipboard.writeText) + * 3. Fallback DOM execCommand("copy") + */ +export async function copyToClipboard(text: string): Promise { + if (!text) { + return false; + } + + // 1. Electron IPC / native clipboard + if (typeof window !== "undefined") { + const electronApi = ( + window as unknown as { + electronAPI?: { + writeClipboardText?: ( + text: string, + ) => Promise<{ success: boolean; error?: string }>; + }; + } + ).electronAPI; + + if (typeof electronApi?.writeClipboardText === "function") { + try { + const result = await electronApi.writeClipboardText(text); + if (result?.success) { + return true; + } + } catch { + // fall through to browser APIs + } + } + } + + // 2. Standard navigator.clipboard + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // fall through to DOM execCommand fallback + } + } + + // 3. Fallback: DOM-based execCommand copy + if (typeof document !== "undefined") { + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + textArea.style.left = "-9999px"; + textArea.style.top = "-9999px"; + textArea.setAttribute("readonly", ""); + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const success = document.execCommand("copy"); + document.body.removeChild(textArea); + if (success) { + return true; + } + } catch { + // all fallbacks exhausted + } + } + + return false; +} diff --git a/src/lib/exporter/modernFrameRenderer.test.ts b/src/lib/exporter/modernFrameRenderer.test.ts index 79c2caa13..415b847c8 100644 --- a/src/lib/exporter/modernFrameRenderer.test.ts +++ b/src/lib/exporter/modernFrameRenderer.test.ts @@ -231,6 +231,60 @@ it("bypasses blur annotation compositing during gaps and clears stale composite }); describe("ModernFrameRenderer Pixi lifecycle", () => { + it("tries WebGL before WebGPU in default/auto mode when both are nominally available", async () => { + pixiApplicationInstancesMock.length = 0; + pixiInitializationErrorsMock.length = 0; + vi.stubGlobal("navigator", { gpu: {} }); + + try { + const renderer = createRenderer() as unknown as { + config: { preferredRenderBackend?: "webgl" | "webgpu" }; + createPixiApplication: ( + canvas: HTMLCanvasElement, + ) => Promise<{ backend: "webgl" | "webgpu" }>; + }; + + await expect( + renderer.createPixiApplication({} as HTMLCanvasElement), + ).resolves.toMatchObject({ + backend: "webgl", + }); + + expect(pixiApplicationInstancesMock).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("falls back to WebGPU when WebGL fails in default/auto mode", async () => { + pixiApplicationInstancesMock.length = 0; + pixiInitializationErrorsMock.length = 0; + pixiInitializationErrorsMock.push(new Error("WebGL initialization failed")); + vi.stubGlobal("navigator", { gpu: {} }); + + try { + const renderer = createRenderer() as unknown as { + config: { preferredRenderBackend?: "webgl" | "webgpu" }; + createPixiApplication: ( + canvas: HTMLCanvasElement, + ) => Promise<{ backend: "webgl" | "webgpu" }>; + }; + + await expect( + renderer.createPixiApplication({} as HTMLCanvasElement), + ).resolves.toMatchObject({ + backend: "webgpu", + }); + + expect(pixiApplicationInstancesMock).toHaveLength(2); + expect(pixiApplicationInstancesMock[0].destroy).not.toHaveBeenCalled(); + expect(pixiApplicationInstancesMock[0].stage.destroy).toHaveBeenCalledTimes(1); + expect(pixiApplicationInstancesMock[0].renderer.destroy).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + it("continues to the next backend when failed-init cleanup would throw", async () => { pixiApplicationInstancesMock.length = 0; pixiInitializationErrorsMock.length = 0; diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index c66c18d79..0e3cfd8e9 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -53,7 +53,10 @@ import { } from "@/components/video-editor/videoPlayback/motionSmoothing"; import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects"; import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion"; -import { getWebcamMediaTargetTimeSeconds, isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync"; +import { + getWebcamMediaTargetTimeSeconds, + isWebcamVisibleAtSourceTime, +} from "@/components/video-editor/videoPlayback/webcamSync"; import { applyZoomTransform, computeZoomTransform, @@ -629,7 +632,7 @@ export class FrameRenderer { : preferredRenderBackend === "webgpu" ? ["webgpu", "webgl"] : typeof navigator !== "undefined" && "gpu" in navigator - ? ["webgpu", "webgl"] + ? ["webgl", "webgpu"] : ["webgl"]; const failures: PixiRendererAttempt[] = []; @@ -2725,7 +2728,12 @@ export class FrameRenderer { private updateWebcamOverlay(referenceTimeSeconds = this.currentVideoTime): void { const webcam = this.config.webcam; - if (!webcam?.enabled || !isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) || !this.webcamRootContainer || !this.webcamMaskGraphics) { + if ( + !webcam?.enabled || + !isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) || + !this.webcamRootContainer || + !this.webcamMaskGraphics + ) { if (this.webcamRootContainer) { this.webcamRootContainer.visible = false; } From 43ec65d4f474c8cc25704ec55d55e4fc6ba80942 Mon Sep 17 00:00:00 2001 From: Md Hasan Hamid <86195509+Mr-Hasan-Hamid@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:21:18 +0530 Subject: [PATCH 11/12] fix(cursor): gate Wayland interaction capture, align logical coordinates, and harden socket polling --- electron/ipc/cursor/interaction.ts | 25 ++++---------- electron/ipc/cursor/linuxTracker.ts | 52 +++++++++++++++++++++-------- electron/ipc/cursor/telemetry.ts | 36 ++++++-------------- 3 files changed, 55 insertions(+), 58 deletions(-) diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 78e4ab586..17608bc5b 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -239,20 +239,11 @@ export async function startInteractionCapture() { stopInteractionCapture(); - let linuxCleanup: (() => void) | null = null; - if (process.platform === "linux") { - linuxCleanup = startLinuxCursorTracker( + if (process.platform === "linux" && isLinuxWayland()) { + const linuxCleanup = startLinuxCursorTracker( (button) => recordCursorMouseDown(button), () => recordCursorMouseUp(), ); - } - - if (!shouldStartGlobalInteractionHook()) { - console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS."); - return; - } - - if (process.platform === "linux" && isLinuxWayland()) { if (linuxCleanup) { setInteractionCaptureCleanup(linuxCleanup); } @@ -262,6 +253,11 @@ export async function startInteractionCapture() { return; } + if (!shouldStartGlobalInteractionHook()) { + console.warn("[CursorTelemetry] Skipping the blocking global interaction hook on macOS."); + return; + } + try { const hook = loadUiohookModule(); console.log( @@ -309,13 +305,6 @@ export async function startInteractionCapture() { } setInteractionCaptureCleanup(() => { - if (linuxCleanup) { - try { - linuxCleanup(); - } catch { - // ignore - } - } try { if (typeof hook.off === "function") { hook.off("mousedown", onMouseDown); diff --git a/electron/ipc/cursor/linuxTracker.ts b/electron/ipc/cursor/linuxTracker.ts index b6a446477..677f00a25 100644 --- a/electron/ipc/cursor/linuxTracker.ts +++ b/electron/ipc/cursor/linuxTracker.ts @@ -68,45 +68,69 @@ export function startLinuxCursorTracker( const hyprSock = getHyprlandSocketPath(); if (hyprSock) { let inFlight = false; + let pollTimer: NodeJS.Timeout | null = null; + let currentSocket: net.Socket | null = null; const pollHyprlandCursor = () => { if (!active) return; if (inFlight) { - setTimeout(pollHyprlandCursor, 16); + pollTimer = setTimeout(pollHyprlandCursor, 16); return; } inFlight = true; + let buffer = ""; const client = net.createConnection(hyprSock, () => { client.write("cursorpos"); }); + currentSocket = client; + client.setTimeout(150); client.on("data", (data) => { - const parts = data.toString().trim().split(","); - if (parts.length === 2) { - const x = parseFloat(parts[0]); - const y = parseFloat(parts[1]); - if (Number.isFinite(x) && Number.isFinite(y)) { - setLinuxCursorScreenPoint({ x, y, updatedAt: Date.now() }); - } - } - client.end(); + buffer += data.toString(); }); - client.on("close", () => { + const handleClose = () => { + if (buffer) { + const parts = buffer.trim().split(","); + if (parts.length === 2) { + const x = parseFloat(parts[0]); + const y = parseFloat(parts[1]); + if (Number.isFinite(x) && Number.isFinite(y)) { + setLinuxCursorScreenPoint({ x, y, updatedAt: Date.now() }); + } + } + buffer = ""; + } inFlight = false; - if (active) setTimeout(pollHyprlandCursor, 16); + currentSocket = null; + if (active) { + pollTimer = setTimeout(pollHyprlandCursor, 16); + } + }; + + client.on("timeout", () => { + client.destroy(); }); + client.on("close", handleClose); + client.on("error", () => { - inFlight = false; - if (active) setTimeout(pollHyprlandCursor, 50); + client.destroy(); }); }; pollHyprlandCursor(); cleanups.push(() => { active = false; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + if (currentSocket) { + currentSocket.destroy(); + currentSocket = null; + } }); } diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 9f3ce7ed1..84bbb4de0 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -24,7 +24,6 @@ import { } from "../state"; import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; import { getScreen, getTelemetryPathForVideo } from "../utils"; -import { getLinuxCursorSync } from "./linuxTracker"; export function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -170,36 +169,21 @@ export function getNormalizedCursorPoint() { const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null; const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000; - const primarySf = - process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1; - - let linuxCursor = isLinuxCacheFresh ? linuxCursorCache : null; - if (process.platform === "linux" && !linuxCursor) { - const syncPoint = getLinuxCursorSync(); - if (syncPoint) { - linuxCursor = { x: syncPoint.x, y: syncPoint.y, updatedAt: Date.now() }; - } - } - - const cursor = linuxCursor - ? { x: linuxCursor.x / primarySf, y: linuxCursor.y / primarySf } - : fallbackCursor; + // Hyprland/Wayland coordinates from socket or sync are already in desktop logical DIP coordinates, + // matching Electron's display.bounds and getCursorScreenPoint(). + const cursor = + isLinuxCacheFresh && linuxCursorCache + ? { x: linuxCursorCache.x, y: linuxCursorCache.y } + : fallbackCursor; const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; if (windowBounds) { - const sf = - process.platform === "win32" || process.platform === "darwin" - ? 1 - : getScreen().getDisplayNearestPoint({ - x: windowBounds.x / primarySf, - y: windowBounds.y / primarySf, - }).scaleFactor || 1; - const width = Math.max(1, windowBounds.width / sf); - const height = Math.max(1, windowBounds.height / sf); + const width = Math.max(1, windowBounds.width); + const height = Math.max(1, windowBounds.height); return { - cx: clamp((cursor.x - windowBounds.x / sf) / width, 0, 1), - cy: clamp((cursor.y - windowBounds.y / sf) / height, 0, 1), + cx: clamp((cursor.x - windowBounds.x) / width, 0, 1), + cy: clamp((cursor.y - windowBounds.y) / height, 0, 1), }; } From c18a8d1323278bf2729d2aa40acfaf90e4d2ecd7 Mon Sep 17 00:00:00 2001 From: Md Hasan Hamid <86195509+Mr-Hasan-Hamid@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:07:48 +0530 Subject: [PATCH 12/12] fix(exporter,telemetry,clipboard): resolve review findings for canvas fallback, focus restoration, and window bounds scaling --- electron/ipc/cursor/telemetry.ts | 17 +++++++++--- src/lib/clipboard.ts | 16 +++++++++-- src/lib/exporter/modernFrameRenderer.ts | 37 +++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 729397ba5..eedf56538 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -179,12 +179,21 @@ export function getNormalizedCursorPoint() { const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; if (windowBounds) { - const width = Math.max(1, windowBounds.width); - const height = Math.max(1, windowBounds.height); + const sf = + process.platform === "win32" || + process.platform === "darwin" || + linuxCursorCache?.coordinateSpace === "logical" + ? 1 + : getScreen().getDisplayNearestPoint({ + x: windowBounds.x / primarySf, + y: windowBounds.y / primarySf, + }).scaleFactor || 1; + const width = Math.max(1, windowBounds.width / sf); + const height = Math.max(1, windowBounds.height / sf); return { - cx: clamp((cursor.x - windowBounds.x) / width, 0, 1), - cy: clamp((cursor.y - windowBounds.y) / height, 0, 1), + cx: clamp((cursor.x - windowBounds.x / sf) / width, 0, 1), + cy: clamp((cursor.y - windowBounds.y / sf) / height, 0, 1), }; } diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index 82aeab538..00a30ee2d 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -47,8 +47,10 @@ export async function copyToClipboard(text: string): Promise { // 3. Fallback: DOM-based execCommand copy if (typeof document !== "undefined") { + const activeElement = document.activeElement as HTMLElement | null; + let textArea: HTMLTextAreaElement | null = null; try { - const textArea = document.createElement("textarea"); + textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.opacity = "0"; @@ -59,12 +61,22 @@ export async function copyToClipboard(text: string): Promise { textArea.focus(); textArea.select(); const success = document.execCommand("copy"); - document.body.removeChild(textArea); if (success) { return true; } } catch { // all fallbacks exhausted + } finally { + if (textArea && textArea.parentNode) { + textArea.parentNode.removeChild(textArea); + } + if (activeElement && typeof activeElement.focus === "function") { + try { + activeElement.focus(); + } catch { + // ignore focus restore errors + } + } } } diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 0e3cfd8e9..be16d82dd 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -608,11 +608,24 @@ export class FrameRenderer { console.log(`[FrameRenderer] Export renderer backend: ${this.rendererBackend}`); } + private resolveRendererBackend( + application: Application, + fallbackBackend: ExportRenderBackend, + ): ExportRenderBackend { + const rendererName = application?.renderer?.constructor?.name?.toLowerCase() ?? ""; + if (rendererName.includes("webgpu")) { + return "webgpu"; + } + if (rendererName.includes("webgl")) { + return "webgl"; + } + return fallbackBackend; + } + private async createPixiApplication( canvas: HTMLCanvasElement, ): Promise<{ app: Application; backend: ExportRenderBackend }> { const baseOptions = { - canvas, width: this.config.width, height: this.config.height, backgroundAlpha: 0, @@ -635,6 +648,7 @@ export class FrameRenderer { ? ["webgl", "webgpu"] : ["webgl"]; const failures: PixiRendererAttempt[] = []; + let currentCanvas = canvas; for (const backend of backendOrder) { if (backend === "webgpu" && !(typeof navigator !== "undefined" && "gpu" in navigator)) { @@ -652,6 +666,7 @@ export class FrameRenderer { app, { ...baseOptions, + canvas: currentCanvas, preference: backend, }, PIXI_RENDERER_INIT_TIMEOUT_MS, @@ -666,7 +681,8 @@ export class FrameRenderer { `Renderer initialized with unsupported fallback backend after ${elapsed}ms: ${app.renderer.constructor?.name ?? "unknown"}`, ); } - return { app, backend }; + const actualBackend = this.resolveRendererBackend(app, backend); + return { app, backend: actualBackend }; } catch (error) { const elapsed = Math.round( (typeof performance === "undefined" ? Date.now() : performance.now()) - @@ -684,6 +700,23 @@ export class FrameRenderer { error, ); destroyPixiApplication(app, `${backend} export renderer initialization`); + if (typeof document !== "undefined") { + currentCanvas = document.createElement("canvas"); + currentCanvas.width = this.config.width; + currentCanvas.height = this.config.height; + try { + const exportCanvas = currentCanvas as HTMLCanvasElement & { + colorSpace?: string; + }; + if ("colorSpace" in exportCanvas) { + exportCanvas.colorSpace = "srgb"; + } + } catch { + // ignore + } + } else { + currentCanvas = {} as HTMLCanvasElement; + } } }